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 FR | Auteur |
+ Editeur | 2025 |
+ - | - | |
+ fiction | pdf |
+ 2 mb |
+ lgli/V:\comics\_0DAY3\[Fr]\Book FR.pdf |
+
+
+  |
+ Book EN | Author |
+ Publisher | 2025 |
+ - | - | |
+ fiction | pdf |
+ 2 mb |
+ lgli/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 (
|