From f189a5f3e52fa822d2b6178f2850081d7a68066a Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:12:52 -0400 Subject: [PATCH 01/24] fix(abb): improve search reliability and result completeness - Add info hash validation with magnet link fallback in scraper - Extend exact-phrase fallback to manual queries, not just auto-generated ones - Default language to 'en' when ABB listing has no language field, preventing valid results from being hidden by the language filter --- shelfmark/release_sources/audiobookbay/scraper.py | 12 ++++++++++++ shelfmark/release_sources/audiobookbay/source.py | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/shelfmark/release_sources/audiobookbay/scraper.py b/shelfmark/release_sources/audiobookbay/scraper.py index 56468ba2..1ec47c54 100644 --- a/shelfmark/release_sources/audiobookbay/scraper.py +++ b/shelfmark/release_sources/audiobookbay/scraper.py @@ -417,6 +417,18 @@ def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") -> # Clean up info hash (remove whitespace, ensure uppercase) info_hash = re.sub(r"\s+", "", info_hash).upper() + # Validate: SHA1 = 40 hex chars, SHA256 = 64 hex chars + if not re.match(r'^[0-9A-F]{40}$|^[0-9A-F]{64}$', info_hash): + logger.warning("Info Hash invalid (got %r), trying magnet fallback.", info_hash) + # Fallback: search entire page for a complete magnet link (e.g. posted in comments) + magnet_match = re.search(r'magnet:\?xt=urn:btih:([0-9a-fA-F]{40,64})', detail_html) + if magnet_match: + info_hash = magnet_match.group(1).upper() + logger.info("Found hash via magnet fallback: %s", info_hash) + else: + logger.warning("No valid magnet link found on page, giving up.") + return None + # 2. Extract Trackers # Find all containing udp:// or http:// trackers = [] diff --git a/shelfmark/release_sources/audiobookbay/source.py b/shelfmark/release_sources/audiobookbay/source.py index f7ad32b8..66700bf4 100644 --- a/shelfmark/release_sources/audiobookbay/source.py +++ b/shelfmark/release_sources/audiobookbay/source.py @@ -238,8 +238,8 @@ def search( exact_phrase=exact_phrase, ) - # For auto-generated queries, fallback to broad matching if exact phrase returns nothing. - if exact_phrase and not results and not plan.manual_query: + # Fallback to broad matching if exact phrase returns nothing (manual or auto query). + if exact_phrase and not results: logger.info( "No exact phrase results, retrying AudiobookBay search without quotes" ) @@ -288,7 +288,7 @@ def search( size_str = result.get("size") size_bytes = parse_size(size_str) if size_str else None language_raw = result.get("language") - language_code = _map_language(language_raw) if language_raw else None + language_code = _map_language(language_raw) if language_raw else "en" bitrate = result.get("bitrate") bitrate_kbps = _parse_bitrate_to_kbps(bitrate) From 55e68a347bcc666f066ff4def02e7baeeeaba9cb Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 00:06:15 -0400 Subject: [PATCH 02/24] =?UTF-8?q?fix:=20three=20upstream=20bugs=20?= =?UTF-8?q?=E2=80=94=20mirror=20URL=20query=20params,=20rTorrent=20audiobo?= =?UTF-8?q?ok=20label,=20notification=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #999: strip query string/fragment from mirror URLs in normalize_http_url so appending search paths doesn't produce malformed URLs - #1025: add RTORRENT_AUDIOBOOK_LABEL setting; falls back to book label if unset - #956: inject configured proxy env vars before Apprise dispatch so Telegram/Discord notifications respect the app proxy setting Cherry-picked from NemesisHubris/litfinder@8e854f9 with the Apprise app_id, description, and logo-URL strings reverted from "LitFinder" back to "Shelfmark". Co-Authored-By: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> --- shelfmark/core/notifications.py | 42 ++++++++++++++++++++++++++ shelfmark/core/utils.py | 7 +++++ shelfmark/download/clients/rtorrent.py | 5 ++- shelfmark/download/clients/settings.py | 9 +++++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/shelfmark/core/notifications.py b/shelfmark/core/notifications.py index a4b2a1c2..fc7a5e23 100644 --- a/shelfmark/core/notifications.py +++ b/shelfmark/core/notifications.py @@ -393,6 +393,41 @@ def _plugin_label(plugin: object, fallback_scheme: str) -> str: return " ".join(parts) +def _apprise_proxy_env() -> dict[str, str]: + """Build proxy env vars from app config so Apprise respects the proxy setting.""" + import os + + from shelfmark.core.config import config as _cfg + + mode = str(_cfg.get("PROXY_MODE", "") or "").lower() + env: dict[str, str] = {} + + if mode == "http": + http = str(_cfg.get("HTTP_PROXY", "") or "").strip() + https = str(_cfg.get("HTTPS_PROXY", "") or "").strip() or http + if http: + env["HTTP_PROXY"] = http + env["http_proxy"] = http + if https: + env["HTTPS_PROXY"] = https + env["https_proxy"] = https + elif mode == "socks5": + socks = str(_cfg.get("SOCKS5_PROXY", "") or "").strip() + if socks: + env["HTTP_PROXY"] = socks + env["http_proxy"] = socks + env["HTTPS_PROXY"] = socks + env["https_proxy"] = socks + + no_proxy = str(_cfg.get("NO_PROXY", "") or "").strip() + if no_proxy and env: + env["NO_PROXY"] = no_proxy + env["no_proxy"] = no_proxy + + # Don't override if the user already set these in the environment directly + return {k: v for k, v in env.items() if not os.environ.get(k)} + + def _dispatch_to_apprise( urls: Iterable[str], *, @@ -400,6 +435,8 @@ def _dispatch_to_apprise( body: str, notify_type: object, ) -> dict[str, Any]: + import os + normalized_urls = _normalize_urls(list(urls)) url_schemes = _extract_url_schemes(normalized_urls) if not normalized_urls: @@ -408,6 +445,11 @@ def _dispatch_to_apprise( if apprise is None: return {"success": False, "message": "Apprise is not installed"} + proxy_env = _apprise_proxy_env() + if proxy_env: + logger.debug("Applying proxy env for Apprise dispatch: %s", list(proxy_env.keys())) + os.environ.update(proxy_env) + valid_urls = 0 invalid_urls = 0 delivered_urls = 0 diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 319d4292..a012bf4a 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -52,6 +52,13 @@ def normalize_http_url( if scheme: normalized = f"{scheme}://{normalized}" + # Strip query string and fragment — mirrors are used as base URLs for + # constructing search requests; params/fragments on the configured URL + # produce malformed URLs when paths are appended (issue #999). + parsed = urlparse(normalized) + if parsed.query or parsed.fragment: + normalized = parsed._replace(query="", fragment="").geturl() + if strip_trailing_slash: normalized = normalized.rstrip("/") diff --git a/shelfmark/download/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py index 8088faa2..9f639978 100644 --- a/shelfmark/download/clients/rtorrent.py +++ b/shelfmark/download/clients/rtorrent.py @@ -115,6 +115,7 @@ def __init__(self) -> None: self._rpc = _create_rtorrent_server_proxy(self._base_url) 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", "")) @staticmethod def is_configured() -> bool: @@ -161,7 +162,9 @@ def add_download( commands = [] - label = category or self._label + is_audiobook = kwargs.get("content_type") == "audiobook" + default_label = self._audiobook_label if is_audiobook and self._audiobook_label else self._label + label = category or default_label if label: logger.debug("Setting rTorrent label: %s", label) commands.append(f"d.custom1.set={label}") diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 67a17b55..21084c27 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -748,11 +748,18 @@ def prowlarr_clients_settings() -> list[SettingsField]: TextField( key="RTORRENT_LABEL", label="Book Label", - description="Label to assign to book downloads in rTorrent", + description="Label to assign to ebook downloads in rTorrent", placeholder="cwabd", default="cwabd", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, ), + TextField( + key="RTORRENT_AUDIOBOOK_LABEL", + label="Audiobook Label", + description="Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)", + placeholder="audiobooks", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, + ), TextField( key="RTORRENT_DOWNLOAD_DIR", label="Download Directory", From 3bed0c1ea652123874b1f027ec19891bcef5e14f Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:32:03 -0400 Subject: [PATCH 03/24] test: add regression tests for all three upstream bug fixes Cover the specific scenarios that were broken: - normalize_http_url: 5 cases for query/fragment stripping (#999) - RTorrentClient: audiobook label selection and book-label fallback (#1025) - TransmissionClient: stopped status treated as complete (seeding ratio fix) - _apprise_proxy_env: http/socks5 proxy injection and env-var preservation (#956) These tests would catch any future regression to the exact failure modes that motivated each fix. --- tests/core/test_notifications.py | 60 +++++++++++++ tests/core/test_utils.py | 27 ++++++ tests/prowlarr/test_rtorrent_client.py | 99 ++++++++++++++++++++++ tests/prowlarr/test_transmission_client.py | 36 ++++++++ 4 files changed, 222 insertions(+) diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py index 50d084c7..01de6e84 100644 --- a/tests/core/test_notifications.py +++ b/tests/core/test_notifications.py @@ -597,3 +597,63 @@ def _fake_get(key, default=None, user_id=None): {"event": "request_fulfilled", "url": "ntfys://ntfy.sh/user-main"}, {"event": "all", "url": "ntfys://ntfy.sh/user-all"}, ] + + +class TestAppriseProxyEnv: + """Regression tests for issue #956 — proxy settings ignored for notifications.""" + + def _patch_config(self, monkeypatch, values): + from shelfmark.core import config as config_module + + def _fake_get(key, default="", **_kwargs): + return values.get(key, default) + + monkeypatch.setattr(config_module.config, "get", _fake_get) + + def test_http_proxy_mode_injects_proxy_env(self, monkeypatch): + self._patch_config(monkeypatch, { + "PROXY_MODE": "http", + "HTTP_PROXY": "http://proxy.example.com:8080", + "HTTPS_PROXY": "", + "NO_PROXY": "", + }) + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + + result = notifications_module._apprise_proxy_env() + + assert result["HTTP_PROXY"] == "http://proxy.example.com:8080" + assert result["HTTPS_PROXY"] == "http://proxy.example.com:8080" + + def test_socks5_proxy_mode_injects_socks_env(self, monkeypatch): + self._patch_config(monkeypatch, { + "PROXY_MODE": "socks5", + "SOCKS5_PROXY": "socks5://proxy.example.com:1080", + "NO_PROXY": "", + }) + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("HTTPS_PROXY", raising=False) + + result = notifications_module._apprise_proxy_env() + + assert result["HTTP_PROXY"] == "socks5://proxy.example.com:1080" + assert result["HTTPS_PROXY"] == "socks5://proxy.example.com:1080" + + def test_no_proxy_mode_returns_empty_dict(self, monkeypatch): + self._patch_config(monkeypatch, {"PROXY_MODE": ""}) + + result = notifications_module._apprise_proxy_env() + + assert result == {} + + def test_does_not_override_already_set_env_vars(self, monkeypatch): + self._patch_config(monkeypatch, { + "PROXY_MODE": "http", + "HTTP_PROXY": "http://new-proxy.example.com:8080", + "NO_PROXY": "", + }) + monkeypatch.setenv("HTTP_PROXY", "http://existing-proxy.example.com:3128") + + result = notifications_module._apprise_proxy_env() + + assert "HTTP_PROXY" not in result diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 8869a7e2..4876b476 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -4,7 +4,34 @@ import types import xmlrpc.client as stdlib_xmlrpc_client +import pytest + from shelfmark.core import utils +from shelfmark.core.utils import normalize_http_url + + +class TestNormalizeHttpUrlQueryStripping: + """Regression tests for issue #999 — mirror URLs with query params/fragments.""" + + def test_strips_query_string_from_configured_url(self) -> None: + result = normalize_http_url("http://mirror.example.com/search?token=abc123") + assert result == "http://mirror.example.com/search" + + def test_strips_fragment_from_configured_url(self) -> None: + result = normalize_http_url("http://mirror.example.com/search#section") + assert result == "http://mirror.example.com/search" + + def test_strips_both_query_and_fragment(self) -> None: + result = normalize_http_url("https://mirror.example.com/path?key=val&x=1#top") + assert result == "https://mirror.example.com/path" + + def test_plain_url_unchanged(self) -> None: + result = normalize_http_url("http://mirror.example.com/search") + assert result == "http://mirror.example.com/search" + + def test_trailing_slash_still_stripped_after_query_removal(self) -> None: + result = normalize_http_url("http://mirror.example.com/?token=x") + assert result == "http://mirror.example.com" def test_get_hardened_xmlrpc_client_tolerates_patch_runtime_error(monkeypatch) -> None: diff --git a/tests/prowlarr/test_rtorrent_client.py b/tests/prowlarr/test_rtorrent_client.py index b4160ffe..eee954cc 100644 --- a/tests/prowlarr/test_rtorrent_client.py +++ b/tests/prowlarr/test_rtorrent_client.py @@ -349,6 +349,105 @@ def test_add_download_failure(self, monkeypatch): assert "RPC Error" in str(excinfo.value) +class TestRTorrentClientAudiobookLabel: + """Regression tests for issue #1025 — rTorrent audiobook label selection.""" + + def _make_client(self, monkeypatch, config_values): + monkeypatch.setattr( + "shelfmark.download.clients.rtorrent.config.get", + make_config_getter(config_values), + ) + mock_rpc = MagicMock() + mock_xmlrpc = create_mock_xmlrpc_module() + mock_xmlrpc.ServerProxy.return_value = mock_rpc + + mock_torrent_info = MagicMock() + mock_torrent_info.torrent_data = None + mock_torrent_info.magnet_url = "magnet:?xt=urn:btih:abc123" + mock_torrent_info.info_hash = "abc123" + mock_torrent_info.is_magnet = True + + return mock_rpc, mock_xmlrpc, mock_torrent_info + + def test_uses_audiobook_label_when_content_type_is_audiobook(self, monkeypatch): + config_values = { + "RTORRENT_URL": "http://localhost:8080/RPC2", + "RTORRENT_LABEL": "books", + "RTORRENT_AUDIOBOOK_LABEL": "audiobooks", + "RTORRENT_DOWNLOAD_DIR": "/downloads", + } + mock_rpc, mock_xmlrpc, mock_torrent_info = self._make_client(monkeypatch, config_values) + + with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}): + with patch( + "shelfmark.download.clients.torrent_utils.extract_torrent_info", + return_value=mock_torrent_info, + ): + if "shelfmark.download.clients.rtorrent" in sys.modules: + del sys.modules["shelfmark.download.clients.rtorrent"] + from shelfmark.download.clients.rtorrent import RTorrentClient + + client = RTorrentClient() + client.add_download( + "magnet:?xt=urn:btih:abc123", "Test Audiobook", content_type="audiobook" + ) + + args = mock_rpc.load.start.call_args[0] + assert "d.custom1.set=audiobooks" in args[2] + assert "d.custom1.set=books" not in args[2] + + def test_falls_back_to_book_label_when_audiobook_label_not_set(self, monkeypatch): + config_values = { + "RTORRENT_URL": "http://localhost:8080/RPC2", + "RTORRENT_LABEL": "books", + "RTORRENT_AUDIOBOOK_LABEL": "", + "RTORRENT_DOWNLOAD_DIR": "/downloads", + } + mock_rpc, mock_xmlrpc, mock_torrent_info = self._make_client(monkeypatch, config_values) + + with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}): + with patch( + "shelfmark.download.clients.torrent_utils.extract_torrent_info", + return_value=mock_torrent_info, + ): + if "shelfmark.download.clients.rtorrent" in sys.modules: + del sys.modules["shelfmark.download.clients.rtorrent"] + from shelfmark.download.clients.rtorrent import RTorrentClient + + client = RTorrentClient() + client.add_download( + "magnet:?xt=urn:btih:abc123", "Test Audiobook", content_type="audiobook" + ) + + args = mock_rpc.load.start.call_args[0] + assert "d.custom1.set=books" in args[2] + + def test_uses_book_label_for_non_audiobook_content(self, monkeypatch): + config_values = { + "RTORRENT_URL": "http://localhost:8080/RPC2", + "RTORRENT_LABEL": "books", + "RTORRENT_AUDIOBOOK_LABEL": "audiobooks", + "RTORRENT_DOWNLOAD_DIR": "/downloads", + } + mock_rpc, mock_xmlrpc, mock_torrent_info = self._make_client(monkeypatch, config_values) + + with patch.dict("sys.modules", {"xmlrpc.client": mock_xmlrpc}): + with patch( + "shelfmark.download.clients.torrent_utils.extract_torrent_info", + return_value=mock_torrent_info, + ): + if "shelfmark.download.clients.rtorrent" in sys.modules: + del sys.modules["shelfmark.download.clients.rtorrent"] + from shelfmark.download.clients.rtorrent import RTorrentClient + + client = RTorrentClient() + client.add_download("magnet:?xt=urn:btih:abc123", "Test Book") + + args = mock_rpc.load.start.call_args[0] + assert "d.custom1.set=books" in args[2] + assert "d.custom1.set=audiobooks" not in args[2] + + class TestRTorrentClientGetStatus: """Tests for RTorrentClient.get_status().""" diff --git a/tests/prowlarr/test_transmission_client.py b/tests/prowlarr/test_transmission_client.py index d9ae897c..3a80fdad 100644 --- a/tests/prowlarr/test_transmission_client.py +++ b/tests/prowlarr/test_transmission_client.py @@ -383,6 +383,42 @@ def test_get_status_seeding(self, monkeypatch): assert status.complete is True assert "/downloads/Test Torrent" in status.file_path + def test_get_status_stopped_treated_as_complete(self, monkeypatch): + """Regression: torrents stopped after seeding ratio/idle limit must show complete.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "shelfmark.download.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent( + percent_done=1.0, + status="stopped", + download_dir="/downloads", + ) + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "shelfmark.download.clients.transmission" in sys.modules: + del sys.modules["shelfmark.download.clients.transmission"] + + from shelfmark.download.clients.transmission import TransmissionClient + + client = TransmissionClient() + status = client.get_status("abc123") + + assert status.complete is True + assert status.progress == 100.0 + def test_get_status_not_found(self, monkeypatch): """Test status for non-existent torrent.""" config_values = { From c72e6d92e70da35b8261ad79a7559927d5ac536a Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:32:10 -0400 Subject: [PATCH 04/24] fix: replace Python 2 except syntax across 27 files All instances of `except X, Y:` (Python 2 syntax) replaced with `except (X, Y):` (Python 3). These were SyntaxErrors that would prevent the affected modules from importing at runtime. --- shelfmark/config/env.py | 6 ++-- shelfmark/config/settings.py | 6 ++-- shelfmark/core/activity_routes.py | 2 +- shelfmark/core/auth_modes.py | 4 +-- shelfmark/core/config.py | 4 +-- shelfmark/core/image_cache.py | 6 ++-- shelfmark/core/logger.py | 6 ++-- shelfmark/core/request_helpers.py | 6 ++-- shelfmark/core/self_user_routes.py | 2 +- shelfmark/core/user_db.py | 2 +- shelfmark/download/clients/deluge.py | 2 +- shelfmark/download/clients/sabnzbd.py | 6 ++-- shelfmark/download/clients/settings.py | 2 +- shelfmark/download/clients/torrent_utils.py | 4 +-- shelfmark/download/http.py | 2 +- shelfmark/download/outputs/email.py | 4 +-- shelfmark/download/permissions_debug.py | 6 ++-- shelfmark/download/postprocess/transfer.py | 2 +- shelfmark/download/postprocess/workspace.py | 4 +-- shelfmark/main.py | 14 ++++---- shelfmark/metadata_providers/__init__.py | 2 +- shelfmark/metadata_providers/hardcover.py | 26 +++++++------- shelfmark/metadata_providers/openlibrary.py | 8 ++--- shelfmark/release_sources/direct_download.py | 35 ++++++++++++++++--- shelfmark/release_sources/irc/cache.py | 2 +- shelfmark/release_sources/newznab/source.py | 2 +- shelfmark/release_sources/prowlarr/torznab.py | 2 +- 27 files changed, 96 insertions(+), 71 deletions(-) diff --git a/shelfmark/config/env.py b/shelfmark/config/env.py index 8eda0486..522d3c70 100644 --- a/shelfmark/config/env.py +++ b/shelfmark/config/env.py @@ -28,7 +28,7 @@ def _read_debug_from_config() -> bool: config = json.load(f) if "DEBUG" in config: return bool(config["DEBUG"]) - except json.JSONDecodeError, OSError: + except (json.JSONDecodeError, OSError): pass return False @@ -40,7 +40,7 @@ def _is_sqlite_file(path: Path) -> bool: with path.open("rb") as f: header = f.read(16) return header[:16] == b"SQLite format 3\x00" - except OSError, PermissionError: + except (OSError, PermissionError): return False @@ -68,7 +68,7 @@ def _is_config_dir_writable() -> bool: test_file = CONFIG_DIR / ".write_test" test_file.touch() test_file.unlink() - except OSError, PermissionError: + except (OSError, PermissionError): return False else: return True diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 9340293b..ce40284a 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -762,7 +762,7 @@ def _is_plain_email_address(addr: str) -> bool: try: port = int(effective.get("EMAIL_SMTP_PORT", 587)) - except TypeError, ValueError: + except (TypeError, ValueError): return {"error": True, "message": "SMTP port must be a number", "values": values} if port < 1 or port > _SMTP_PORT_MAX: @@ -774,7 +774,7 @@ def _is_plain_email_address(addr: str) -> bool: try: timeout_seconds = int(effective.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60)) - except TypeError, ValueError: + except (TypeError, ValueError): return { "error": True, "message": "SMTP timeout (seconds) must be a number", @@ -799,7 +799,7 @@ def _is_plain_email_address(addr: str) -> bool: try: attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)) - except TypeError, ValueError: + except (TypeError, ValueError): return { "error": True, "message": "Attachment size limit (MB) must be a number", diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py index 4bf8ffc3..4a776c15 100644 --- a/shelfmark/core/activity_routes.py +++ b/shelfmark/core/activity_routes.py @@ -170,7 +170,7 @@ def _resolve_db_user_id( ) try: parsed_db_user_id = int(raw_db_user_id) - except TypeError, ValueError: + except (TypeError, ValueError): if not require_in_auth_mode: return None, None return None, _activity_error_response( diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py index 81c81948..497be60f 100644 --- a/shelfmark/core/auth_modes.py +++ b/shelfmark/core/auth_modes.py @@ -49,7 +49,7 @@ def has_local_password_admin(user_db: object | None = None) -> bool: if not _has_admin_password_api(db): return False return db.has_admin_with_password() - except AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error: + except (AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error): return False @@ -119,7 +119,7 @@ def load_active_auth_mode( has_local_admin=has_local_password_admin(user_db), disable_local_auth=DISABLE_LOCAL_AUTH, ) - except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error: + except (ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error): return "none" diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index d42d2de4..a5913534 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -167,7 +167,7 @@ def _get_user_db(self) -> UserDB | None: db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db") user_db = user_db_cls(db_path) user_db.initialize() - except ImportError, OSError, sqlite3.Error: + except (ImportError, OSError, sqlite3.Error): # Multi-user support is optional; fall back to global config when unavailable. return None else: @@ -186,7 +186,7 @@ def _get_user_settings(self, user_id: int) -> dict[str, Any]: try: settings = user_db.get_user_settings(user_id) - except sqlite3.OperationalError, OSError, ValueError, TypeError: + except (sqlite3.OperationalError, OSError, ValueError, TypeError): return {} if not isinstance(settings, dict): diff --git a/shelfmark/core/image_cache.py b/shelfmark/core/image_cache.py index 8587af68..9c363ea3 100644 --- a/shelfmark/core/image_cache.py +++ b/shelfmark/core/image_cache.py @@ -112,7 +112,7 @@ def _load_index(self) -> None: try: with self.index_path.open() as f: self._index = json.load(f) - except OSError, json.JSONDecodeError: + except (OSError, json.JSONDecodeError): self._index = {} def _sync_index_with_files(self) -> None: @@ -495,7 +495,7 @@ def _prepare_safe_url(url: str) -> str | None: return None parsed = urlparse(prepared_url) hostname = parsed.hostname - except requests.exceptions.RequestException, ValueError: + except (requests.exceptions.RequestException, ValueError): return None if not prepared_url: @@ -519,7 +519,7 @@ def _prepare_safe_url(url: str) -> str | None: ip = ipaddress.ip_address(sockaddr[0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return None - except socket.gaierror, ValueError: + except (socket.gaierror, ValueError): return None return prepared_url diff --git a/shelfmark/core/logger.py b/shelfmark/core/logger.py index 0bc9f990..c7bbf042 100644 --- a/shelfmark/core/logger.py +++ b/shelfmark/core/logger.py @@ -78,10 +78,10 @@ def _get_process_rss_mb(proc: object) -> float | None: proc_rss_mb = _get_process_rss_mb(proc) if proc_rss_mb is not None: app_memory_mb += proc_rss_mb - except PermissionError, psutil.AccessDenied, OSError: + except (PermissionError, psutil.AccessDenied, OSError): try: app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024) - except AttributeError, OSError, psutil.Error: + except (AttributeError, OSError, psutil.Error): app_memory_mb = 0.0 memory = psutil.virtual_memory() @@ -92,7 +92,7 @@ def _get_process_rss_mb(proc: object) -> float | None: f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, " f"Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%" ) - except AttributeError, OSError, psutil.Error: + except (AttributeError, OSError, psutil.Error): # Avoid breaking the original log call if psutil is missing or restricted. return diff --git a/shelfmark/core/request_helpers.py b/shelfmark/core/request_helpers.py index 5ac9bfb5..22eaf0b1 100644 --- a/shelfmark/core/request_helpers.py +++ b/shelfmark/core/request_helpers.py @@ -101,7 +101,7 @@ def get_session_db_user_id(session_obj: object) -> int | None: raw = session_obj.get("db_user_id") if _is_mapping_with_get(session_obj) else None try: return int(raw) if raw is not None and _is_convertible_to_int(raw) else None - except TypeError, ValueError: + except (TypeError, ValueError): return None @@ -111,7 +111,7 @@ def coerce_int(value: object, default: int) -> int: return default try: return int(value) - except TypeError, ValueError: + except (TypeError, ValueError): return default @@ -129,7 +129,7 @@ def normalize_positive_int(value: object) -> int | None: return None try: parsed = int(value) - except TypeError, ValueError: + except (TypeError, ValueError): return None return parsed if parsed > 0 else None diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index 481feda4..1e380efc 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -60,7 +60,7 @@ def _get_current_user( return None, None, (jsonify({"error": "Invalid user context"}), 400) try: user_id = int(raw_user_id) - except TypeError, ValueError: + except (TypeError, ValueError): return None, None, (jsonify({"error": "Invalid user context"}), 400) user = user_db.get_user(user_id=user_id) diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 954e84cb..98633fc1 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -497,7 +497,7 @@ def _parse_request_row(row: sqlite3.Row | None) -> dict[str, Any] | None: continue try: payload[key] = json.loads(raw_value) - except ValueError, TypeError: + except (ValueError, TypeError): payload[key] = None return payload diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py index cbaf3399..79a578de 100644 --- a/shelfmark/download/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -372,7 +372,7 @@ def get_status(self, download_id: str) -> DownloadStatus: if eta is not None: try: eta = int(eta) - except TypeError, ValueError: + except (TypeError, ValueError): eta = None if eta is not None and (eta < 0 or eta > ONE_WEEK_IN_SECONDS): diff --git a/shelfmark/download/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py index db3ca1e8..4d185e2d 100644 --- a/shelfmark/download/clients/sabnzbd.py +++ b/shelfmark/download/clients/sabnzbd.py @@ -59,7 +59,7 @@ def _parse_eta(eta_str: str) -> int | None: parts = eta_str.split(":") if len(parts) == _ETA_PART_COUNT: return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) - except ValueError, IndexError: + except (ValueError, IndexError): pass return None @@ -71,7 +71,7 @@ def _parse_speed(slot: dict) -> int | None: if kbpersec_str: try: return int(float(kbpersec_str) * 1024) - except ValueError, TypeError: + except (ValueError, TypeError): pass # Fall back to human-readable speed field @@ -90,7 +90,7 @@ def _parse_speed(slot: dict) -> int | None: if prefix in unit: return int(speed_val * mult) return int(speed_val) - except ValueError, IndexError: + except (ValueError, IndexError): return None diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 21084c27..64a6ce6f 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -313,7 +313,7 @@ def get_daemon_version(session: requests.Session, rpc_id: int) -> Any: methods = rpc_call(session, rpc_id, "system.listMethods") if isinstance(methods, list) and "daemon.get_version" in methods: return rpc_call(session, rpc_id + 1, "daemon.get_version") - except requests.exceptions.RequestException, RuntimeError, ValueError, TypeError: + except (requests.exceptions.RequestException, RuntimeError, ValueError, TypeError): # Fall back to daemon.info to preserve existing behavior. pass diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index 3478556c..7b854019 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -340,7 +340,7 @@ def extract_btmh(value: str) -> str | None: padded = raw_value.upper() + "=" * (-len(raw_value) % 8) try: data = base64.b32decode(padded, casefold=True) - except BinasciiError, ValueError: + except (BinasciiError, ValueError): return None if not data: @@ -378,7 +378,7 @@ def extract_btmh(value: str) -> str | None: if re.match(r"^[A-Z2-7]{32}$", hash_value.upper()): try: return base64.b32decode(hash_value.upper()).hex().lower() - except BinasciiError, ValueError: + except (BinasciiError, ValueError): logger.debug( "Could not decode base32 BTIH hash from magnet URI: %s", hash_value ) diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py index 8460e28e..ef5eb64d 100644 --- a/shelfmark/download/http.py +++ b/shelfmark/download/http.py @@ -176,7 +176,7 @@ def parse_size_string(size: str) -> float | None: if normalized.endswith(suffix): return float(normalized[:-2]) * mult return float(normalized) - except ValueError, IndexError: + except (ValueError, IndexError): return None diff --git a/shelfmark/download/outputs/email.py b/shelfmark/download/outputs/email.py index 2cdc5ee7..c72d85ec 100644 --- a/shelfmark/download/outputs/email.py +++ b/shelfmark/download/outputs/email.py @@ -146,7 +146,7 @@ def _parse_attachment_limit_mb(value: object) -> int: return 25 try: return int(value) - except TypeError, ValueError: + except (TypeError, ValueError): return 25 @@ -164,7 +164,7 @@ def _render_subject(template: str, task: DownloadTask) -> str: } try: rendered = template.format(**mapping) - except IndexError, KeyError, ValueError: + except (IndexError, KeyError, ValueError): rendered = template rendered = " ".join(str(rendered).split()).strip() diff --git a/shelfmark/download/permissions_debug.py b/shelfmark/download/permissions_debug.py index 1a7247fb..76ccc864 100644 --- a/shelfmark/download/permissions_debug.py +++ b/shelfmark/download/permissions_debug.py @@ -68,7 +68,7 @@ def _format_uid(uid: int) -> str: import pwd return pwd.getpwuid(uid).pw_name - except ImportError, KeyError: + except (ImportError, KeyError): return str(uid) @@ -77,7 +77,7 @@ def _format_gid(gid: int) -> str: import grp return grp.getgrgid(gid).gr_name - except ImportError, KeyError: + except (ImportError, KeyError): return str(gid) @@ -105,7 +105,7 @@ def log_path_permission_context(label: str, path: Path) -> None: for probe in [path, path.parent]: try: resolved = _run_io(probe.resolve) - except OSError, RuntimeError: + except (OSError, RuntimeError): resolved = probe try: diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index a0f5b7f5..55c04fc0 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -124,7 +124,7 @@ def is_torrent_source(source_path: Path, task: DownloadTask) -> bool: original_path = Path(task.original_download_path) try: return run_blocking_io(source_path.resolve) == run_blocking_io(original_path.resolve) - except OSError, ValueError: + except (OSError, ValueError): return os.path.normpath(str(source_path)) == os.path.normpath(str(original_path)) diff --git a/shelfmark/download/postprocess/workspace.py b/shelfmark/download/postprocess/workspace.py index f8cafd80..85bc0c0c 100644 --- a/shelfmark/download/postprocess/workspace.py +++ b/shelfmark/download/postprocess/workspace.py @@ -41,7 +41,7 @@ def is_within_tmp_dir(path: Path) -> bool: try: run_blocking_io(path.resolve).relative_to(run_blocking_io(tmp_dir.resolve)) - except OSError, ValueError: + except (OSError, ValueError): return False else: return True @@ -62,7 +62,7 @@ def _is_original_download(path: Path | None, task: DownloadTask) -> bool: try: original = Path(task.original_download_path) return run_blocking_io(path.resolve) == run_blocking_io(original.resolve) - except OSError, ValueError: + except (OSError, ValueError): return False diff --git a/shelfmark/main.py b/shelfmark/main.py index 46fdbff9..dc97e33b 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -364,7 +364,7 @@ def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[st for raw_category in categories: try: category_id = int(raw_category) - except TypeError, ValueError: + except (TypeError, ValueError): continue if min_cat <= category_id <= max_cat: return "audiobook", True @@ -408,7 +408,7 @@ def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> if db_user_id is not None: try: user_settings = user_db.get_user_settings(int(db_user_id)) - except TypeError, ValueError: + except (TypeError, ValueError): user_settings = None effective = merge_request_policy_settings(global_settings, user_settings) @@ -480,7 +480,7 @@ def _resolve_download_user_context( try: target_user_id = int(on_behalf_of_user_id) - except TypeError, ValueError: + except (TypeError, ValueError): return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400) if target_user_id <= 0: @@ -753,7 +753,7 @@ def get_proxy_header(header_name: str) -> str | None: if raw_db_user_id is not None: try: session_db_user = user_db.get_user(user_id=int(raw_db_user_id)) - except TypeError, ValueError: + except (TypeError, ValueError): session_db_user = None session_db_username = ( @@ -829,7 +829,7 @@ def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Resp ): return jsonify({"error": "Admin access required"}), 403 - except RuntimeError, TypeError, ValueError: + except (RuntimeError, TypeError, ValueError): logger.exception("Admin access check error") return jsonify({"error": "Internal Server Error"}), 500 @@ -1259,7 +1259,7 @@ def _notify_admin_for_terminal_download_status( raw_owner_user_id = getattr(task, "user_id", None) try: owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None - except TypeError, ValueError: + except (TypeError, ValueError): owner_user_id = None content_type = normalize_optional_text(getattr(task, "content_type", None)) @@ -1451,7 +1451,7 @@ def _task_owned_by_actor( raw_task_user_id = getattr(task, "user_id", None) try: task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None - except TypeError, ValueError: + except (TypeError, ValueError): task_user_id = None if actor_user_id is not None and task_user_id is not None: diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index deebc95c..58aaac91 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -411,7 +411,7 @@ def _get_book_targets_for_batch(self, book_id: str) -> list[dict[str, Any]]: """Safely fetch targets for one book, falling back to an empty list.""" try: return self.get_book_targets(book_id) - except NotImplementedError, ValueError: + except (NotImplementedError, ValueError): return [] def set_book_target_state( diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 06a2a6e6..904873a2 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -581,12 +581,12 @@ def _extract_publish_year(data: dict) -> int | None: if data.get("release_year"): try: return int(data["release_year"]) - except ValueError, TypeError: + except (ValueError, TypeError): pass if data.get("release_date"): try: return int(str(data["release_date"])[:4]) - except ValueError, TypeError: + except (ValueError, TypeError): pass return None @@ -613,7 +613,7 @@ def _normalize_series_position(value: Any) -> float | None: try: return float(value) - except TypeError, ValueError: + except (TypeError, ValueError): return None @@ -1060,7 +1060,7 @@ def _fetch_list_books_by_id(self, list_id: int, page: int, limit: int) -> Search try: books_count = int(books_count_raw) - except TypeError, ValueError: + except (TypeError, ValueError): books_count = 0 books: list[BookMetadata] = [] @@ -1330,7 +1330,7 @@ def _search_title_options(self, query: str) -> list[dict[str, str]]: try: if release_year is not None and int(release_year) > current_year: continue - except TypeError, ValueError: + except (TypeError, ValueError): pass label = str(item.get("title") or "").strip() @@ -1363,7 +1363,7 @@ def _format_series_option_description(self, item: dict[str, Any]) -> str | None: if books_count is not None: books_count_int = int(books_count) parts.append(f"{books_count_int} book{'s' if books_count_int != 1 else ''}") - except TypeError, ValueError: + except (TypeError, ValueError): pass return " • ".join(parts) if parts else None @@ -1728,7 +1728,7 @@ def _fetch_user_books_by_status(self, status_id: int, page: int, limit: int) -> try: total_found = int(count_raw) - except TypeError, ValueError: + except (TypeError, ValueError): total_found = 0 books: list[BookMetadata] = [] @@ -1783,7 +1783,7 @@ def _fetch_user_lists(self) -> list[dict[str, str]]: def _format_label(name: str, books_count: Any) -> str: try: return f"{name} ({int(books_count)})" - except TypeError, ValueError: + except (TypeError, ValueError): return name for status in HARDCOVER_STATUSES: @@ -2434,7 +2434,7 @@ def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> Sear books=books, page=options.page, total_found=found_count, has_more=has_more ) - except AttributeError, KeyError, TypeError, ValueError: + except (AttributeError, KeyError, TypeError, ValueError): logger.exception("Hardcover search error") return SearchResult(books=[], page=options.page, total_found=0, has_more=False) @@ -2512,7 +2512,7 @@ def get_book(self, book_id: str) -> BookMetadata | None: except ValueError: logger.exception("Invalid book ID: %s", book_id) return None - except AttributeError, KeyError, TypeError: + except (AttributeError, KeyError, TypeError): logger.exception("Hardcover get_book error") return None @@ -2583,7 +2583,7 @@ def search_by_isbn(self, isbn: str) -> BookMetadata | None: return self._parse_book(book_data) - except AttributeError, IndexError, KeyError, TypeError, ValueError: + except (AttributeError, IndexError, KeyError, TypeError, ValueError): logger.exception("Hardcover ISBN search error") return None @@ -2857,7 +2857,7 @@ def _parse_book(self, book: dict) -> BookMetadata: if rating is not None: try: rating_str = f"{float(rating):.1f}" - except TypeError, ValueError: + except (TypeError, ValueError): rating_str = str(rating) if ratings_count: @@ -2870,7 +2870,7 @@ def _parse_book(self, book: dict) -> BookMetadata: if users_count: try: readers_value = f"{int(users_count):,}" - except TypeError, ValueError: + except (TypeError, ValueError): readers_value = str(users_count) display_fields.append(DisplayField(label="Readers", value=readers_value, icon="users")) diff --git a/shelfmark/metadata_providers/openlibrary.py b/shelfmark/metadata_providers/openlibrary.py index 3417bd4c..ce2353c3 100644 --- a/shelfmark/metadata_providers/openlibrary.py +++ b/shelfmark/metadata_providers/openlibrary.py @@ -222,7 +222,7 @@ def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> list except requests.RequestException: logger.exception("Open Library search request failed") return [] - except TypeError, ValueError: + except (TypeError, ValueError): logger.exception("Open Library search parsing error") return [] return books @@ -261,7 +261,7 @@ def get_book(self, book_id: str) -> BookMetadata | None: except requests.RequestException: logger.exception("Open Library get_book request failed") return None - except TypeError, ValueError: + except (TypeError, ValueError): logger.exception("Open Library get_book parsing error") return None @@ -322,7 +322,7 @@ def search_by_isbn(self, isbn: str) -> BookMetadata | None: except requests.RequestException: logger.exception("Open Library ISBN search request failed") return None - except TypeError, ValueError: + except (TypeError, ValueError): logger.exception("Open Library ISBN search parsing error") return None @@ -513,7 +513,7 @@ def _get_author_name(self, author_key: str) -> str | None: author = response.json() return author.get("name") - except requests.RequestException, ValueError: + except (requests.RequestException, ValueError): # Don't log errors for author lookups - they're supplementary return None diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index c919cbdf..c264adb6 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -574,7 +574,7 @@ def _parse_book_info_page( slow_urls_no_waitlist.add(href) else: slow_urls_with_waitlist.add(href) - except AttributeError, TypeError: + except (AttributeError, TypeError): pass logger.debug( @@ -1229,6 +1229,9 @@ def _get_download_url( return downloader.get_absolute_url(link, url) +_AA_COUNTDOWN_MAX_RETRIES = 3 + + def _extract_slow_download_url( soup: BeautifulSoup, link: str, @@ -1237,6 +1240,7 @@ def _extract_slow_download_url( status_callback: Callable[[str, str | None], None] | None, selector: network.AAMirrorSelector, source_context: str | None = None, + _countdown_attempts: int = 0, ) -> str: """Extract download URL from AA slow download pages.""" html_str = str(soup) @@ -1301,6 +1305,14 @@ def _extract_slow_download_url( countdown_seconds = _extract_countdown_seconds(soup, html_str) if countdown_seconds > 0: + if _countdown_attempts >= _AA_COUNTDOWN_MAX_RETRIES: + logger.warning( + "Countdown retry limit (%s) reached for %s, giving up", + _AA_COUNTDOWN_MAX_RETRIES, + title, + ) + return "" + max_countdown_seconds = 600 sleep_time = min(countdown_seconds, max_countdown_seconds) if countdown_seconds > max_countdown_seconds: @@ -1309,7 +1321,7 @@ def _extract_slow_download_url( countdown_seconds, max_countdown_seconds, ) - logger.info("AA waitlist: %ss for %s", sleep_time, title) + logger.info("AA waitlist: %ss for %s (attempt %s/%s)", sleep_time, title, _countdown_attempts + 1, _AA_COUNTDOWN_MAX_RETRIES) # Live countdown with status updates for remaining in range(sleep_time, 0, -1): @@ -1330,8 +1342,21 @@ def _extract_slow_download_url( if status_callback and source_context: status_callback("resolving", f"{source_context} - Fetching") - return _get_download_url( - link, title, cancel_flag, status_callback, selector, source_context + html = downloader.html_get_page( + link, selector=selector, cancel_flag=cancel_flag, status_callback=status_callback + ) + if not html: + return "" + new_soup = BeautifulSoup(_html_response_text(html), "html.parser") + return _extract_slow_download_url( + new_soup, + link, + title, + cancel_flag, + status_callback, + selector, + source_context, + _countdown_attempts + 1, ) link_texts = [a.get_text(strip=True)[:50] for a in soup.find_all("a", href=True)[:10]] @@ -1398,7 +1423,7 @@ def _parse_countdown_seconds_from_element(element: Tag) -> int | None: """Parse an integer countdown from a tag, returning None when invalid.""" try: seconds = int(element.get_text(strip=True)) - except ValueError, TypeError: + except (ValueError, TypeError): return None if 0 < seconds < _AA_COUNTDOWN_MAX_SECONDS: diff --git a/shelfmark/release_sources/irc/cache.py b/shelfmark/release_sources/irc/cache.py index e0b4e844..bc64e93f 100644 --- a/shelfmark/release_sources/irc/cache.py +++ b/shelfmark/release_sources/irc/cache.py @@ -97,7 +97,7 @@ def _dict_to_release(data: dict[str, Any]) -> Release: if data.get("protocol"): try: data["protocol"] = ReleaseProtocol(data["protocol"]) - except ValueError, KeyError: + except (ValueError, KeyError): data["protocol"] = None return Release(**data) diff --git a/shelfmark/release_sources/newznab/source.py b/shelfmark/release_sources/newznab/source.py index 966d6d0d..ad32d5d2 100644 --- a/shelfmark/release_sources/newznab/source.py +++ b/shelfmark/release_sources/newznab/source.py @@ -97,7 +97,7 @@ def add_flag(flag: object) -> None: try: if download_volume_factor is not None and float(download_volume_factor) == 0.0: is_freeleech = True - except TypeError, ValueError: + except (TypeError, ValueError): pass if any(f.lower() in {"freeleech", "fl"} for f in indexer_flags): diff --git a/shelfmark/release_sources/prowlarr/torznab.py b/shelfmark/release_sources/prowlarr/torznab.py index f354bb88..2d277b1f 100644 --- a/shelfmark/release_sources/prowlarr/torznab.py +++ b/shelfmark/release_sources/prowlarr/torznab.py @@ -70,7 +70,7 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]: try: root = DefusedElementTree.fromstring(xml_text) - except DefusedElementTree.ParseError, DefusedXmlException: + except (DefusedElementTree.ParseError, DefusedXmlException): return [] items = root.findall(".//item") From 91e43287f4196b9624fab331b9e084368e1dcddb Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:32:15 -0400 Subject: [PATCH 05/24] fix(#1040): clean up empty destination dir when write probe fails validate_destination() now tracks whether it created the directory itself. If the write probe then fails, the empty directory is removed so nothing is orphaned on disk. --- shelfmark/download/postprocess/destination.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py index 76c875fc..0f0c406c 100644 --- a/shelfmark/download/postprocess/destination.py +++ b/shelfmark/download/postprocess/destination.py @@ -40,9 +40,11 @@ def validate_destination( status_callback("error", f"Destination is not a directory: {destination}") return False + created_by_us = False if not destination_exists: try: run_blocking_io(destination.mkdir, parents=True, exist_ok=True) + created_by_us = True except (OSError, PermissionError) as exc: log_path_permission_context("destination_create", destination) logger.warning("Cannot create destination: %s (%s)", destination, exc) @@ -63,6 +65,11 @@ def validate_destination( log_path_permission_context("destination_write_probe", destination) logger.warning("Destination not writable: %s (%s)", destination, exc) status_callback("error", f"Destination not writable: {destination} ({exc})") + if created_by_us: + try: + run_blocking_io(destination.rmdir) + except OSError: + pass return False return True From 0b7ba5f689d2814ec9bcb5ff3496899f11832ee7 Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:32:19 -0400 Subject: [PATCH 06/24] fix(#1010): refresh activity snapshot after cancel handleCancel only called fetchStatus(), leaving a gap where the item had left the live queue but not yet appeared in the DB snapshot. Both are now refreshed in parallel so cancelled items stay visible. --- src/frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 367d440f..a1990eec 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1491,7 +1491,7 @@ function App() { const handleCancel = async (id: string) => { try { await cancelDownload(id); - await fetchStatus(); + await Promise.all([fetchStatus(), refreshActivitySnapshot()]); } catch (error) { console.error('Cancel failed:', error); showToast('Failed to cancel/clear download', 'error'); From cec601cef1269b420650e4e7ea118b80b785d2bb Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:32:25 -0400 Subject: [PATCH 07/24] fix(#1021): cap AA slow-download countdown retries to prevent infinite loop _extract_slow_download_url was calling _get_download_url recursively after each countdown wait with no depth limit. Pages that repeatedly return a countdown (common through Tor) caused an infinite loop. Now retries are bounded by _AA_COUNTDOWN_MAX_RETRIES (3). Also fixed tor.sh rotation_monitor sending pkill -HUP twice in one cycle when DNS resolution failed. --- tor.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tor.sh b/tor.sh index a5c223aa..60df653d 100644 --- a/tor.sh +++ b/tor.sh @@ -304,15 +304,20 @@ rotation_monitor() { echo "[*] Circuit rotation #$rotation_count at $(date)" # Test DNS resolution through Tor + dns_ok=true if ! timeout 10 nslookup google.com 127.0.0.1 > /dev/null 2>&1; then echo "[!] $(date): DNS resolution slow/failing, rotating circuits..." pkill -HUP tor || true sleep 10 + dns_ok=false fi # Proactively rotate circuits every 5 minutes to keep them fresh - echo "[*] $(date): Proactive circuit rotation..." - pkill -HUP tor || true + # Skip if we already rotated for DNS failure this cycle + if $dns_ok; then + echo "[*] $(date): Proactive circuit rotation..." + pkill -HUP tor || true + fi # Verify Tor is still responsive after rotation sleep 5 From 187d0a1cf4f3b51186c55ecdb44dc451f6d49ef2 Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:26:21 -0400 Subject: [PATCH 08/24] feat: detect language from AA distant path (PR #1031) When DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH is enabled, parse the file path column in AA search results (e.g. lgli/N:\...\[BD FR] Book.cbz) to infer language when AA's own metadata is missing or unknown. Detection priority: 1. Explicit bracket tags: [FR], [BD FR], [En] 2. Keyed markers: "BD FR", "language: fr" 3. Full language names: "french", "deutsch" 4. Loose 2-3 char codes (ambiguous ones like "en"/"de" require bracket or key context to avoid false positives) When enabled with a language filter, the server-side &lang= parameter is suppressed and filtering is done locally so lgli files without AA language metadata are not excluded before the path can be inspected. Also relaxes _parse_search_result_row to only require title + format, allowing sparse lgli rows (missing author/publisher/year) to pass through. --- shelfmark/config/settings.py | 11 + shelfmark/release_sources/direct_download.py | 257 ++++++++++++++++++- tests/config/test_download_settings.py | 14 + tests/direct_download/test_search_queries.py | 165 ++++++++++++ 4 files changed, 434 insertions(+), 13 deletions(-) diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index ce40284a..a3265609 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -1448,6 +1448,17 @@ def download_source_settings() -> list[SettingsField]: ), default=False, ), + CheckboxField( + key="DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH", + label="Detect Language From Distant Path", + description=( + "When language metadata is missing or unknown, parse the distant path " + "(file path shown in search results) for language tags like [BD FR] or [En]. " + "Also enables local language filtering so lgli files without AA language " + "metadata are not excluded before the distant path can be checked." + ), + default=False, + ), PasswordField( key="AA_DONATOR_KEY", label="Account Donator Key", diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index c264adb6..feeaa0bf 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -3,9 +3,12 @@ import itertools import json import re +import threading import time +import unicodedata from dataclasses import replace from http import HTTPStatus +from pathlib import Path from typing import TYPE_CHECKING, ClassVar, NoReturn, TypedDict from urllib.parse import quote, urlparse @@ -197,6 +200,39 @@ def _tag_has_class_containing(tag: Tag, needle: str) -> bool: _MIN_VALID_FILE_SIZE = 10 * 1024 _AA_COUNTDOWN_MAX_SECONDS = 300 +# --- Distant-path language detection --- + +_DISTANT_PATH_EXTENSIONS = ( + "epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr", + "pdf", "zip", "rar", "m4b", "mp3", +) +_DISTANT_PATH_EXTENSION_PATTERN = "|".join(re.escape(e) for e in _DISTANT_PATH_EXTENSIONS) +_DISTANT_PATH_PATTERN = re.compile( + rf"(?:[A-Za-z0-9._-]+/)?[A-Za-z]:(?:\\|/)[^\n\r<>\"]+?\.(?:{_DISTANT_PATH_EXTENSION_PATTERN})\b", + re.IGNORECASE, +) +_DISTANT_PATH_FALLBACK_PATTERN = re.compile( + r"(?:[A-Za-z0-9._-]+/)?[A-Za-z]:(?:\\|/)[^\n\r<>\"]+", + re.IGNORECASE, +) +_BRACKETED_LANGUAGE_CODE_PATTERN = re.compile( + r"\[(?:bd[\s._-]*)?([A-Za-z]{2,3})\]", + re.IGNORECASE, +) +_KEYED_LANGUAGE_CODE_PATTERN = re.compile( + r"\b(?:bd|lang(?:uage)?)\s*[:._-]?\s*([A-Za-z]{2,3})\b", + re.IGNORECASE, +) +_LANGUAGE_CODE_TOKEN_PATTERN = re.compile( + r"(?:^|[\s_./\\\-\[(])([A-Za-z]{2,3})(?=$|[\s_./\\\-)\]])" +) +_LANGUAGE_NAME_TOKEN_PATTERN = re.compile(r"[a-z]{4,}(?:-[a-z0-9]+)?") +_LANGUAGE_ALIAS_TO_CODE: dict[str, str] | None = None +_LANGUAGE_ALIAS_LOCK = threading.Lock() +_LANGUAGE_PLACEHOLDERS = frozenset({"", "-", "--", "unknown", "unk", "n/a", "na"}) +# Short codes that appear in common words — require bracket/key context to accept +_AMBIGUOUS_SHORT_LANGUAGE_CODES = frozenset({"de", "en", "it", "la", "no", "or", "is", "in"}) + # Sources that require Cloudflare bypass _CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "welib"}) @@ -204,6 +240,190 @@ def _tag_has_class_containing(tag: Tag, needle: str) -> bool: _AA_PAGE_SOURCES = frozenset({"aa-slow-nowait", "aa-slow-wait"}) +def _is_language_from_path_enabled() -> bool: + return bool(config.get("DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH", False)) + + +def _normalize_language_token(value: str) -> str: + normalized = value.strip().lower() + for dash in ("‑", "–", "—", "−"): + normalized = normalized.replace(dash, "-") + return normalized + + +def _fold_text(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + return "".join(c for c in normalized if not unicodedata.combining(c)).lower() + + +def _language_alias_to_code() -> dict[str, str]: + """Build alias→code map from bundled language metadata (lazy, cached).""" + global _LANGUAGE_ALIAS_TO_CODE + cached = _LANGUAGE_ALIAS_TO_CODE + if cached is not None: + return cached + + with _LANGUAGE_ALIAS_LOCK: + cached = _LANGUAGE_ALIAS_TO_CODE + if cached is not None: + return cached + + mapping: dict[str, str] = {} + data_path = Path(__file__).resolve().parents[2] / "data" / "book-languages.json" + + try: + raw = json.loads(data_path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + _LANGUAGE_ALIAS_TO_CODE = {} + return _LANGUAGE_ALIAS_TO_CODE + + if not isinstance(raw, list): + _LANGUAGE_ALIAS_TO_CODE = {} + return _LANGUAGE_ALIAS_TO_CODE + + for item in raw: + if not isinstance(item, dict): + continue + code = _normalize_language_token(str(item.get("code", ""))) + name = _normalize_language_token(str(item.get("language", ""))) + if not code: + continue + mapping.setdefault(code, code) + mapping.setdefault(code.replace("-", "_"), code) + mapping.setdefault(code.split("-")[0], code) + mapping.setdefault(_fold_text(code), code) + if name: + mapping.setdefault(name, code) + mapping.setdefault(_fold_text(name), code) + + _LANGUAGE_ALIAS_TO_CODE = mapping + return _LANGUAGE_ALIAS_TO_CODE + + +def _extract_distant_path(row: Tag, *, enabled: bool) -> str | None: + """Extract the Windows-style file path from an AA search result row.""" + if not enabled: + return None + + def _normalize_candidate(text: str) -> str: + normalized = re.sub(r"\s*([\\/])\s*", r"\1", text) + normalized = re.sub(r":\s*([\\/])", r":\1", normalized) + normalized = re.sub( + r"\s+\.(epub|mobi|azw3|fb2|djvu|cbz|cbr|pdf|zip|rar|m4b|mp3)\b", + r".\1", + normalized, + flags=re.IGNORECASE, + ) + return normalized + + candidates = [row.get_text(" ", strip=True)] + for cell in row.find_all("td"): + cell_text = cell.get_text(" ", strip=True) + if cell_text: + candidates.append(cell_text) + + best: str | None = None + for text in candidates: + for match in _DISTANT_PATH_PATTERN.findall(_normalize_candidate(text)): + candidate = match.strip().rstrip(".,;") + if best is None or len(candidate) > len(best): + best = candidate + + if best is not None: + return best + + for text in candidates: + for match in _DISTANT_PATH_FALLBACK_PATTERN.findall(_normalize_candidate(text)): + candidate = match.strip().rstrip(".,;") + if best is None or len(candidate) > len(best): + best = candidate + + return best + + +def _detect_language_from_distant_path(path: str | None) -> str | None: + """Infer a language code from distant-path tags such as [BD FR] or [Fr].""" + if not path: + return None + + aliases = _language_alias_to_code() + if not aliases: + return None + + folded_path = _fold_text(path) + strong_candidates: list[str] = [] + + for code in _BRACKETED_LANGUAGE_CODE_PATTERN.findall(path): + normalized = _normalize_language_token(code) + if normalized in aliases: + strong_candidates.append(aliases[normalized]) + + for code in _KEYED_LANGUAGE_CODE_PATTERN.findall(path): + normalized = _normalize_language_token(code) + if normalized in aliases: + strong_candidates.append(aliases[normalized]) + + non_ambiguous = [c for c in strong_candidates if c not in _AMBIGUOUS_SHORT_LANGUAGE_CODES] + if non_ambiguous: + return non_ambiguous[0] + + for token in _LANGUAGE_NAME_TOKEN_PATTERN.findall(folded_path): + normalized = _normalize_language_token(token) + if normalized in aliases: + candidate = aliases[normalized] + if candidate not in _AMBIGUOUS_SHORT_LANGUAGE_CODES: + return candidate + + if strong_candidates: + return strong_candidates[0] + + for code in _LANGUAGE_CODE_TOKEN_PATTERN.findall(path): + normalized = _normalize_language_token(code) + if normalized in _AMBIGUOUS_SHORT_LANGUAGE_CODES: + continue + if normalized in aliases: + return aliases[normalized] + + return None + + +def _is_missing_or_placeholder_language(language: str | None) -> bool: + if language is None: + return True + return _normalize_language_token(language) in _LANGUAGE_PLACEHOLDERS + + +def _normalize_requested_languages(languages: list[str] | None) -> set[str]: + if not languages: + return set() + aliases = _language_alias_to_code() + normalized: set[str] = set() + for value in languages: + token = _normalize_language_token(str(value)) + if not token or token == "all": + continue + normalized.add(aliases.get(token, token)) + return normalized + + +def _book_matches_requested_languages(book_language: str | None, requested: set[str]) -> bool: + """Return True when a book's language matches the requested filter. + + Books with unknown/missing language always pass — the server-side &lang= filter + already narrowed the result set, so dropping unlabelled rows hides valid results. + """ + if not requested: + return True + if not book_language: + return True + aliases = _language_alias_to_code() + normalized_book = aliases.get( + _normalize_language_token(book_language), + _normalize_language_token(book_language), + ) + return normalized_book in requested + + def _is_configured_zlib_link(url: str) -> bool: """Return True when a URL belongs to a configured Z-Library mirror.""" from shelfmark.core.mirrors import get_zlib_cookie_domains @@ -360,9 +580,17 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]: filters_query = "" - for value in filters.lang or []: - if value and value != "all": - filters_query += f"&lang={quote(value)}" + path_language_enabled = _is_language_from_path_enabled() + requested_langs = _normalize_requested_languages(filters.lang) + + # When path-language inference is on and a language is requested, skip the + # server-side &lang= filter: lgli files often have no AA language metadata + # and would be excluded before we can infer language from the distant path. + # Local filtering below handles the narrowing instead. + if not (path_language_enabled and requested_langs): + for value in filters.lang or []: + if value and value != "all": + filters_query += f"&lang={quote(value)}" if filters.sort and filters.sort != "relevance": filters_query += f"&sort={quote(filters.sort)}" @@ -418,6 +646,9 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]: if book: books.append(book) + if path_language_enabled and requested_langs: + books = [b for b in books if _book_matches_requested_languages(b.language, requested_langs)] + supported_formats = _get_supported_formats() books.sort( @@ -471,6 +702,9 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None: if not record_id: return None + path_language_enabled = _is_language_from_path_enabled() + distant_path = _extract_distant_path(row, enabled=path_language_enabled) + preview_img = cells[0].find("img") preview = _get_attr(preview_img, "src") if isinstance(preview_img, Tag) else None @@ -483,18 +717,14 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None: file_format = _first_stripped_text(cells[9].find("span")) size = _first_stripped_text(cells[10].find("span")) - if ( - title is None - or author is None - or publisher is None - or year is None - or language is None - or content is None - or file_format is None - or size is None - ): + # Only title and format are truly required — lgli rows often have sparse metadata + if title is None or file_format is None: return None + if path_language_enabled and _is_missing_or_placeholder_language(language): + detected = _detect_language_from_distant_path(distant_path) + language = detected or "unknown" + return BrowseRecord( id=record_id, title=title, @@ -507,6 +737,7 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None: content=content.lower() if content else None, format=file_format.lower() if file_format else None, size=size, + download_path=distant_path, ) except (AttributeError, IndexError, KeyError, TypeError) as e: logger.error_trace(f"Error parsing search result row: {e}") diff --git a/tests/config/test_download_settings.py b/tests/config/test_download_settings.py index 556b1a1f..f2943712 100644 --- a/tests/config/test_download_settings.py +++ b/tests/config/test_download_settings.py @@ -368,6 +368,20 @@ def test_download_source_settings_include_direct_download_toggle(): assert "Add your own mirror URLs" in toggle_field.description +def test_download_source_settings_include_distant_path_language_toggle(): + from shelfmark.config.settings import download_source_settings + + fields = download_source_settings() + toggle_field = next( + field + for field in fields + if getattr(field, "key", None) == "DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH" + ) + + assert toggle_field.default is False + assert "distant path" in toggle_field.description.lower() + + def test_fast_source_options_lock_entries_without_mirror_or_donator_requirements(monkeypatch): from shelfmark.config.settings import _get_fast_source_options diff --git a/tests/direct_download/test_search_queries.py b/tests/direct_download/test_search_queries.py index 8bd36527..582b5847 100644 --- a/tests/direct_download/test_search_queries.py +++ b/tests/direct_download/test_search_queries.py @@ -182,3 +182,168 @@ def fake_search_books(query: str, filters): ("mistborn custom query", ["en"], ["epub"]), ("mistborn custom query", None, ["epub"]), ] + + +# --- Distant-path language detection tests --- + +def _patch_path_language(monkeypatch, enabled: bool = True): + import shelfmark.release_sources.direct_download as dd + + original_get = dd.config.get + + def _fake_get(key: str, default=None, user_id=None): + del user_id + if key == "DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH": + return enabled + return original_get(key, default) + + monkeypatch.setattr(dd.config, "get", _fake_get) + return dd + + +def _row_from_html(html: str): + from bs4 import BeautifulSoup + return BeautifulSoup(html, "html.parser").find("tr") + + +def _make_row(distant_path: str, language: str = "", record_id: str = "rec-1") -> str: + return rf""" + + + A Book Title + Author Name + Publisher + 2024 + - + - + {language} + fiction + epub + 1 mb + {distant_path} + + """ + + +def test_detects_bracketed_language_from_distant_path(monkeypatch): + dd = _patch_path_language(monkeypatch) + row = _row_from_html(_make_row(r"lgli/N:\comics1\emule\2021.08.01\[BD FR] Scrameustache.cbz")) + record = dd._parse_search_result_row(row) + assert record is not None + assert record.language == "fr" + assert record.download_path is not None + + +def test_detects_mixed_case_bracketed_language(monkeypatch): + dd = _patch_path_language(monkeypatch) + row = _row_from_html(_make_row(r"lgli/V:\comics\_0DAY3\[Fr]\BDs [Fr]\!Pdf\S\Book.pdf")) + record = dd._parse_search_result_row(row) + assert record is not None + assert record.language == "fr" + + +def test_overrides_unknown_language_with_path_detection(monkeypatch): + dd = _patch_path_language(monkeypatch) + row = _row_from_html(_make_row(r"lgli/V:\comics\_0DAY3\[Fr]\Book.pdf", language="unknown")) + record = dd._parse_search_result_row(row) + assert record is not None + assert record.language == "fr" + + +def test_sets_unknown_when_path_has_no_language(monkeypatch): + dd = _patch_path_language(monkeypatch) + row = _row_from_html(_make_row(r"lgli/N:\comics1\emule\NoLanguageHere.epub")) + record = dd._parse_search_result_row(row) + assert record is not None + assert record.language == "unknown" + + +def test_avoids_en_false_positive_when_french_present(monkeypatch): + dd = _patch_path_language(monkeypatch) + row = _row_from_html(_make_row( + r"lgli/V:\comics\_0DAY2\Stripboeken Frans - BD en Français\[BD Fr] Book.cbr" + )) + record = dd._parse_search_result_row(row) + assert record is not None + assert record.language == "fr" + + +def test_keeps_row_with_missing_language_when_toggle_disabled(monkeypatch): + dd = _patch_path_language(monkeypatch, enabled=False) + row = _row_from_html(_make_row(r"lgli/N:\comics1\[BD FR] Scrameustache.cbz")) + record = dd._parse_search_result_row(row) + assert record is not None + assert record.language is None + + +def test_keeps_sparse_lgli_row(monkeypatch): + """lgli rows missing author/publisher/year must not be dropped.""" + dd = _patch_path_language(monkeypatch) + html = r""" + + + Gos - 1978 - Le scrameustache T06.cbz + + Comic book + cbz + 17.4MB + lgli/N:\comics1\ftp\[BD.FR] French Comics\Book.cbz + + """ + record = dd._parse_search_result_row(_row_from_html(html)) + assert record is not None + assert record.id == "sparse-1" + assert record.language == "fr" + assert record.author is None + + +def test_search_books_filters_locally_when_path_language_enabled(monkeypatch): + dd = _patch_path_language(monkeypatch) + monkeypatch.setattr(dd.network, "get_aa_base_url", lambda: "https://mirror.example") + monkeypatch.setattr(dd.network, "AAMirrorSelector", lambda: object()) + + captured_url: dict[str, str] = {} + + def _fake_html_get_page(url: str, selector, allow_bypasser_fallback=False): + del selector, allow_bypasser_fallback + captured_url["url"] = url + return r""" + + + + + + + + + + + + + + + + + + + +
Livre FRAuteurEditeur2025--fictionpdf2 mblgli/V:\comics\_0DAY3\[Fr]\Book FR.pdf
Book ENAuthorPublisher2025--fictionpdf2 mblgli/V:\comics\_0DAY3\[En]\Book EN.pdf
+ """ + + monkeypatch.setattr(dd.downloader, "html_get_page", _fake_html_get_page) + + records = dd.search_books("demo", SearchFilters(lang=["fr"], format=["pdf"])) + + assert "&lang=" not in captured_url["url"] + assert len(records) == 1 + assert records[0].id == "rec-fr" + assert records[0].language == "fr" + + +def test_book_matches_requested_languages_logic(): + import shelfmark.release_sources.direct_download as dd + + assert dd._book_matches_requested_languages(None, {"fr"}) is True + assert dd._book_matches_requested_languages(None, set()) is True + assert dd._book_matches_requested_languages("en", {"fr"}) is False + assert dd._book_matches_requested_languages("fr", {"fr"}) is True From 5dddda8bf45d3be63c624aeeab7f2325e2d5ec0f Mon Sep 17 00:00:00 2001 From: NemesisHubris <155838970+NemesisHubris@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:17:33 -0400 Subject: [PATCH 09/24] feat: show display_name in activity feed for admin users Propagates display_name alongside username through the full stack so the activity sidebar shows friendly names instead of raw usernames. Backend enriches queue status, download history, and request snapshots. Frontend uses display_name in meta lines, the user filter dropdown, and filter tooltips, falling back to username when display_name is absent. Also adds relative timestamps (e.g. "2h ago") beneath each activity card's meta line. --- patches/shelfmark/core/activity_routes.py | 1107 +++++++++++++++++ patches/shelfmark/core/request_helpers.py | 189 +++ shelfmark/core/activity_routes.py | 23 +- shelfmark/core/request_helpers.py | 13 +- shelfmark/main.py | 20 + .../src/components/activity/ActivityCard.tsx | 29 + .../components/activity/ActivitySidebar.tsx | 39 +- .../components/activity/activityMappers.ts | 24 +- .../src/components/activity/activityTypes.ts | 1 + src/frontend/src/hooks/useActivity.ts | 6 + src/frontend/src/types/index.ts | 4 +- 11 files changed, 1431 insertions(+), 24 deletions(-) create mode 100644 patches/shelfmark/core/activity_routes.py create mode 100644 patches/shelfmark/core/request_helpers.py diff --git a/patches/shelfmark/core/activity_routes.py b/patches/shelfmark/core/activity_routes.py new file mode 100644 index 00000000..c529dfe9 --- /dev/null +++ b/patches/shelfmark/core/activity_routes.py @@ -0,0 +1,1107 @@ +"""Activity API routes (snapshot, dismiss, history).""" + +from __future__ import annotations + +import sqlite3 +from typing import TYPE_CHECKING, Any, NamedTuple + +from flask import Flask, Response, jsonify, request, session + +from shelfmark.core.activity_view_state_service import ( + ADMIN_VIEWER_SCOPE, + NOAUTH_VIEWER_SCOPE, + ActivityViewStateService, + user_viewer_scope, +) +from shelfmark.core.download_history_service import ( + ACTIVE_DOWNLOAD_STATUS, + VALID_TERMINAL_STATUSES, + DownloadHistoryService, +) +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import ( + ACTIVE_QUEUE_STATUSES, + TERMINAL_QUEUE_STATUSES, + QueueStatus, +) +from shelfmark.core.request_helpers import ( + emit_ws_event, + extract_release_source_id, + normalize_positive_int, + populate_request_usernames, +) +from shelfmark.core.request_validation import RequestStatus + +if TYPE_CHECKING: + from collections.abc import Callable + + from shelfmark.core.user_db import UserDB + +logger = setup_logger(__name__) +_USER_DB_IDENTITY_ERRORS = (sqlite3.Error, OSError) +type ActivityRouteResponse = tuple[Response, int] + + +def _normalize_log_field(value: object) -> str: + if value is None: + return "-" + text = str(value).strip() + return text or "-" + + +def _log_activity_rejection( + action: str, + *, + status_code: int, + reason: str, + auth_mode: object = None, + viewer_scope: object = None, + item_type: object = None, + item_key: object = None, + item_count: int | None = None, + missing_item_keys: list[str] | None = None, + owner_user_id: object = None, + final_status: object = None, + request_id: object = None, +) -> None: + parts = [ + f"Activity {action} rejected", + f"status={status_code}", + f"reason={_normalize_log_field(reason)}", + f"method={request.method}", + f"path={request.path}", + f"user={_normalize_log_field(session.get('user_id'))}", + f"db_user_id={_normalize_log_field(session.get('db_user_id'))}", + f"is_admin={bool(session.get('is_admin', False))}", + ] + if auth_mode is not None: + parts.append(f"auth_mode={_normalize_log_field(auth_mode)}") + if viewer_scope is not None: + parts.append(f"viewer_scope={_normalize_log_field(viewer_scope)}") + if item_type is not None: + parts.append(f"item_type={_normalize_log_field(item_type)}") + if item_key is not None: + parts.append(f"item_key={_normalize_log_field(item_key)}") + if item_count is not None: + parts.append(f"item_count={item_count}") + if missing_item_keys: + parts.append(f"missing_item_keys={','.join(missing_item_keys)}") + if owner_user_id is not None: + parts.append(f"owner_user_id={_normalize_log_field(owner_user_id)}") + if final_status is not None: + parts.append(f"final_status={_normalize_log_field(final_status)}") + if request_id is not None: + parts.append(f"request_id={_normalize_log_field(request_id)}") + logger.warning(" ".join(parts)) + + +def _activity_error_response( + action: str, + *, + status_code: int, + error: str, + code: str | None = None, + auth_mode: object = None, + viewer_scope: object = None, + item_type: object = None, + item_key: object = None, + item_count: int | None = None, + missing_item_keys: list[str] | None = None, + owner_user_id: object = None, + final_status: object = None, + request_id: object = None, +) -> tuple[Response, int]: + _log_activity_rejection( + action, + status_code=status_code, + reason=error, + auth_mode=auth_mode, + viewer_scope=viewer_scope, + item_type=item_type, + item_key=item_key, + item_count=item_count, + missing_item_keys=missing_item_keys, + owner_user_id=owner_user_id, + final_status=final_status, + request_id=request_id, + ) + + payload: dict[str, Any] = {"error": error} + if code: + payload["code"] = code + if missing_item_keys: + payload["missing_item_keys"] = missing_item_keys + return jsonify(payload), status_code + + +def _require_authenticated( + resolve_auth_mode: Callable[[], str], *, action: str +) -> tuple[Response, int] | None: + auth_mode = resolve_auth_mode() + if auth_mode == "none": + return None + if "user_id" not in session: + return _activity_error_response( + action, + status_code=401, + error="Unauthorized", + auth_mode=auth_mode, + ) + return None + + +def _resolve_db_user_id( + *, + require_in_auth_mode: bool = True, + user_db: UserDB | None = None, + action: str | None = None, + auth_mode: str | None = None, +) -> tuple[int | None, tuple[Response, int] | None]: + raw_db_user_id = session.get("db_user_id") + if raw_db_user_id is None: + if not require_in_auth_mode: + return None, None + return None, _activity_error_response( + action or "request", + status_code=403, + error="User identity unavailable for activity workflow", + code="user_identity_unavailable", + auth_mode=auth_mode, + ) + try: + parsed_db_user_id = int(raw_db_user_id) + except (TypeError, ValueError): + if not require_in_auth_mode: + return None, None + return None, _activity_error_response( + action or "request", + status_code=403, + error="User identity unavailable for activity workflow", + code="user_identity_unavailable", + auth_mode=auth_mode, + ) + + if parsed_db_user_id < 1: + if not require_in_auth_mode: + return None, None + return None, _activity_error_response( + action or "request", + status_code=403, + error="User identity unavailable for activity workflow", + code="user_identity_unavailable", + auth_mode=auth_mode, + ) + + if user_db is not None: + try: + db_user = user_db.get_user(user_id=parsed_db_user_id) + except _USER_DB_IDENTITY_ERRORS as exc: + logger.warning("Failed to validate activity db identity %s: %s", parsed_db_user_id, exc) + db_user = None + if db_user is None: + if not require_in_auth_mode: + return None, None + return None, _activity_error_response( + action or "request", + status_code=403, + error="User identity unavailable for activity workflow", + code="user_identity_unavailable", + auth_mode=auth_mode, + ) + + return parsed_db_user_id, None + + +class _ActorContext(NamedTuple): + db_user_id: int | None + is_no_auth: bool + is_admin: bool + owner_scope: int | None + viewer_scope: str + + +type ActivityActorResolution = tuple[_ActorContext, None] | tuple[None, ActivityRouteResponse] + + +def _require_activity_actor(actor: _ActorContext | None, *, action: str) -> _ActorContext: + """Convert a resolved actor into the non-optional form route handlers expect.""" + if actor is None: + msg = f"Activity actor missing after successful resolution for {action}" + raise RuntimeError(msg) + return actor + + +def _resolve_activity_actor( + *, + user_db: UserDB, + resolve_auth_mode: Callable[[], str], + action: str, +) -> ActivityActorResolution: + """Resolve acting user identity for activity mutations. + + Returns (actor, error_response). On success actor is non-None. + """ + auth_mode = resolve_auth_mode() + if auth_mode == "none": + return _ActorContext( + db_user_id=None, + is_no_auth=True, + is_admin=True, + owner_scope=None, + viewer_scope=NOAUTH_VIEWER_SCOPE, + ), None + + db_user_id, db_gate = _resolve_db_user_id( + user_db=user_db, + action=action, + auth_mode=auth_mode, + ) + if db_user_id is None: + if db_gate is None: + msg = f"Activity actor resolution failed without an error response for {action}" + raise RuntimeError(msg) + return None, db_gate + + is_admin = bool(session.get("is_admin")) + viewer_scope = ADMIN_VIEWER_SCOPE if is_admin else user_viewer_scope(db_user_id) + return _ActorContext( + db_user_id=db_user_id, + is_no_auth=False, + is_admin=is_admin, + owner_scope=None if is_admin else db_user_id, + viewer_scope=viewer_scope, + ), None + + +def _activity_ws_room(actor: _ActorContext) -> str: + """Resolve the WebSocket room for activity events.""" + if actor.is_no_auth or actor.is_admin: + return "admins" + if actor.db_user_id is not None: + return f"user_{actor.db_user_id}" + return "admins" + + +def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> str | None: + """Return an error string if the actor doesn't own the item, else None.""" + if actor.is_admin: + return None + owner_user_id = normalize_positive_int(row.get("user_id")) + if owner_user_id != actor.db_user_id: + return "Forbidden" + return None + + +def _check_terminal_download(row: dict[str, Any]) -> str | None: + final_status = str(row.get("final_status") or "").strip().lower() + if final_status not in VALID_TERMINAL_STATUSES: + return "Only terminal downloads can be dismissed" + return None + + +def _check_terminal_request(row: dict[str, Any]) -> str | None: + if _request_terminal_status(row) is None: + return "Only terminal requests can be dismissed" + return None + + +def _download_row_log_context(row: dict[str, Any]) -> dict[str, Any]: + return { + "owner_user_id": normalize_positive_int(row.get("user_id")), + "final_status": row.get("final_status"), + "request_id": normalize_positive_int(row.get("request_id")), + } + + +def _request_row_log_context(row: dict[str, Any]) -> dict[str, Any]: + return { + "owner_user_id": normalize_positive_int(row.get("user_id")), + "request_id": normalize_positive_int(row.get("id")), + } + + +def _list_visible_requests( + user_db: UserDB, *, is_admin: bool, db_user_id: int | None +) -> list[dict[str, Any]]: + if is_admin: + request_rows = user_db.list_requests() + populate_request_usernames(request_rows, user_db) + return request_rows + + if db_user_id is None: + return [] + return user_db.list_requests(user_id=db_user_id) + + +def _parse_item_key(item_key: object, prefix: str) -> str | None: + """Extract the value after 'prefix:' from an item_key string.""" + if not isinstance(item_key, str) or not item_key.startswith(f"{prefix}:"): + return None + value = item_key.split(":", 1)[1].strip() + return value or None + + +_ALL_BUCKET_KEYS = (*ACTIVE_QUEUE_STATUSES, *TERMINAL_QUEUE_STATUSES) + + +def _build_queue_index( + queue_status: dict[str, dict[str, Any]], +) -> dict[str, tuple[str, dict[str, Any]]]: + """Index live queue entries by task id for fast activity lookups.""" + queue_index: dict[str, tuple[str, dict[str, Any]]] = {} + for bucket_key in _ALL_BUCKET_KEYS: + bucket = queue_status.get(bucket_key) + if not isinstance(bucket, dict): + continue + for task_id, payload in bucket.items(): + normalized_bucket_key = ( + bucket_key.value if isinstance(bucket_key, QueueStatus) else str(bucket_key) + ) + queue_index[str(task_id)] = (normalized_bucket_key, payload) + return queue_index + + +def _effective_download_row_for_activity( + row: dict[str, Any], + *, + has_live_queue_entry: bool, +) -> dict[str, Any]: + """Treat stale persisted active rows as interrupted failures for activity APIs.""" + final_status = str(row.get("final_status") or "").strip().lower() + if final_status != ACTIVE_DOWNLOAD_STATUS or has_live_queue_entry: + return row + + effective_row = dict(row) + effective_row["final_status"] = QueueStatus.ERROR.value + effective_row["retry_final_status"] = final_status + + status_message = effective_row.get("status_message") + if not isinstance(status_message, str) or not status_message.strip(): + effective_row["status_message"] = "Interrupted" + + return effective_row + + +def _build_download_status_from_db( + *, + db_rows: list[dict[str, Any]], + queue_status: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + """Build the download status dict from DB rows, overlaying live queue data. + + Active DB rows are matched against the queue for live progress. + Terminal DB rows go directly into their final bucket. + Stale active rows (no queue entry) are treated as interrupted errors. + """ + status: dict[str, dict[str, Any]] = {key: {} for key in _ALL_BUCKET_KEYS} + queue_index = _build_queue_index(queue_status) + + for row in db_rows: + task_id = str(row.get("task_id") or "").strip() + if not task_id: + continue + + final_status = row.get("final_status") + queue_entry = queue_index.pop(task_id, None) + + if final_status == ACTIVE_DOWNLOAD_STATUS: + if queue_entry is not None: + bucket_key, queue_payload = queue_entry + status[bucket_key][task_id] = queue_payload + else: + effective_row = _effective_download_row_for_activity( + row, + has_live_queue_entry=False, + ) + download_payload = DownloadHistoryService.to_download_payload(effective_row) + status[QueueStatus.ERROR][task_id] = download_payload + elif final_status in VALID_TERMINAL_STATUSES: + download_payload = DownloadHistoryService.to_download_payload(row) + if queue_entry is not None: + _, queue_payload = queue_entry + if isinstance(queue_payload, dict) and "retry_available" in queue_payload: + download_payload["retry_available"] = bool(queue_payload.get("retry_available")) + # For complete/cancelled the saved status_message is a stale + # progress string (e.g. "Fetching download sources") — clear it + # so the frontend only shows its own status label. Error rows + # keep theirs since the message describes the failure. + if final_status in ("complete", "cancelled"): + download_payload["status_message"] = None + status[final_status][task_id] = download_payload + + return status + + +def _request_terminal_status(row: dict[str, Any]) -> str | None: + request_status = row.get("status") + if request_status == RequestStatus.PENDING: + return None + if request_status == RequestStatus.REJECTED: + return RequestStatus.REJECTED + if request_status == RequestStatus.CANCELLED: + return RequestStatus.CANCELLED + if request_status != RequestStatus.FULFILLED: + return None + + delivery_state = str(row.get("delivery_state") or "").strip().lower() + if delivery_state in {QueueStatus.ERROR, QueueStatus.CANCELLED}: + return delivery_state + return QueueStatus.COMPLETE + + +def _minimal_request_snapshot(request_row: dict[str, Any], request_id: int) -> dict[str, Any]: + book_data = request_row.get("book_data") + release_data = request_row.get("release_data") + if not isinstance(book_data, dict): + book_data = {} + if not isinstance(release_data, dict): + release_data = {} + + minimal_request = { + "id": request_id, + "user_id": request_row.get("user_id"), + "status": request_row.get("status"), + "request_level": request_row.get("request_level"), + "delivery_state": request_row.get("delivery_state"), + "book_data": book_data, + "release_data": release_data, + "note": request_row.get("note"), + "admin_note": request_row.get("admin_note"), + "created_at": request_row.get("created_at"), + "updated_at": request_row.get("reviewed_at") or request_row.get("created_at"), + } + username = request_row.get("username") + if isinstance(username, str): + minimal_request["username"] = username + display_name = request_row.get("display_name") + if display_name is not None: + minimal_request["display_name"] = display_name + return {"kind": "request", "request": minimal_request} + + +def _request_history_entry( + request_row: dict[str, Any], + *, + dismissed_at: str | None, +) -> dict[str, Any] | None: + request_id = normalize_positive_int(request_row.get("id")) + if request_id is None: + return None + final_status = _request_terminal_status(request_row) + item_key = f"request:{request_id}" + return { + "id": item_key, + "user_id": request_row.get("user_id"), + "item_type": "request", + "item_key": item_key, + "dismissed_at": dismissed_at, + "snapshot": _minimal_request_snapshot(request_row, request_id), + "origin": "request", + "final_status": final_status, + "terminal_at": request_row.get("reviewed_at") or request_row.get("created_at"), + "request_id": request_id, + "source_id": extract_release_source_id(request_row.get("release_data")), + } + + +def register_activity_routes( + app: Flask, + user_db: UserDB, + *, + activity_view_state_service: ActivityViewStateService, + download_history_service: DownloadHistoryService, + resolve_auth_mode: Callable[[], str], + queue_status: Callable[..., dict[str, dict[str, Any]]], + sync_request_delivery_states: Callable[..., list[dict[str, Any]]], + emit_request_updates: Callable[[list[dict[str, Any]]], None], + ws_manager: object | None = None, +) -> None: + """Register activity routes.""" + + @app.route("/api/activity/snapshot", methods=["GET"]) + def api_activity_snapshot() -> Response | tuple[Response, int]: + auth_gate = _require_authenticated(resolve_auth_mode, action="snapshot") + if auth_gate is not None: + return auth_gate + + actor, actor_error = _resolve_activity_actor( + user_db=user_db, + resolve_auth_mode=resolve_auth_mode, + action="snapshot", + ) + if actor_error is not None: + return actor_error + actor = _require_activity_actor(actor, action="snapshot") + + hidden_rows = activity_view_state_service.list_hidden(viewer_scope=actor.viewer_scope) + hidden_item_keys = {str(row.get("item_key") or "").strip() for row in hidden_rows} + dismissed_entries = [ + { + "item_type": str(row.get("item_type") or "").strip().lower(), + "item_key": str(row.get("item_key") or "").strip(), + } + for row in hidden_rows + if str(row.get("item_type") or "").strip().lower() in {"download", "request"} + and str(row.get("item_key") or "").strip() + ] + live_queue = queue_status(user_id=actor.owner_scope) + db_rows = download_history_service.list_recent( + user_id=actor.owner_scope, + limit=200, + ) + visible_db_rows = [ + row + for row in db_rows + if f"download:{str(row.get('task_id') or '').strip()}" not in hidden_item_keys + ] + + status = _build_download_status_from_db( + db_rows=visible_db_rows, + queue_status=live_queue, + ) + + updated_requests = sync_request_delivery_states( + user_db, + queue_status=status, + user_id=actor.owner_scope, + ) + emit_request_updates(updated_requests) + request_rows = _list_visible_requests( + user_db, + is_admin=actor.is_admin, + db_user_id=actor.db_user_id, + ) + visible_request_rows: list[dict[str, Any]] = [] + for row in request_rows: + request_id = normalize_positive_int(row.get("id")) + if request_id is None: + continue + if f"request:{request_id}" in hidden_item_keys: + continue + visible_request_rows.append(row) + + return jsonify( + { + "status": status, + "requests": visible_request_rows, + "dismissed": dismissed_entries, + } + ) + + @app.route("/api/activity/dismiss", methods=["POST"]) + def api_activity_dismiss() -> Response | tuple[Response, int]: + auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss") + if auth_gate is not None: + return auth_gate + + actor, actor_error = _resolve_activity_actor( + user_db=user_db, + resolve_auth_mode=resolve_auth_mode, + action="dismiss", + ) + if actor_error is not None: + return actor_error + actor = _require_activity_actor(actor, action="dismiss") + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return _activity_error_response("dismiss", status_code=400, error="Invalid payload") + + item_type = str(data.get("item_type") or "").strip().lower() + item_key = data.get("item_key") + + dismissal_item: dict[str, str] | None = None + + if item_type == "download": + task_id = _parse_item_key(item_key, "download") + if task_id is None: + return _activity_error_response( + "dismiss", + status_code=400, + error="item_key must be in the format download:", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=item_key, + ) + + existing = download_history_service.get_by_task_id(task_id) + if existing is None: + return _activity_error_response( + "dismiss", + status_code=404, + error="Download not found", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=f"download:{task_id}", + ) + + live_queue_index = _build_queue_index(queue_status(user_id=actor.owner_scope)) + effective_existing = _effective_download_row_for_activity( + existing, + has_live_queue_entry=task_id in live_queue_index, + ) + ownership_error = _check_item_ownership(actor, existing) + if ownership_error is not None: + return _activity_error_response( + "dismiss", + status_code=403, + error=ownership_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=f"download:{task_id}", + **_download_row_log_context(effective_existing), + ) + terminal_error = _check_terminal_download(effective_existing) + if terminal_error is not None: + return _activity_error_response( + "dismiss", + status_code=409, + error=terminal_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=f"download:{task_id}", + **_download_row_log_context(effective_existing), + ) + + activity_view_state_service.dismiss( + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=f"download:{task_id}", + ) + dismissal_item = { + "item_type": "download", + "item_key": f"download:{task_id}", + } + + elif item_type == "request": + request_id = normalize_positive_int(_parse_item_key(item_key, "request")) + if request_id is None: + return _activity_error_response( + "dismiss", + status_code=400, + error="item_key must be in the format request:", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=item_key, + ) + + request_row = user_db.get_request(request_id) + if request_row is None: + return _activity_error_response( + "dismiss", + status_code=404, + error="Request not found", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=f"request:{request_id}", + request_id=request_id, + ) + + ownership_error = _check_item_ownership(actor, request_row) + if ownership_error is not None: + return _activity_error_response( + "dismiss", + status_code=403, + error=ownership_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=f"request:{request_id}", + **_request_row_log_context(request_row), + ) + terminal_error = _check_terminal_request(request_row) + if terminal_error is not None: + return _activity_error_response( + "dismiss", + status_code=409, + error=terminal_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=f"request:{request_id}", + **_request_row_log_context(request_row), + ) + + activity_view_state_service.dismiss( + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=f"request:{request_id}", + ) + dismissal_item = { + "item_type": "request", + "item_key": f"request:{request_id}", + } + else: + return _activity_error_response( + "dismiss", + status_code=400, + error="item_type must be one of: download, request", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type=item_type, + item_key=item_key, + ) + + room = _activity_ws_room(actor) + emit_ws_event( + ws_manager, + event_name="activity_update", + room=room, + payload={ + "kind": "dismiss", + "item_type": dismissal_item["item_type"], + "item_key": dismissal_item["item_key"], + }, + ) + + return jsonify({"status": "dismissed", "item": dismissal_item}) + + @app.route("/api/activity/dismiss-many", methods=["POST"]) + def api_activity_dismiss_many() -> Response | tuple[Response, int]: + auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss_many") + if auth_gate is not None: + return auth_gate + + actor, actor_error = _resolve_activity_actor( + user_db=user_db, + resolve_auth_mode=resolve_auth_mode, + action="dismiss_many", + ) + if actor_error is not None: + return actor_error + actor = _require_activity_actor(actor, action="dismiss_many") + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return _activity_error_response( + "dismiss_many", + status_code=400, + error="Invalid payload", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + ) + items = data.get("items") + if not isinstance(items, list): + return _activity_error_response( + "dismiss_many", + status_code=400, + error="items must be an array", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + ) + + dismissal_items: list[dict[str, str]] = [] + missing_item_keys: list[str] = [] + live_queue_index: dict[str, tuple[str, dict[str, Any]]] | None = None + + for item in items: + if not isinstance(item, dict): + return _activity_error_response( + "dismiss_many", + status_code=400, + error="items must contain objects", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_count=len(items), + ) + + item_type = str(item.get("item_type") or "").strip().lower() + item_key = item.get("item_key") + + if item_type == "download": + task_id = _parse_item_key(item_key, "download") + if task_id is None: + return _activity_error_response( + "dismiss_many", + status_code=400, + error="download item_key must be in the format download:", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=item_key, + item_count=len(items), + ) + existing = download_history_service.get_by_task_id(task_id) + if existing is None: + missing_item_keys.append(f"download:{task_id}") + continue + if live_queue_index is None: + live_queue_index = _build_queue_index(queue_status(user_id=actor.owner_scope)) + effective_existing = _effective_download_row_for_activity( + existing, + has_live_queue_entry=task_id in live_queue_index, + ) + ownership_error = _check_item_ownership(actor, existing) + if ownership_error is not None: + return _activity_error_response( + "dismiss_many", + status_code=403, + error=ownership_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=f"download:{task_id}", + item_count=len(items), + **_download_row_log_context(effective_existing), + ) + terminal_error = _check_terminal_download(effective_existing) + if terminal_error is not None: + return _activity_error_response( + "dismiss_many", + status_code=409, + error=terminal_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="download", + item_key=f"download:{task_id}", + item_count=len(items), + **_download_row_log_context(effective_existing), + ) + dismissal_items.append({"item_type": "download", "item_key": f"download:{task_id}"}) + continue + + if item_type == "request": + request_id = normalize_positive_int(_parse_item_key(item_key, "request")) + if request_id is None: + return _activity_error_response( + "dismiss_many", + status_code=400, + error="request item_key must be in the format request:", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=item_key, + item_count=len(items), + ) + request_row = user_db.get_request(request_id) + if request_row is None: + missing_item_keys.append(f"request:{request_id}") + continue + ownership_error = _check_item_ownership(actor, request_row) + if ownership_error is not None: + return _activity_error_response( + "dismiss_many", + status_code=403, + error=ownership_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=f"request:{request_id}", + item_count=len(items), + **_request_row_log_context(request_row), + ) + terminal_error = _check_terminal_request(request_row) + if terminal_error is not None: + return _activity_error_response( + "dismiss_many", + status_code=409, + error=terminal_error, + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type="request", + item_key=f"request:{request_id}", + item_count=len(items), + **_request_row_log_context(request_row), + ) + dismissal_items.append( + {"item_type": "request", "item_key": f"request:{request_id}"} + ) + continue + + return _activity_error_response( + "dismiss_many", + status_code=400, + error="item_type must be one of: download, request", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_type=item_type, + item_key=item_key, + item_count=len(items), + ) + + if missing_item_keys: + return _activity_error_response( + "dismiss_many", + status_code=404, + error="One or more activity items were not found", + auth_mode=resolve_auth_mode(), + viewer_scope=actor.viewer_scope, + item_count=len(items), + missing_item_keys=missing_item_keys, + ) + + dismissed_count = activity_view_state_service.dismiss_many( + viewer_scope=actor.viewer_scope, + items=dismissal_items, + ) + + room = _activity_ws_room(actor) + emit_ws_event( + ws_manager, + event_name="activity_update", + room=room, + payload={ + "kind": "dismiss_many", + "count": dismissed_count, + }, + ) + + return jsonify({"status": "dismissed", "count": dismissed_count}) + + @app.route("/api/activity/history", methods=["GET"]) + def api_activity_history() -> Response | tuple[Response, int]: + auth_gate = _require_authenticated(resolve_auth_mode, action="history") + if auth_gate is not None: + return auth_gate + + actor, actor_error = _resolve_activity_actor( + user_db=user_db, + resolve_auth_mode=resolve_auth_mode, + action="history", + ) + if actor_error is not None: + return actor_error + actor = _require_activity_actor(actor, action="history") + + limit = request.args.get("limit", type=int, default=50) + offset = request.args.get("offset", type=int, default=0) + if limit is None: + limit = 50 + if offset is None: + offset = 0 + if limit < 1: + return _activity_error_response( + "history", status_code=400, error="limit must be a positive integer" + ) + if offset < 0: + return _activity_error_response( + "history", + status_code=400, + error="offset must be a non-negative integer", + ) + + history_rows = activity_view_state_service.list_history( + viewer_scope=actor.viewer_scope, + limit=limit, + offset=offset, + ) + live_queue_index = _build_queue_index(queue_status(user_id=actor.owner_scope)) + payload: list[dict[str, Any]] = [] + + for history_row in history_rows: + item_type = str(history_row.get("item_type") or "").strip().lower() + item_key = str(history_row.get("item_key") or "").strip() + dismissed_at = history_row.get("dismissed_at") + + if not isinstance(dismissed_at, str) or not dismissed_at.strip(): + msg = f"Activity history state missing dismissed_at for {item_key}" + raise RuntimeError(msg) + + if item_type == "download": + task_id = _parse_item_key(item_key, "download") + if task_id is None: + msg = f"Invalid activity history item_key: {item_key}" + raise RuntimeError(msg) + + download_row = download_history_service.get_by_task_id(task_id) + if download_row is None: + msg = f"Download history row not found for {item_key}" + raise RuntimeError(msg) + + if not actor.is_admin: + owner_user_id = normalize_positive_int(download_row.get("user_id")) + if owner_user_id != actor.db_user_id: + msg = f"Viewer state out of scope for {item_key}" + raise RuntimeError(msg) + + effective_download_row = _effective_download_row_for_activity( + download_row, + has_live_queue_entry=task_id in live_queue_index, + ) + history_entry = DownloadHistoryService.to_history_row( + effective_download_row, + dismissed_at=dismissed_at, + ) + uid = normalize_positive_int(download_row.get("user_id")) + if uid is not None: + try: + db_user = user_db.get_user(user_id=uid) + display_name = db_user.get("display_name") if db_user else None + except Exception: + display_name = None + if isinstance(history_entry.get("snapshot"), dict): + dl = history_entry["snapshot"].get("download") + if isinstance(dl, dict): + dl["display_name"] = display_name + payload.append(history_entry) + continue + + if item_type == "request": + request_id = normalize_positive_int(_parse_item_key(item_key, "request")) + if request_id is None: + msg = f"Invalid activity history item_key: {item_key}" + raise RuntimeError(msg) + + request_row = user_db.get_request(request_id) + if request_row is None: + msg = f"Request row not found for {item_key}" + raise RuntimeError(msg) + + if not actor.is_admin: + owner_user_id = normalize_positive_int(request_row.get("user_id")) + if owner_user_id != actor.db_user_id: + msg = f"Viewer state out of scope for {item_key}" + raise RuntimeError(msg) + + populate_request_usernames([request_row], user_db) + entry = _request_history_entry( + request_row, + dismissed_at=dismissed_at, + ) + if entry is None: + msg = f"Failed to build request history entry for {item_key}" + raise RuntimeError(msg) + payload.append(entry) + continue + + msg = f"Unknown activity history item_type: {item_type}" + raise RuntimeError(msg) + + return jsonify(payload) + + @app.route("/api/activity/history", methods=["DELETE"]) + def api_activity_history_clear() -> Response | tuple[Response, int]: + auth_gate = _require_authenticated(resolve_auth_mode, action="history_clear") + if auth_gate is not None: + return auth_gate + + actor, actor_error = _resolve_activity_actor( + user_db=user_db, + resolve_auth_mode=resolve_auth_mode, + action="history_clear", + ) + if actor_error is not None: + return actor_error + actor = _require_activity_actor(actor, action="history_clear") + + cleared_count = activity_view_state_service.clear_history( + viewer_scope=actor.viewer_scope, + ) + + room = _activity_ws_room(actor) + emit_ws_event( + ws_manager, + event_name="activity_update", + room=room, + payload={ + "kind": "history_cleared", + "count": cleared_count, + }, + ) + return jsonify({"status": "cleared", "cleared_count": cleared_count}) diff --git a/patches/shelfmark/core/request_helpers.py b/patches/shelfmark/core/request_helpers.py new file mode 100644 index 00000000..897716ae --- /dev/null +++ b/patches/shelfmark/core/request_helpers.py @@ -0,0 +1,189 @@ +"""Shared request-related helper functions used by routes and services.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Protocol, SupportsIndex, SupportsInt, TypeGuard + +from shelfmark.core.config import config as app_config +from shelfmark.core.logger import setup_logger + +_logger = setup_logger(__name__) + +type _ConvertibleToInt = str | bytes | bytearray | SupportsInt | SupportsIndex + + +class _MappingWithGet(Protocol): + """Minimal mapping protocol for session-like objects.""" + + def get(self, key: str, default: object = None, /) -> object: ... + + +class _UserDBLike(Protocol): + """Minimal user DB protocol for username population helpers.""" + + def get_user(self, *, user_id: int) -> dict[str, Any] | None: ... + + +def _is_mapping_with_get(candidate: object) -> TypeGuard[_MappingWithGet]: + """Return True when *candidate* exposes a mapping-style get method.""" + return callable(getattr(candidate, "get", None)) + + +def _is_user_db_like(candidate: object) -> TypeGuard[_UserDBLike]: + """Return True when *candidate* exposes the user lookup API we need.""" + return callable(getattr(candidate, "get_user", None)) + + +def _is_convertible_to_int(value: object) -> TypeGuard[_ConvertibleToInt]: + """Return True when *value* can be passed to ``int`` safely.""" + return ( + isinstance(value, (str, bytes, bytearray)) + or hasattr(value, "__int__") + or hasattr(value, "__index__") + ) + + +def now_utc_iso() -> str: + """Return the current UTC time as a seconds-precision ISO 8601 string.""" + return datetime.now(UTC).isoformat(timespec="seconds") + + +def emit_ws_event( + ws_manager: object, + *, + event_name: str, + payload: dict[str, Any], + room: str, +) -> None: + """Emit a WebSocket event via the shared manager, swallowing failures.""" + if ws_manager is None: + return + try: + socketio = getattr(ws_manager, "socketio", None) + is_enabled = getattr(ws_manager, "is_enabled", None) + if socketio is None or not callable(is_enabled) or not is_enabled(): + return + socketio.emit(event_name, payload, to=room) + except (AttributeError, RuntimeError, TypeError, ValueError) as exc: + _logger.warning( + "Failed to emit WebSocket event '%s' to room '%s': %s", + event_name, + room, + exc, + ) + + +def load_users_request_policy_settings() -> dict[str, Any]: + """Load global request-policy settings from the users config file.""" + from shelfmark.core.request_policy import REQUEST_POLICY_KEYS + + return {key: app_config.get(key) for key in REQUEST_POLICY_KEYS} + + +def coerce_bool(value: object, *, default: bool = False) -> bool: + """Coerce arbitrary values into booleans with string-friendly semantics.""" + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off", ""}: + return False + return bool(value) + + +def get_session_db_user_id(session_obj: object) -> int | None: + """Extract and coerce `db_user_id` from a Flask session to ``int | None``.""" + raw = session_obj.get("db_user_id") if _is_mapping_with_get(session_obj) else None + try: + return int(raw) if raw is not None and _is_convertible_to_int(raw) else None + except (TypeError, ValueError): + return None + + +def coerce_int(value: object, default: int) -> int: + """Best-effort integer coercion with fallback to default.""" + if not _is_convertible_to_int(value): + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def normalize_optional_text(value: object) -> str | None: + """Return a trimmed string or None for empty/non-string input.""" + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def normalize_positive_int(value: object) -> int | None: + """Parse *value* as a positive integer, returning ``None`` on failure.""" + if not _is_convertible_to_int(value): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def normalize_optional_positive_int(value: object, field_name: str = "value") -> int | None: + """Parse *value* as a positive integer or ``None``. + + Raises ``ValueError`` when *value* is present but not a valid + positive integer. + """ + if value is None: + return None + if not _is_convertible_to_int(value): + msg = f"{field_name} must be a positive integer when provided" + raise ValueError(msg) + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + msg = f"{field_name} must be a positive integer when provided" + raise ValueError(msg) from exc + if parsed < 1: + msg = f"{field_name} must be a positive integer when provided" + raise ValueError(msg) + return parsed + + +def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> None: + """Add 'username' and 'display_name' to each request row by looking up user_id.""" + if not _is_user_db_like(user_db): + return + + cache: dict[int, dict[str, str | None]] = {} + for row in rows: + requester_id = normalize_positive_int(row.get("user_id")) + if requester_id is None: + row["username"] = "" + row["display_name"] = None + continue + if requester_id not in cache: + requester = user_db.get_user(user_id=requester_id) + cache[requester_id] = { + "username": requester.get("username", "") if requester else "", + "display_name": requester.get("display_name") if requester else None, + } + row["username"] = cache[requester_id]["username"] + row["display_name"] = cache[requester_id]["display_name"] + + +def extract_release_source_id(release_data: object) -> str | None: + """Extract and normalize release_data.source_id.""" + if not isinstance(release_data, dict): + return None + source_id = release_data.get("source_id") + if not isinstance(source_id, str): + return None + normalized = source_id.strip() + return normalized or None diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py index 4a776c15..c529dfe9 100644 --- a/shelfmark/core/activity_routes.py +++ b/shelfmark/core/activity_routes.py @@ -473,6 +473,9 @@ def _minimal_request_snapshot(request_row: dict[str, Any], request_id: int) -> d username = request_row.get("username") if isinstance(username, str): minimal_request["username"] = username + display_name = request_row.get("display_name") + if display_name is not None: + minimal_request["display_name"] = display_name return {"kind": "request", "request": minimal_request} @@ -1021,12 +1024,22 @@ def api_activity_history() -> Response | tuple[Response, int]: download_row, has_live_queue_entry=task_id in live_queue_index, ) - payload.append( - DownloadHistoryService.to_history_row( - effective_download_row, - dismissed_at=dismissed_at, - ) + history_entry = DownloadHistoryService.to_history_row( + effective_download_row, + dismissed_at=dismissed_at, ) + uid = normalize_positive_int(download_row.get("user_id")) + if uid is not None: + try: + db_user = user_db.get_user(user_id=uid) + display_name = db_user.get("display_name") if db_user else None + except Exception: + display_name = None + if isinstance(history_entry.get("snapshot"), dict): + dl = history_entry["snapshot"].get("download") + if isinstance(dl, dict): + dl["display_name"] = display_name + payload.append(history_entry) continue if item_type == "request": diff --git a/shelfmark/core/request_helpers.py b/shelfmark/core/request_helpers.py index 22eaf0b1..897716ae 100644 --- a/shelfmark/core/request_helpers.py +++ b/shelfmark/core/request_helpers.py @@ -157,20 +157,25 @@ def normalize_optional_positive_int(value: object, field_name: str = "value") -> def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> None: - """Add 'username' to each request row by looking up user_id.""" + """Add 'username' and 'display_name' to each request row by looking up user_id.""" if not _is_user_db_like(user_db): return - cache: dict[int, str] = {} + cache: dict[int, dict[str, str | None]] = {} for row in rows: requester_id = normalize_positive_int(row.get("user_id")) if requester_id is None: row["username"] = "" + row["display_name"] = None continue if requester_id not in cache: requester = user_db.get_user(user_id=requester_id) - cache[requester_id] = requester.get("username", "") if requester else "" - row["username"] = cache[requester_id] + cache[requester_id] = { + "username": requester.get("username", "") if requester else "", + "display_name": requester.get("display_name") if requester else None, + } + row["username"] = cache[requester_id]["username"] + row["display_name"] = cache[requester_id]["display_name"] def extract_release_source_id(release_data: object) -> str | None: diff --git a/shelfmark/main.py b/shelfmark/main.py index dc97e33b..a98c01e7 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1548,6 +1548,25 @@ def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None: ) +def _enrich_queue_status_with_display_names( + status: dict[str, dict[str, Any]], db: Any +) -> None: + """Add display_name to each book dict in the queue status using a per-user cache.""" + cache: dict[int, str | None] = {} + for bucket in status.values(): + for book in bucket.values(): + uid_raw = book.get("user_id") + if not isinstance(uid_raw, int) or uid_raw <= 0: + continue + if uid_raw not in cache: + try: + row = db.get_user(user_id=uid_raw) + cache[uid_raw] = row.get("display_name") if row else None + except Exception: + cache[uid_raw] = None + book["display_name"] = cache[uid_raw] + + @app.route("/api/status", methods=["GET"]) @login_required def api_status() -> Response | tuple[Response, int]: @@ -1571,6 +1590,7 @@ def api_status() -> Response | tuple[Response, int]: user_id=user_id, ) _emit_request_update_events(updated_requests) + _enrich_queue_status_with_display_names(status, user_db) return jsonify(status) except _OPERATIONAL_ERRORS as e: logger.error_trace(f"Status error: {e}") diff --git a/src/frontend/src/components/activity/ActivityCard.tsx b/src/frontend/src/components/activity/ActivityCard.tsx index 7469c845..0178acc6 100644 --- a/src/frontend/src/components/activity/ActivityCard.tsx +++ b/src/frontend/src/components/activity/ActivityCard.tsx @@ -230,6 +230,30 @@ const toSourceLabel = (value: unknown): string => { .join(' '); }; +const formatRelativeTime = (epochMs: number): string => { + if (!epochMs || !Number.isFinite(epochMs)) { + return ''; + } + const diffMs = Date.now() - epochMs; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) { + return 'just now'; + } + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) { + return `${diffMin}m ago`; + } + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) { + return `${diffHr}h ago`; + } + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) { + return `${diffDay}d ago`; + } + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(epochMs); +}; + const formatDateTime = (isoDate: string): string => { const parsed = Date.parse(isoDate); if (!Number.isFinite(parsed)) { @@ -786,6 +810,11 @@ export const ActivityCard = ({

{item.metaLine}

+ {item.timestamp > 0 && ( +

+ {formatRelativeTime(item.timestamp)} +

+ )} {noteLine && (

diff --git a/src/frontend/src/components/activity/ActivitySidebar.tsx b/src/frontend/src/components/activity/ActivitySidebar.tsx index d64f38dd..c6efab8c 100644 --- a/src/frontend/src/components/activity/ActivitySidebar.tsx +++ b/src/frontend/src/components/activity/ActivitySidebar.tsx @@ -179,6 +179,7 @@ const mergeRequestWithDownload = ( metaLine: downloadItem.metaLine, timestamp: Math.max(downloadItem.timestamp, requestItem.timestamp), username: requestItem.username || downloadItem.username, + displayName: requestItem.displayName || downloadItem.displayName, adminNote: requestItem.adminNote, requestId: requestItem.requestId, requestLevel: requestItem.requestLevel, @@ -207,6 +208,14 @@ const getItemUsername = (item: ActivityItem): string | null => { return normalized || null; }; +const getItemDisplayLabel = (item: ActivityItem): string | null => { + const displayName = item.displayName || item.requestRecord?.display_name || undefined; + if (displayName?.trim()) { + return displayName.trim(); + } + return getItemUsername(item); +}; + const parsePinned = (value: string | null): boolean => { if (!value) { return false; @@ -433,7 +442,7 @@ export const ActivitySidebar = ({ } const availableUsers = useMemo(() => { - const userMap = new Map(); + const userMap = new Map(); baseVisibleItems.forEach((item) => { const username = getItemUsername(item); if (!username) { @@ -441,15 +450,21 @@ export const ActivitySidebar = ({ } const lookupKey = username.toLowerCase(); if (!userMap.has(lookupKey)) { - userMap.set(lookupKey, username); + userMap.set(lookupKey, { + username, + label: getItemDisplayLabel(item) ?? username, + }); } }); - return Array.from(userMap.values()).toSorted((left, right) => left.localeCompare(right)); + return Array.from(userMap.values()).toSorted((left, right) => + left.label.localeCompare(right.label), + ); }, [baseVisibleItems]); const effectiveSelectedUser = - selectedUser === ALL_USERS_FILTER || availableUsers.includes(selectedUser) + selectedUser === ALL_USERS_FILTER || + availableUsers.some((u) => u.username === selectedUser) ? selectedUser : ALL_USERS_FILTER; if (effectiveSelectedUser !== selectedUser) { @@ -637,12 +652,18 @@ export const ActivitySidebar = ({ title={ effectiveSelectedUser === ALL_USERS_FILTER ? 'Filter by user' - : `Filtered: ${effectiveSelectedUser}` + : `Filtered: ${ + availableUsers.find((u) => u.username === effectiveSelectedUser) + ?.label ?? effectiveSelectedUser + }` } aria-label={ effectiveSelectedUser === ALL_USERS_FILTER ? 'Filter by user' - : `Filtered by user ${effectiveSelectedUser}` + : `Filtered by user ${ + availableUsers.find((u) => u.username === effectiveSelectedUser) + ?.label ?? effectiveSelectedUser + }` } aria-expanded={isDropdownOpen} > @@ -665,9 +686,11 @@ export const ActivitySidebar = ({ > {({ close }) => (

- {[ALL_USERS_FILTER, ...availableUsers].map((value) => { + {[ + { username: ALL_USERS_FILTER, label: 'All users' }, + ...availableUsers, + ].map(({ username: value, label }) => { const isSelected = effectiveSelectedUser === value; - const label = value === ALL_USERS_FILTER ? 'All users' : value; return ( - ); - })} + {[{ username: ALL_USERS_FILTER, label: 'All users' }, ...availableUsers].map( + ({ username: value, label }) => { + const isSelected = effectiveSelectedUser === value; + return ( + + ); + }, + )}
)} From 23d7d94ed528ed73609af5b6ef8f11f793db4806 Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 00:16:55 -0400 Subject: [PATCH 22/24] test(abb): use valid hex info hashes in scraper test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix(abb) port (f189a5f) added SHA-1/SHA-256 hex validation on info hashes. The existing test fixtures used 'ABC123DEF456GHI789...' and 'ABC 123 DEF 456', neither of which is valid hex — pre-fix they passed trivially, post-fix they (correctly) fail validation. Replaced with valid 40-char hex strings; assertions still match on the partial 'ABC123DEF456' prefix so the cleanup-behavior assertion is preserved. --- tests/audiobookbay/test_scraper.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/audiobookbay/test_scraper.py b/tests/audiobookbay/test_scraper.py index c3370e89..074b1bc1 100644 --- a/tests/audiobookbay/test_scraper.py +++ b/tests/audiobookbay/test_scraper.py @@ -58,7 +58,7 @@ - + @@ -83,7 +83,7 @@
Info HashABC123DEF456GHI789JKL012MNO345PQR678STUABC123DEF456789012345678901234567890ABCD
Tracker 1
- +
Info HashABC123DEF456GHI789JKL012MNO345PQR678STUABC123DEF456789012345678901234567890ABCD
@@ -360,7 +360,7 @@ def test_extract_magnet_link_success(self, mock_html_get): assert magnet_link is not None assert magnet_link.startswith("magnet:?xt=urn:btih:") - assert "ABC123DEF456GHI789JKL012MNO345PQR678STU" in magnet_link + assert "ABC123DEF456789012345678901234567890ABCD" in magnet_link assert "udp%3A//tracker.openbittorrent.com%3A80" in magnet_link assert "http%3A//tracker.example.com%3A8080" in magnet_link assert mock_html_get.call_count == 2 @@ -395,7 +395,7 @@ def test_extract_magnet_link_fallback(self, mock_html_get): assert magnet_link is not None assert magnet_link.startswith("magnet:?xt=urn:btih:") - assert "ABC123DEF456GHI789JKL012MNO345PQR678STU" in magnet_link + assert "ABC123DEF456789012345678901234567890ABCD" in magnet_link # Should contain default trackers assert "udp%3A//tracker.openbittorrent.com%3A80" in magnet_link @@ -441,7 +441,7 @@ def test_extract_magnet_link_cleans_info_hash(self, mock_html_get): - +
Info HashABC 123 DEF 456ABC 123 DEF 456 789 012 345 678 901 234 567 890 ABC D
From 2910577ba0c0308e1ab14724a66c7248f28a60ff Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 00:38:01 -0400 Subject: [PATCH 23/24] style: ruff lint fixes for ported code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked code from NemesisHubris/litfinder used slightly different ruff settings; this aligns with this repo's stricter config. Auto-fixed (ruff --fix): import sorting in pipeline.py and transfer.py, quote style in audiobookbay/scraper.py regex, unused pytest imports in three test files. Manual fixes: - BLE001: narrow blind `except Exception:` to (AttributeError, KeyError, TypeError) in activity_routes.py and main.py display-name lookups - SIM105: replace try/except/pass with contextlib.suppress(OSError) in destination.py write-probe cleanup - TC003: move `from pathlib import Path` into TYPE_CHECKING block in postprocess/group.py (only used as annotation, file has `from __future__ import annotations`) - RET504: inline final return in direct_download._normalize_candidate - S105: noqa on `token == "all"` (language sentinel, not a credential) - C405: list literal → set literal in test_text_match.py - C408: dict() call → dict literal in test_postprocess_group_integration.py - RUF059: prefix unused `final_paths` with `_` in one integration test All 1942 backend tests still pass. --- shelfmark/core/activity_routes.py | 2 +- shelfmark/download/postprocess/destination.py | 5 ++--- shelfmark/download/postprocess/group.py | 5 ++++- shelfmark/download/postprocess/pipeline.py | 2 +- shelfmark/download/postprocess/transfer.py | 2 +- shelfmark/main.py | 2 +- .../release_sources/audiobookbay/scraper.py | 4 ++-- shelfmark/release_sources/direct_download.py | 5 ++--- tests/core/test_text_match.py | 5 ++--- tests/core/test_utils.py | 2 -- tests/download/test_postprocess_group.py | 2 -- .../test_postprocess_group_integration.py | 20 +++++++++---------- 12 files changed, 25 insertions(+), 31 deletions(-) diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py index c529dfe9..c06f6c2d 100644 --- a/shelfmark/core/activity_routes.py +++ b/shelfmark/core/activity_routes.py @@ -1033,7 +1033,7 @@ def api_activity_history() -> Response | tuple[Response, int]: try: db_user = user_db.get_user(user_id=uid) display_name = db_user.get("display_name") if db_user else None - except Exception: + except (AttributeError, KeyError, TypeError): display_name = None if isinstance(history_entry.get("snapshot"), dict): dl = history_entry["snapshot"].get("download") diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py index 0f0c406c..c44add99 100644 --- a/shelfmark/download/postprocess/destination.py +++ b/shelfmark/download/postprocess/destination.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import uuid from typing import TYPE_CHECKING @@ -66,10 +67,8 @@ def validate_destination( logger.warning("Destination not writable: %s (%s)", destination, exc) status_callback("error", f"Destination not writable: {destination} ({exc})") if created_by_us: - try: + with contextlib.suppress(OSError): run_blocking_io(destination.rmdir) - except OSError: - pass return False return True diff --git a/shelfmark/download/postprocess/group.py b/shelfmark/download/postprocess/group.py index cd71313f..c39e7c8c 100644 --- a/shelfmark/download/postprocess/group.py +++ b/shelfmark/download/postprocess/group.py @@ -3,7 +3,10 @@ from __future__ import annotations import re -from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path # "Title [ID] - 01 - Chapter Name" → "Title [ID]" _CHAPTER_PATTERN = re.compile(r"^(.*?)\s+-\s+\d+\s+-\s+.+$") diff --git a/shelfmark/download/postprocess/pipeline.py b/shelfmark/download/postprocess/pipeline.py index e7ee9663..e94ff71a 100644 --- a/shelfmark/download/postprocess/pipeline.py +++ b/shelfmark/download/postprocess/pipeline.py @@ -17,7 +17,6 @@ from __future__ import annotations -from .group import group_book_files from .custom_script import ( CustomScriptContext, CustomScriptExecution, @@ -28,6 +27,7 @@ run_custom_script, ) from .destination import get_final_destination, validate_destination +from .group import group_book_files from .prepare import build_output_plan, prepare_output_files from .scan import ( collect_directory_files, diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index cf286ad8..8da31601 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -15,7 +15,6 @@ parse_naming_template, sanitize_filename, ) -from shelfmark.download.postprocess.group import group_book_files from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.fs import ( atomic_copy, @@ -23,6 +22,7 @@ atomic_move, run_blocking_io, ) +from shelfmark.download.postprocess.group import group_book_files from shelfmark.download.postprocess.policy import get_file_organization, get_template from .scan import collect_directory_files, scan_directory_tree diff --git a/shelfmark/main.py b/shelfmark/main.py index a98c01e7..dd0d65da 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1562,7 +1562,7 @@ def _enrich_queue_status_with_display_names( try: row = db.get_user(user_id=uid_raw) cache[uid_raw] = row.get("display_name") if row else None - except Exception: + except (AttributeError, KeyError, TypeError): cache[uid_raw] = None book["display_name"] = cache[uid_raw] diff --git a/shelfmark/release_sources/audiobookbay/scraper.py b/shelfmark/release_sources/audiobookbay/scraper.py index 1ec47c54..1faeb4a4 100644 --- a/shelfmark/release_sources/audiobookbay/scraper.py +++ b/shelfmark/release_sources/audiobookbay/scraper.py @@ -418,10 +418,10 @@ def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") -> info_hash = re.sub(r"\s+", "", info_hash).upper() # Validate: SHA1 = 40 hex chars, SHA256 = 64 hex chars - if not re.match(r'^[0-9A-F]{40}$|^[0-9A-F]{64}$', info_hash): + if not re.match(r"^[0-9A-F]{40}$|^[0-9A-F]{64}$", info_hash): logger.warning("Info Hash invalid (got %r), trying magnet fallback.", info_hash) # Fallback: search entire page for a complete magnet link (e.g. posted in comments) - magnet_match = re.search(r'magnet:\?xt=urn:btih:([0-9a-fA-F]{40,64})', detail_html) + magnet_match = re.search(r"magnet:\?xt=urn:btih:([0-9a-fA-F]{40,64})", detail_html) if magnet_match: info_hash = magnet_match.group(1).upper() logger.info("Found hash via magnet fallback: %s", info_hash) diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 95659c0f..0fa7d089 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -308,13 +308,12 @@ def _extract_distant_path(row: Tag, *, enabled: bool) -> str | None: def _normalize_candidate(text: str) -> str: normalized = re.sub(r"\s*([\\/])\s*", r"\1", text) normalized = re.sub(r":\s*([\\/])", r":\1", normalized) - normalized = re.sub( + return re.sub( r"\s+\.(epub|mobi|azw3|fb2|djvu|cbz|cbr|pdf|zip|rar|m4b|mp3)\b", r".\1", normalized, flags=re.IGNORECASE, ) - return normalized candidates = [row.get_text(" ", strip=True)] for cell in row.find_all("td"): @@ -400,7 +399,7 @@ def _normalize_requested_languages(languages: list[str] | None) -> set[str]: normalized: set[str] = set() for value in languages: token = _normalize_language_token(str(value)) - if not token or token == "all": + if not token or token == "all": # noqa: S105 - "all" is a language sentinel continue normalized.add(aliases.get(token, token)) return normalized diff --git a/tests/core/test_text_match.py b/tests/core/test_text_match.py index c9cd1ea2..b9e7b93d 100644 --- a/tests/core/test_text_match.py +++ b/tests/core/test_text_match.py @@ -1,4 +1,3 @@ -import pytest from shelfmark.core.text_match import ( DEFAULT_TITLE_MATCH_THRESHOLD, @@ -72,11 +71,11 @@ def test_no_match(self): assert title_tokens_match("Frankenstein", haystack) is False def test_partial_match_above_threshold(self): - haystack = set(["lord", "rings", "fellowship"]) + haystack = {"lord", "rings", "fellowship"} assert title_tokens_match("Lord of the Rings", haystack, threshold=0.5) is True def test_partial_match_below_threshold(self): - haystack = set(["lord"]) + haystack = {"lord"} assert title_tokens_match("Lord of the Rings", haystack, threshold=0.85) is False def test_empty_title_returns_false(self): diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 4876b476..e9018203 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -4,8 +4,6 @@ import types import xmlrpc.client as stdlib_xmlrpc_client -import pytest - from shelfmark.core import utils from shelfmark.core.utils import normalize_http_url diff --git a/tests/download/test_postprocess_group.py b/tests/download/test_postprocess_group.py index 4289020a..b28fabc9 100644 --- a/tests/download/test_postprocess_group.py +++ b/tests/download/test_postprocess_group.py @@ -1,7 +1,5 @@ from pathlib import Path -import pytest - from shelfmark.download.postprocess.group import _book_prefix, group_book_files diff --git a/tests/download/test_postprocess_group_integration.py b/tests/download/test_postprocess_group_integration.py index 358733d3..c831df8e 100644 --- a/tests/download/test_postprocess_group_integration.py +++ b/tests/download/test_postprocess_group_integration.py @@ -7,21 +7,19 @@ from pathlib import Path -import pytest - from shelfmark.core.models import DownloadTask from shelfmark.download.postprocess.transfer import transfer_book_files def _task(**kwargs) -> DownloadTask: - defaults = dict( - task_id="group-test", - source="direct_download", - title="Millennial Mage", - author="Tess Irondale", - format="m4b", - content_type="audiobook", - ) + defaults = { + "task_id": "group-test", + "source": "direct_download", + "title": "Millennial Mage", + "author": "Tess Irondale", + "format": "m4b", + "content_type": "audiobook", + } return DownloadTask(**{**defaults, **kwargs}) @@ -108,7 +106,7 @@ def test_standalone_files_each_get_own_subfolder(self, tmp_path: Path, monkeypat lambda *, is_audiobook, organization_mode: "{Author}/{Title}", ) - final_paths, error, _ = transfer_book_files( + _final_paths, error, _ = transfer_book_files( all_files, destination=dest, task=_task(), From b59498c40d22314fe042516d421446d02ac24196 Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 00:42:14 -0400 Subject: [PATCH 24/24] style: ruff format ported code to match project config LitFinder uses slightly different formatting (line length, wrap style) than this repo. The 31 files that drifted are all touched by the port commits; `make python-format` was failing on them. Running `ruff format` brings everything back to this repo's existing config. No behavior changes; all 1942 backend tests still pass. --- shelfmark/config/env.py | 6 +-- shelfmark/config/settings.py | 6 +-- shelfmark/core/activity_routes.py | 4 +- shelfmark/core/auth_modes.py | 4 +- shelfmark/core/config.py | 4 +- shelfmark/core/image_cache.py | 6 +-- shelfmark/core/logger.py | 6 +-- shelfmark/core/request_helpers.py | 6 +-- shelfmark/core/self_user_routes.py | 2 +- shelfmark/core/user_db.py | 2 +- shelfmark/download/clients/deluge.py | 2 +- shelfmark/download/clients/rtorrent.py | 4 +- shelfmark/download/clients/sabnzbd.py | 6 +-- shelfmark/download/clients/settings.py | 2 +- shelfmark/download/clients/torrent_utils.py | 4 +- shelfmark/download/http.py | 2 +- shelfmark/download/outputs/email.py | 4 +- shelfmark/download/permissions_debug.py | 6 +-- shelfmark/download/postprocess/transfer.py | 2 +- shelfmark/download/postprocess/workspace.py | 4 +- shelfmark/main.py | 20 ++++----- shelfmark/metadata_providers/__init__.py | 2 +- shelfmark/metadata_providers/hardcover.py | 26 ++++++------ shelfmark/metadata_providers/openlibrary.py | 8 ++-- shelfmark/release_sources/direct_download.py | 28 ++++++++++--- shelfmark/release_sources/irc/cache.py | 2 +- shelfmark/release_sources/newznab/source.py | 2 +- shelfmark/release_sources/prowlarr/torznab.py | 2 +- tests/core/test_notifications.py | 41 +++++++++++-------- tests/core/test_text_match.py | 1 - tests/direct_download/test_search_queries.py | 8 ++-- 31 files changed, 124 insertions(+), 98 deletions(-) diff --git a/shelfmark/config/env.py b/shelfmark/config/env.py index 522d3c70..8eda0486 100644 --- a/shelfmark/config/env.py +++ b/shelfmark/config/env.py @@ -28,7 +28,7 @@ def _read_debug_from_config() -> bool: config = json.load(f) if "DEBUG" in config: return bool(config["DEBUG"]) - except (json.JSONDecodeError, OSError): + except json.JSONDecodeError, OSError: pass return False @@ -40,7 +40,7 @@ def _is_sqlite_file(path: Path) -> bool: with path.open("rb") as f: header = f.read(16) return header[:16] == b"SQLite format 3\x00" - except (OSError, PermissionError): + except OSError, PermissionError: return False @@ -68,7 +68,7 @@ def _is_config_dir_writable() -> bool: test_file = CONFIG_DIR / ".write_test" test_file.touch() test_file.unlink() - except (OSError, PermissionError): + except OSError, PermissionError: return False else: return True diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 5e50d03d..e7afeb6f 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -762,7 +762,7 @@ def _is_plain_email_address(addr: str) -> bool: try: port = int(effective.get("EMAIL_SMTP_PORT", 587)) - except (TypeError, ValueError): + except TypeError, ValueError: return {"error": True, "message": "SMTP port must be a number", "values": values} if port < 1 or port > _SMTP_PORT_MAX: @@ -774,7 +774,7 @@ def _is_plain_email_address(addr: str) -> bool: try: timeout_seconds = int(effective.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60)) - except (TypeError, ValueError): + except TypeError, ValueError: return { "error": True, "message": "SMTP timeout (seconds) must be a number", @@ -799,7 +799,7 @@ def _is_plain_email_address(addr: str) -> bool: try: attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)) - except (TypeError, ValueError): + except TypeError, ValueError: return { "error": True, "message": "Attachment size limit (MB) must be a number", diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py index c06f6c2d..dbc1086e 100644 --- a/shelfmark/core/activity_routes.py +++ b/shelfmark/core/activity_routes.py @@ -170,7 +170,7 @@ def _resolve_db_user_id( ) try: parsed_db_user_id = int(raw_db_user_id) - except (TypeError, ValueError): + except TypeError, ValueError: if not require_in_auth_mode: return None, None return None, _activity_error_response( @@ -1033,7 +1033,7 @@ def api_activity_history() -> Response | tuple[Response, int]: try: db_user = user_db.get_user(user_id=uid) display_name = db_user.get("display_name") if db_user else None - except (AttributeError, KeyError, TypeError): + except AttributeError, KeyError, TypeError: display_name = None if isinstance(history_entry.get("snapshot"), dict): dl = history_entry["snapshot"].get("download") diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py index 497be60f..81c81948 100644 --- a/shelfmark/core/auth_modes.py +++ b/shelfmark/core/auth_modes.py @@ -49,7 +49,7 @@ def has_local_password_admin(user_db: object | None = None) -> bool: if not _has_admin_password_api(db): return False return db.has_admin_with_password() - except (AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error): + except AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error: return False @@ -119,7 +119,7 @@ def load_active_auth_mode( has_local_admin=has_local_password_admin(user_db), disable_local_auth=DISABLE_LOCAL_AUTH, ) - except (ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error): + except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error: return "none" diff --git a/shelfmark/core/config.py b/shelfmark/core/config.py index a5913534..d42d2de4 100644 --- a/shelfmark/core/config.py +++ b/shelfmark/core/config.py @@ -167,7 +167,7 @@ def _get_user_db(self) -> UserDB | None: db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db") user_db = user_db_cls(db_path) user_db.initialize() - except (ImportError, OSError, sqlite3.Error): + except ImportError, OSError, sqlite3.Error: # Multi-user support is optional; fall back to global config when unavailable. return None else: @@ -186,7 +186,7 @@ def _get_user_settings(self, user_id: int) -> dict[str, Any]: try: settings = user_db.get_user_settings(user_id) - except (sqlite3.OperationalError, OSError, ValueError, TypeError): + except sqlite3.OperationalError, OSError, ValueError, TypeError: return {} if not isinstance(settings, dict): diff --git a/shelfmark/core/image_cache.py b/shelfmark/core/image_cache.py index 9c363ea3..8587af68 100644 --- a/shelfmark/core/image_cache.py +++ b/shelfmark/core/image_cache.py @@ -112,7 +112,7 @@ def _load_index(self) -> None: try: with self.index_path.open() as f: self._index = json.load(f) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: self._index = {} def _sync_index_with_files(self) -> None: @@ -495,7 +495,7 @@ def _prepare_safe_url(url: str) -> str | None: return None parsed = urlparse(prepared_url) hostname = parsed.hostname - except (requests.exceptions.RequestException, ValueError): + except requests.exceptions.RequestException, ValueError: return None if not prepared_url: @@ -519,7 +519,7 @@ def _prepare_safe_url(url: str) -> str | None: ip = ipaddress.ip_address(sockaddr[0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return None - except (socket.gaierror, ValueError): + except socket.gaierror, ValueError: return None return prepared_url diff --git a/shelfmark/core/logger.py b/shelfmark/core/logger.py index c7bbf042..0bc9f990 100644 --- a/shelfmark/core/logger.py +++ b/shelfmark/core/logger.py @@ -78,10 +78,10 @@ def _get_process_rss_mb(proc: object) -> float | None: proc_rss_mb = _get_process_rss_mb(proc) if proc_rss_mb is not None: app_memory_mb += proc_rss_mb - except (PermissionError, psutil.AccessDenied, OSError): + except PermissionError, psutil.AccessDenied, OSError: try: app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024) - except (AttributeError, OSError, psutil.Error): + except AttributeError, OSError, psutil.Error: app_memory_mb = 0.0 memory = psutil.virtual_memory() @@ -92,7 +92,7 @@ def _get_process_rss_mb(proc: object) -> float | None: f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, " f"Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%" ) - except (AttributeError, OSError, psutil.Error): + except AttributeError, OSError, psutil.Error: # Avoid breaking the original log call if psutil is missing or restricted. return diff --git a/shelfmark/core/request_helpers.py b/shelfmark/core/request_helpers.py index 897716ae..eabdf709 100644 --- a/shelfmark/core/request_helpers.py +++ b/shelfmark/core/request_helpers.py @@ -101,7 +101,7 @@ def get_session_db_user_id(session_obj: object) -> int | None: raw = session_obj.get("db_user_id") if _is_mapping_with_get(session_obj) else None try: return int(raw) if raw is not None and _is_convertible_to_int(raw) else None - except (TypeError, ValueError): + except TypeError, ValueError: return None @@ -111,7 +111,7 @@ def coerce_int(value: object, default: int) -> int: return default try: return int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return default @@ -129,7 +129,7 @@ def normalize_positive_int(value: object) -> int | None: return None try: parsed = int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None return parsed if parsed > 0 else None diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index 1e380efc..481feda4 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -60,7 +60,7 @@ def _get_current_user( return None, None, (jsonify({"error": "Invalid user context"}), 400) try: user_id = int(raw_user_id) - except (TypeError, ValueError): + except TypeError, ValueError: return None, None, (jsonify({"error": "Invalid user context"}), 400) user = user_db.get_user(user_id=user_id) diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 98633fc1..954e84cb 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -497,7 +497,7 @@ def _parse_request_row(row: sqlite3.Row | None) -> dict[str, Any] | None: continue try: payload[key] = json.loads(raw_value) - except (ValueError, TypeError): + except ValueError, TypeError: payload[key] = None return payload diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py index 79a578de..cbaf3399 100644 --- a/shelfmark/download/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -372,7 +372,7 @@ def get_status(self, download_id: str) -> DownloadStatus: if eta is not None: try: eta = int(eta) - except (TypeError, ValueError): + except TypeError, ValueError: eta = None if eta is not None and (eta < 0 or eta > ONE_WEEK_IN_SECONDS): diff --git a/shelfmark/download/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py index 9f639978..35098c85 100644 --- a/shelfmark/download/clients/rtorrent.py +++ b/shelfmark/download/clients/rtorrent.py @@ -163,7 +163,9 @@ def add_download( commands = [] is_audiobook = kwargs.get("content_type") == "audiobook" - default_label = self._audiobook_label if is_audiobook and self._audiobook_label else self._label + default_label = ( + self._audiobook_label if is_audiobook and self._audiobook_label else self._label + ) label = category or default_label if label: logger.debug("Setting rTorrent label: %s", label) diff --git a/shelfmark/download/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py index 4d185e2d..db3ca1e8 100644 --- a/shelfmark/download/clients/sabnzbd.py +++ b/shelfmark/download/clients/sabnzbd.py @@ -59,7 +59,7 @@ def _parse_eta(eta_str: str) -> int | None: parts = eta_str.split(":") if len(parts) == _ETA_PART_COUNT: return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) - except (ValueError, IndexError): + except ValueError, IndexError: pass return None @@ -71,7 +71,7 @@ def _parse_speed(slot: dict) -> int | None: if kbpersec_str: try: return int(float(kbpersec_str) * 1024) - except (ValueError, TypeError): + except ValueError, TypeError: pass # Fall back to human-readable speed field @@ -90,7 +90,7 @@ def _parse_speed(slot: dict) -> int | None: if prefix in unit: return int(speed_val * mult) return int(speed_val) - except (ValueError, IndexError): + except ValueError, IndexError: return None diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 64a6ce6f..21084c27 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -313,7 +313,7 @@ def get_daemon_version(session: requests.Session, rpc_id: int) -> Any: methods = rpc_call(session, rpc_id, "system.listMethods") if isinstance(methods, list) and "daemon.get_version" in methods: return rpc_call(session, rpc_id + 1, "daemon.get_version") - except (requests.exceptions.RequestException, RuntimeError, ValueError, TypeError): + except requests.exceptions.RequestException, RuntimeError, ValueError, TypeError: # Fall back to daemon.info to preserve existing behavior. pass diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index 7b854019..3478556c 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -340,7 +340,7 @@ def extract_btmh(value: str) -> str | None: padded = raw_value.upper() + "=" * (-len(raw_value) % 8) try: data = base64.b32decode(padded, casefold=True) - except (BinasciiError, ValueError): + except BinasciiError, ValueError: return None if not data: @@ -378,7 +378,7 @@ def extract_btmh(value: str) -> str | None: if re.match(r"^[A-Z2-7]{32}$", hash_value.upper()): try: return base64.b32decode(hash_value.upper()).hex().lower() - except (BinasciiError, ValueError): + except BinasciiError, ValueError: logger.debug( "Could not decode base32 BTIH hash from magnet URI: %s", hash_value ) diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py index ef5eb64d..8460e28e 100644 --- a/shelfmark/download/http.py +++ b/shelfmark/download/http.py @@ -176,7 +176,7 @@ def parse_size_string(size: str) -> float | None: if normalized.endswith(suffix): return float(normalized[:-2]) * mult return float(normalized) - except (ValueError, IndexError): + except ValueError, IndexError: return None diff --git a/shelfmark/download/outputs/email.py b/shelfmark/download/outputs/email.py index c72d85ec..2cdc5ee7 100644 --- a/shelfmark/download/outputs/email.py +++ b/shelfmark/download/outputs/email.py @@ -146,7 +146,7 @@ def _parse_attachment_limit_mb(value: object) -> int: return 25 try: return int(value) - except (TypeError, ValueError): + except TypeError, ValueError: return 25 @@ -164,7 +164,7 @@ def _render_subject(template: str, task: DownloadTask) -> str: } try: rendered = template.format(**mapping) - except (IndexError, KeyError, ValueError): + except IndexError, KeyError, ValueError: rendered = template rendered = " ".join(str(rendered).split()).strip() diff --git a/shelfmark/download/permissions_debug.py b/shelfmark/download/permissions_debug.py index 76ccc864..1a7247fb 100644 --- a/shelfmark/download/permissions_debug.py +++ b/shelfmark/download/permissions_debug.py @@ -68,7 +68,7 @@ def _format_uid(uid: int) -> str: import pwd return pwd.getpwuid(uid).pw_name - except (ImportError, KeyError): + except ImportError, KeyError: return str(uid) @@ -77,7 +77,7 @@ def _format_gid(gid: int) -> str: import grp return grp.getgrgid(gid).gr_name - except (ImportError, KeyError): + except ImportError, KeyError: return str(gid) @@ -105,7 +105,7 @@ def log_path_permission_context(label: str, path: Path) -> None: for probe in [path, path.parent]: try: resolved = _run_io(probe.resolve) - except (OSError, RuntimeError): + except OSError, RuntimeError: resolved = probe try: diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index 8da31601..c3acaca7 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -125,7 +125,7 @@ def is_torrent_source(source_path: Path, task: DownloadTask) -> bool: original_path = Path(task.original_download_path) try: return run_blocking_io(source_path.resolve) == run_blocking_io(original_path.resolve) - except (OSError, ValueError): + except OSError, ValueError: return os.path.normpath(str(source_path)) == os.path.normpath(str(original_path)) diff --git a/shelfmark/download/postprocess/workspace.py b/shelfmark/download/postprocess/workspace.py index 85bc0c0c..f8cafd80 100644 --- a/shelfmark/download/postprocess/workspace.py +++ b/shelfmark/download/postprocess/workspace.py @@ -41,7 +41,7 @@ def is_within_tmp_dir(path: Path) -> bool: try: run_blocking_io(path.resolve).relative_to(run_blocking_io(tmp_dir.resolve)) - except (OSError, ValueError): + except OSError, ValueError: return False else: return True @@ -62,7 +62,7 @@ def _is_original_download(path: Path | None, task: DownloadTask) -> bool: try: original = Path(task.original_download_path) return run_blocking_io(path.resolve) == run_blocking_io(original.resolve) - except (OSError, ValueError): + except OSError, ValueError: return False diff --git a/shelfmark/main.py b/shelfmark/main.py index dd0d65da..afee4afd 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -364,7 +364,7 @@ def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[st for raw_category in categories: try: category_id = int(raw_category) - except (TypeError, ValueError): + except TypeError, ValueError: continue if min_cat <= category_id <= max_cat: return "audiobook", True @@ -408,7 +408,7 @@ def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> if db_user_id is not None: try: user_settings = user_db.get_user_settings(int(db_user_id)) - except (TypeError, ValueError): + except TypeError, ValueError: user_settings = None effective = merge_request_policy_settings(global_settings, user_settings) @@ -480,7 +480,7 @@ def _resolve_download_user_context( try: target_user_id = int(on_behalf_of_user_id) - except (TypeError, ValueError): + except TypeError, ValueError: return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400) if target_user_id <= 0: @@ -753,7 +753,7 @@ def get_proxy_header(header_name: str) -> str | None: if raw_db_user_id is not None: try: session_db_user = user_db.get_user(user_id=int(raw_db_user_id)) - except (TypeError, ValueError): + except TypeError, ValueError: session_db_user = None session_db_username = ( @@ -829,7 +829,7 @@ def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Resp ): return jsonify({"error": "Admin access required"}), 403 - except (RuntimeError, TypeError, ValueError): + except RuntimeError, TypeError, ValueError: logger.exception("Admin access check error") return jsonify({"error": "Internal Server Error"}), 500 @@ -1259,7 +1259,7 @@ def _notify_admin_for_terminal_download_status( raw_owner_user_id = getattr(task, "user_id", None) try: owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None - except (TypeError, ValueError): + except TypeError, ValueError: owner_user_id = None content_type = normalize_optional_text(getattr(task, "content_type", None)) @@ -1451,7 +1451,7 @@ def _task_owned_by_actor( raw_task_user_id = getattr(task, "user_id", None) try: task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None - except (TypeError, ValueError): + except TypeError, ValueError: task_user_id = None if actor_user_id is not None and task_user_id is not None: @@ -1548,9 +1548,7 @@ def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None: ) -def _enrich_queue_status_with_display_names( - status: dict[str, dict[str, Any]], db: Any -) -> None: +def _enrich_queue_status_with_display_names(status: dict[str, dict[str, Any]], db: Any) -> None: """Add display_name to each book dict in the queue status using a per-user cache.""" cache: dict[int, str | None] = {} for bucket in status.values(): @@ -1562,7 +1560,7 @@ def _enrich_queue_status_with_display_names( try: row = db.get_user(user_id=uid_raw) cache[uid_raw] = row.get("display_name") if row else None - except (AttributeError, KeyError, TypeError): + except AttributeError, KeyError, TypeError: cache[uid_raw] = None book["display_name"] = cache[uid_raw] diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 58aaac91..deebc95c 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -411,7 +411,7 @@ def _get_book_targets_for_batch(self, book_id: str) -> list[dict[str, Any]]: """Safely fetch targets for one book, falling back to an empty list.""" try: return self.get_book_targets(book_id) - except (NotImplementedError, ValueError): + except NotImplementedError, ValueError: return [] def set_book_target_state( diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 904873a2..06a2a6e6 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -581,12 +581,12 @@ def _extract_publish_year(data: dict) -> int | None: if data.get("release_year"): try: return int(data["release_year"]) - except (ValueError, TypeError): + except ValueError, TypeError: pass if data.get("release_date"): try: return int(str(data["release_date"])[:4]) - except (ValueError, TypeError): + except ValueError, TypeError: pass return None @@ -613,7 +613,7 @@ def _normalize_series_position(value: Any) -> float | None: try: return float(value) - except (TypeError, ValueError): + except TypeError, ValueError: return None @@ -1060,7 +1060,7 @@ def _fetch_list_books_by_id(self, list_id: int, page: int, limit: int) -> Search try: books_count = int(books_count_raw) - except (TypeError, ValueError): + except TypeError, ValueError: books_count = 0 books: list[BookMetadata] = [] @@ -1330,7 +1330,7 @@ def _search_title_options(self, query: str) -> list[dict[str, str]]: try: if release_year is not None and int(release_year) > current_year: continue - except (TypeError, ValueError): + except TypeError, ValueError: pass label = str(item.get("title") or "").strip() @@ -1363,7 +1363,7 @@ def _format_series_option_description(self, item: dict[str, Any]) -> str | None: if books_count is not None: books_count_int = int(books_count) parts.append(f"{books_count_int} book{'s' if books_count_int != 1 else ''}") - except (TypeError, ValueError): + except TypeError, ValueError: pass return " • ".join(parts) if parts else None @@ -1728,7 +1728,7 @@ def _fetch_user_books_by_status(self, status_id: int, page: int, limit: int) -> try: total_found = int(count_raw) - except (TypeError, ValueError): + except TypeError, ValueError: total_found = 0 books: list[BookMetadata] = [] @@ -1783,7 +1783,7 @@ def _fetch_user_lists(self) -> list[dict[str, str]]: def _format_label(name: str, books_count: Any) -> str: try: return f"{name} ({int(books_count)})" - except (TypeError, ValueError): + except TypeError, ValueError: return name for status in HARDCOVER_STATUSES: @@ -2434,7 +2434,7 @@ def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> Sear books=books, page=options.page, total_found=found_count, has_more=has_more ) - except (AttributeError, KeyError, TypeError, ValueError): + except AttributeError, KeyError, TypeError, ValueError: logger.exception("Hardcover search error") return SearchResult(books=[], page=options.page, total_found=0, has_more=False) @@ -2512,7 +2512,7 @@ def get_book(self, book_id: str) -> BookMetadata | None: except ValueError: logger.exception("Invalid book ID: %s", book_id) return None - except (AttributeError, KeyError, TypeError): + except AttributeError, KeyError, TypeError: logger.exception("Hardcover get_book error") return None @@ -2583,7 +2583,7 @@ def search_by_isbn(self, isbn: str) -> BookMetadata | None: return self._parse_book(book_data) - except (AttributeError, IndexError, KeyError, TypeError, ValueError): + except AttributeError, IndexError, KeyError, TypeError, ValueError: logger.exception("Hardcover ISBN search error") return None @@ -2857,7 +2857,7 @@ def _parse_book(self, book: dict) -> BookMetadata: if rating is not None: try: rating_str = f"{float(rating):.1f}" - except (TypeError, ValueError): + except TypeError, ValueError: rating_str = str(rating) if ratings_count: @@ -2870,7 +2870,7 @@ def _parse_book(self, book: dict) -> BookMetadata: if users_count: try: readers_value = f"{int(users_count):,}" - except (TypeError, ValueError): + except TypeError, ValueError: readers_value = str(users_count) display_fields.append(DisplayField(label="Readers", value=readers_value, icon="users")) diff --git a/shelfmark/metadata_providers/openlibrary.py b/shelfmark/metadata_providers/openlibrary.py index ce2353c3..3417bd4c 100644 --- a/shelfmark/metadata_providers/openlibrary.py +++ b/shelfmark/metadata_providers/openlibrary.py @@ -222,7 +222,7 @@ def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> list except requests.RequestException: logger.exception("Open Library search request failed") return [] - except (TypeError, ValueError): + except TypeError, ValueError: logger.exception("Open Library search parsing error") return [] return books @@ -261,7 +261,7 @@ def get_book(self, book_id: str) -> BookMetadata | None: except requests.RequestException: logger.exception("Open Library get_book request failed") return None - except (TypeError, ValueError): + except TypeError, ValueError: logger.exception("Open Library get_book parsing error") return None @@ -322,7 +322,7 @@ def search_by_isbn(self, isbn: str) -> BookMetadata | None: except requests.RequestException: logger.exception("Open Library ISBN search request failed") return None - except (TypeError, ValueError): + except TypeError, ValueError: logger.exception("Open Library ISBN search parsing error") return None @@ -513,7 +513,7 @@ def _get_author_name(self, author_key: str) -> str | None: author = response.json() return author.get("name") - except (requests.RequestException, ValueError): + except requests.RequestException, ValueError: # Don't log errors for author lookups - they're supplementary return None diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 0fa7d089..a86badc7 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -203,8 +203,18 @@ def _tag_has_class_containing(tag: Tag, needle: str) -> bool: # --- Distant-path language detection --- _DISTANT_PATH_EXTENSIONS = ( - "epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr", - "pdf", "zip", "rar", "m4b", "mp3", + "epub", + "mobi", + "azw3", + "fb2", + "djvu", + "cbz", + "cbr", + "pdf", + "zip", + "rar", + "m4b", + "mp3", ) _DISTANT_PATH_EXTENSION_PATTERN = "|".join(re.escape(e) for e in _DISTANT_PATH_EXTENSIONS) _DISTANT_PATH_PATTERN = re.compile( @@ -273,7 +283,7 @@ def _language_alias_to_code() -> dict[str, str]: try: raw = json.loads(data_path.read_text(encoding="utf-8")) - except (OSError, ValueError, TypeError): + except OSError, ValueError, TypeError: _LANGUAGE_ALIAS_TO_CODE = {} return _LANGUAGE_ALIAS_TO_CODE @@ -819,7 +829,7 @@ def _parse_book_info_page( slow_urls_no_waitlist.add(href) else: slow_urls_with_waitlist.add(href) - except (AttributeError, TypeError): + except AttributeError, TypeError: pass logger.debug( @@ -1566,7 +1576,13 @@ def _extract_slow_download_url( countdown_seconds, max_countdown_seconds, ) - logger.info("AA waitlist: %ss for %s (attempt %s/%s)", sleep_time, title, _countdown_attempts + 1, _AA_COUNTDOWN_MAX_RETRIES) + logger.info( + "AA waitlist: %ss for %s (attempt %s/%s)", + sleep_time, + title, + _countdown_attempts + 1, + _AA_COUNTDOWN_MAX_RETRIES, + ) # Live countdown with status updates for remaining in range(sleep_time, 0, -1): @@ -1668,7 +1684,7 @@ def _parse_countdown_seconds_from_element(element: Tag) -> int | None: """Parse an integer countdown from a tag, returning None when invalid.""" try: seconds = int(element.get_text(strip=True)) - except (ValueError, TypeError): + except ValueError, TypeError: return None if 0 < seconds < _AA_COUNTDOWN_MAX_SECONDS: diff --git a/shelfmark/release_sources/irc/cache.py b/shelfmark/release_sources/irc/cache.py index bc64e93f..e0b4e844 100644 --- a/shelfmark/release_sources/irc/cache.py +++ b/shelfmark/release_sources/irc/cache.py @@ -97,7 +97,7 @@ def _dict_to_release(data: dict[str, Any]) -> Release: if data.get("protocol"): try: data["protocol"] = ReleaseProtocol(data["protocol"]) - except (ValueError, KeyError): + except ValueError, KeyError: data["protocol"] = None return Release(**data) diff --git a/shelfmark/release_sources/newznab/source.py b/shelfmark/release_sources/newznab/source.py index ad32d5d2..966d6d0d 100644 --- a/shelfmark/release_sources/newznab/source.py +++ b/shelfmark/release_sources/newznab/source.py @@ -97,7 +97,7 @@ def add_flag(flag: object) -> None: try: if download_volume_factor is not None and float(download_volume_factor) == 0.0: is_freeleech = True - except (TypeError, ValueError): + except TypeError, ValueError: pass if any(f.lower() in {"freeleech", "fl"} for f in indexer_flags): diff --git a/shelfmark/release_sources/prowlarr/torznab.py b/shelfmark/release_sources/prowlarr/torznab.py index 2d277b1f..f354bb88 100644 --- a/shelfmark/release_sources/prowlarr/torznab.py +++ b/shelfmark/release_sources/prowlarr/torznab.py @@ -70,7 +70,7 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]: try: root = DefusedElementTree.fromstring(xml_text) - except (DefusedElementTree.ParseError, DefusedXmlException): + except DefusedElementTree.ParseError, DefusedXmlException: return [] items = root.findall(".//item") diff --git a/tests/core/test_notifications.py b/tests/core/test_notifications.py index 01de6e84..95ededee 100644 --- a/tests/core/test_notifications.py +++ b/tests/core/test_notifications.py @@ -611,12 +611,15 @@ def _fake_get(key, default="", **_kwargs): monkeypatch.setattr(config_module.config, "get", _fake_get) def test_http_proxy_mode_injects_proxy_env(self, monkeypatch): - self._patch_config(monkeypatch, { - "PROXY_MODE": "http", - "HTTP_PROXY": "http://proxy.example.com:8080", - "HTTPS_PROXY": "", - "NO_PROXY": "", - }) + self._patch_config( + monkeypatch, + { + "PROXY_MODE": "http", + "HTTP_PROXY": "http://proxy.example.com:8080", + "HTTPS_PROXY": "", + "NO_PROXY": "", + }, + ) monkeypatch.delenv("HTTP_PROXY", raising=False) monkeypatch.delenv("HTTPS_PROXY", raising=False) @@ -626,11 +629,14 @@ def test_http_proxy_mode_injects_proxy_env(self, monkeypatch): assert result["HTTPS_PROXY"] == "http://proxy.example.com:8080" def test_socks5_proxy_mode_injects_socks_env(self, monkeypatch): - self._patch_config(monkeypatch, { - "PROXY_MODE": "socks5", - "SOCKS5_PROXY": "socks5://proxy.example.com:1080", - "NO_PROXY": "", - }) + self._patch_config( + monkeypatch, + { + "PROXY_MODE": "socks5", + "SOCKS5_PROXY": "socks5://proxy.example.com:1080", + "NO_PROXY": "", + }, + ) monkeypatch.delenv("HTTP_PROXY", raising=False) monkeypatch.delenv("HTTPS_PROXY", raising=False) @@ -647,11 +653,14 @@ def test_no_proxy_mode_returns_empty_dict(self, monkeypatch): assert result == {} def test_does_not_override_already_set_env_vars(self, monkeypatch): - self._patch_config(monkeypatch, { - "PROXY_MODE": "http", - "HTTP_PROXY": "http://new-proxy.example.com:8080", - "NO_PROXY": "", - }) + self._patch_config( + monkeypatch, + { + "PROXY_MODE": "http", + "HTTP_PROXY": "http://new-proxy.example.com:8080", + "NO_PROXY": "", + }, + ) monkeypatch.setenv("HTTP_PROXY", "http://existing-proxy.example.com:3128") result = notifications_module._apprise_proxy_env() diff --git a/tests/core/test_text_match.py b/tests/core/test_text_match.py index b9e7b93d..063afd90 100644 --- a/tests/core/test_text_match.py +++ b/tests/core/test_text_match.py @@ -1,4 +1,3 @@ - from shelfmark.core.text_match import ( DEFAULT_TITLE_MATCH_THRESHOLD, author_surname, diff --git a/tests/direct_download/test_search_queries.py b/tests/direct_download/test_search_queries.py index 582b5847..fe106e5f 100644 --- a/tests/direct_download/test_search_queries.py +++ b/tests/direct_download/test_search_queries.py @@ -186,6 +186,7 @@ def fake_search_books(query: str, filters): # --- Distant-path language detection tests --- + def _patch_path_language(monkeypatch, enabled: bool = True): import shelfmark.release_sources.direct_download as dd @@ -203,6 +204,7 @@ def _fake_get(key: str, default=None, user_id=None): def _row_from_html(html: str): from bs4 import BeautifulSoup + return BeautifulSoup(html, "html.parser").find("tr") @@ -260,9 +262,9 @@ def test_sets_unknown_when_path_has_no_language(monkeypatch): def test_avoids_en_false_positive_when_french_present(monkeypatch): dd = _patch_path_language(monkeypatch) - row = _row_from_html(_make_row( - r"lgli/V:\comics\_0DAY2\Stripboeken Frans - BD en Français\[BD Fr] Book.cbr" - )) + row = _row_from_html( + _make_row(r"lgli/V:\comics\_0DAY2\Stripboeken Frans - BD en Français\[BD Fr] Book.cbr") + ) record = dd._parse_search_result_row(row) assert record is not None assert record.language == "fr"