From e819a77656295dbbf2dbc5964372e798ec7e3096 Mon Sep 17 00:00:00 2001 From: stonebeta Date: Sun, 19 Apr 2026 18:49:48 +0800 Subject: [PATCH 01/16] perf(entrypoint): narrow chown scope to skip .venv (#1011) Reduces container startup time on low-spec devices (TrueNAS etc.) by limiting recursive chown to /app/data, /app/config, /home/ab instead of /app. Closes #969 (related performance). --- entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 979180b1..a3105b64 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,6 +10,6 @@ fi groupmod -o -g "${PGID}" ab usermod -o -u "${PUID}" ab -chown ab:ab -R /app /home/ab +chown ab:ab -R /app/data /app/config /home/ab exec su-exec "${PUID}:${PGID}" python main.py \ No newline at end of file From 117c24ce77aa3e41f8f8769534ec060f9edf3591 Mon Sep 17 00:00:00 2001 From: Alex Tazman <121289167+Tazman0872@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:49:59 +0800 Subject: [PATCH 02/16] fix(checker): respect SSL setting when probing qBittorrent (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup probe hardcoded http:// ignoring settings.downloader.ssl, causing 5-minute startup delay when qBittorrent is HTTPS-only or redirects http→https. Now derives the scheme from settings.downloader.ssl. --- backend/src/module/checker/checker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/module/checker/checker.py b/backend/src/module/checker/checker.py index e5af98e7..5c2bb233 100644 --- a/backend/src/module/checker/checker.py +++ b/backend/src/module/checker/checker.py @@ -66,8 +66,9 @@ class Checker: return True try: + prefix = "https" if settings.downloader.ssl else "http" url = ( - f"http://{settings.downloader.host}" + f"{prefix}://{settings.downloader.host}" if "://" not in settings.downloader.host else f"{settings.downloader.host}" ) From 6235892de22bc03852561d1f67febd23c20171b2 Mon Sep 17 00:00:00 2001 From: ZzzzSsssWwww Date: Sun, 19 Apr 2026 18:50:09 +0800 Subject: [PATCH 03/16] fix(rss): cascade-delete torrents when deleting RSSItem (#1019) Deleting an RSSItem failed with sqlite3.IntegrityError: FOREIGN KEY constraint failed when child torrents still referenced it, leaving the sidebar entry stuck in the UI. - delete() now removes referencing torrents in the same transaction before removing the RSSItem - Adds missing session.rollback() in the exception path - rss_loop wraps each iteration to skip stale RSSItems deleted mid-loop via API - Tests enable PRAGMA foreign_keys=ON to match production and gain 5 cascade-delete regression tests Closes #1010 Closes #1017 --- backend/src/module/core/sub_thread.py | 9 +- backend/src/module/database/rss.py | 19 ++-- backend/src/test/test_database.py | 130 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/backend/src/module/core/sub_thread.py b/backend/src/module/core/sub_thread.py index a1d3c9ec..96b664b2 100644 --- a/backend/src/module/core/sub_thread.py +++ b/backend/src/module/core/sub_thread.py @@ -31,7 +31,14 @@ class RSSThread(ProgramStatus): # Analyse RSS rss_list = engine.rss.search_aggregate() for rss in rss_list: - await self.analyser.rss_to_data(rss, engine) + try: + await self.analyser.rss_to_data(rss, engine) + except Exception: + # RSS 可能在遍历期间被 API 删除,跳过即可 + logger.debug( + "[RSSThread] Skipping RSS id=%s, likely deleted", + rss.id if hasattr(rss, "id") else "?", + ) # Run RSS Engine await engine.refresh_rss(client) if settings.bangumi_manage.eps_complete: diff --git a/backend/src/module/database/rss.py b/backend/src/module/database/rss.py index 67d8ad29..8004777e 100644 --- a/backend/src/module/database/rss.py +++ b/backend/src/module/database/rss.py @@ -2,7 +2,7 @@ import logging from sqlmodel import Session, and_, delete, select -from module.models import RSSItem, RSSUpdate +from module.models import RSSItem, RSSUpdate, Torrent logger = logging.getLogger(__name__) @@ -107,16 +107,23 @@ class RSSDatabase: return list(result.scalars().all()) def delete(self, _id: int) -> bool: - condition = delete(RSSItem).where(RSSItem.id == _id) try: - self.session.execute(condition) + # 先删除引用该 RSS 的 torrent,避免外键约束报错 + self.session.execute(delete(Torrent).where(Torrent.rss_id == _id)) + self.session.execute(delete(RSSItem).where(RSSItem.id == _id)) self.session.commit() return True except Exception as e: + self.session.rollback() logger.error(f"Delete RSS Item failed. Because: {e}") return False def delete_all(self): - condition = delete(RSSItem) - self.session.execute(condition) - self.session.commit() + try: + # 先删除所有引用 RSS 的 torrent,避免外键约束报错 + self.session.execute(delete(Torrent).where(Torrent.rss_id != None)) # noqa: E711 + self.session.execute(delete(RSSItem)) + self.session.commit() + except Exception as e: + self.session.rollback() + logger.error(f"Delete all RSS Items failed. Because: {e}") diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index 971dbbf3..f3c282ac 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -1,6 +1,7 @@ import json import pytest +from sqlalchemy import event from sqlmodel import Session, SQLModel, create_engine from module.database.bangumi import BangumiDatabase @@ -12,6 +13,30 @@ from module.models import Bangumi, RSSItem, Torrent engine = create_engine("sqlite://", echo=False) +@event.listens_for(engine, "connect") +def _enable_foreign_keys(dbapi_conn, connection_record): + """匹配生产环境行为:启用 SQLite 外键约束。""" + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +def _ensure_bangumi(session, bangumi_id: int): + """确保 bangumi 表中存在指定 id 的记录,满足外键约束。""" + if session.get(Bangumi, bangumi_id) is None: + session.add(Bangumi( + id=bangumi_id, + official_title=f"Stub Anime {bangumi_id}", + title_raw=f"Stub {bangumi_id}", + group_name="TestGroup", + dpi="1080p", + source="Web", + subtitle="CHT", + rss_link=f"stub_{bangumi_id}", + )) + session.commit() + + @pytest.fixture def db_session(): SQLModel.metadata.create_all(engine) @@ -189,6 +214,9 @@ def test_torrent_with_bangumi_id(db_session): """Test torrent with bangumi_id for offset lookup.""" db = TorrentDatabase(db_session) + # 父记录满足外键约束 + _ensure_bangumi(db_session, 42) + # Create torrent linked to a bangumi torrent = Torrent( name="[SubGroup] Test Anime - 04 [1080p].mkv", @@ -445,6 +473,7 @@ class TestDeleteByBangumiId: def test_deletes_matching_torrents(self, db_session): db = TorrentDatabase(db_session) + _ensure_bangumi(db_session, 10) for i in range(3): db.add(Torrent(name=f"torrent_{i}", url=f"https://example.com/{i}", bangumi_id=10)) assert len(db.search_all()) == 3 @@ -455,6 +484,8 @@ class TestDeleteByBangumiId: def test_leaves_other_bangumi_torrents(self, db_session): db = TorrentDatabase(db_session) + _ensure_bangumi(db_session, 20) + _ensure_bangumi(db_session, 30) db.add(Torrent(name="keep", url="https://example.com/keep", bangumi_id=20)) db.add(Torrent(name="delete", url="https://example.com/delete", bangumi_id=30)) @@ -466,6 +497,7 @@ class TestDeleteByBangumiId: def test_no_match_returns_zero(self, db_session): db = TorrentDatabase(db_session) + _ensure_bangumi(db_session, 5) db.add(Torrent(name="unrelated", url="https://example.com/1", bangumi_id=5)) count = db.delete_by_bangumi_id(999) @@ -474,6 +506,7 @@ class TestDeleteByBangumiId: def test_skips_null_bangumi_id(self, db_session): db = TorrentDatabase(db_session) + _ensure_bangumi(db_session, 7) db.add(Torrent(name="orphan", url="https://example.com/orphan", bangumi_id=None)) db.add(Torrent(name="target", url="https://example.com/target", bangumi_id=7)) @@ -486,6 +519,7 @@ class TestDeleteByBangumiId: def test_check_new_finds_urls_after_cleanup(self, db_session): """Core scenario: after deleting torrent records, check_new should treat those URLs as new.""" db = TorrentDatabase(db_session) + _ensure_bangumi(db_session, 42) db.add(Torrent(name="ep01", url="https://mikan.me/t/001", bangumi_id=42)) db.add(Torrent(name="ep02", url="https://mikan.me/t/002", bangumi_id=42)) @@ -579,3 +613,99 @@ def test_match_list_with_aliases(db_session): unmatched = db.match_list(torrents, "rss2") assert len(unmatched) == 1 assert unmatched[0].name == "[OtherGroup] Different Anime - 01.mkv" + + +# ============================================================ +# RSS Foreign Key Constraint Tests +# ============================================================ + + +class TestRSSDeleteWithTorrents: + """Regression tests: deleting RSSItem must cascade-delete referencing torrents.""" + + def test_delete_rss_with_torrents(self, db_session): + """删除 RSSItem 时应自动清除引用它的 torrent 记录。""" + rss_db = RSSDatabase(db_session) + torrent_db = TorrentDatabase(db_session) + + # 创建 RSS 和关联的 torrent + rss = RSSItem(url="https://mikanani.me/RSS/test", name="Test RSS") + rss_db.add(rss) + + torrent_db.add(Torrent(name="ep01", url="https://example.com/1", rss_id=rss.id)) + torrent_db.add(Torrent(name="ep02", url="https://example.com/2", rss_id=rss.id)) + # 不关联此 RSS 的 torrent + torrent_db.add(Torrent(name="other", url="https://example.com/3", rss_id=None)) + + assert len(torrent_db.search_rss(rss.id)) == 2 + + # 删除 RSS(不应报外键错误) + result = rss_db.delete(rss.id) + assert result is True + + # RSS 和关联 torrent 都应被删除 + assert rss_db.search_id(rss.id) is None + assert len(torrent_db.search_rss(rss.id)) == 0 + # 无关 torrent 不受影响 + assert len(torrent_db.search_all()) == 1 + + def test_delete_rss_without_torrents(self, db_session): + """删除没有关联 torrent 的 RSSItem 应正常工作。""" + rss_db = RSSDatabase(db_session) + + rss = RSSItem(url="https://mikanani.me/RSS/empty", name="Empty RSS") + rss_db.add(rss) + + result = rss_db.delete(rss.id) + assert result is True + assert rss_db.search_id(rss.id) is None + + def test_delete_rss_cascades_in_transaction(self, db_session): + """验证删除操作在同一事务中完成,要么全成功要么全回滚。""" + rss_db = RSSDatabase(db_session) + torrent_db = TorrentDatabase(db_session) + + rss = RSSItem(url="https://mikanani.me/RSS/tx", name="TX Test") + rss_db.add(rss) + + for i in range(5): + torrent_db.add( + Torrent(name=f"ep{i:02d}", url=f"https://example.com/{i}", rss_id=rss.id) + ) + + assert len(torrent_db.search_rss(rss.id)) == 5 + + rss_db.delete(rss.id) + + # 全部清理干净 + assert rss_db.search_id(rss.id) is None + assert len(torrent_db.search_rss(rss.id)) == 0 + + def test_delete_all_rss_with_torrents(self, db_session): + """delete_all 应删除所有 RSS 及其关联 torrent。""" + rss_db = RSSDatabase(db_session) + torrent_db = TorrentDatabase(db_session) + + rss1 = RSSItem(url="https://mikanani.me/RSS/a", name="RSS A") + rss2 = RSSItem(url="https://mikanani.me/RSS/b", name="RSS B") + rss_db.add(rss1) + rss_db.add(rss2) + + torrent_db.add(Torrent(name="t1", url="https://example.com/1", rss_id=rss1.id)) + torrent_db.add(Torrent(name="t2", url="https://example.com/2", rss_id=rss2.id)) + # rss_id 为 None 的 torrent 应不受影响 + torrent_db.add(Torrent(name="orphan", url="https://example.com/3", rss_id=None)) + + rss_db.delete_all() + + assert len(rss_db.search_all()) == 0 + # 只剩 rss_id 为 None 的 + remaining = torrent_db.search_all() + assert len(remaining) == 1 + assert remaining[0].name == "orphan" + + def test_delete_nonexistent_rss(self, db_session): + """删除不存在的 RSS 不应报错。""" + rss_db = RSSDatabase(db_session) + result = rss_db.delete(999) + assert result is True From d849edf89f21b42d40c20a70ffcbe64a9877f913 Mon Sep 17 00:00:00 2001 From: ZzzzSsssWwww Date: Sun, 19 Apr 2026 18:50:18 +0800 Subject: [PATCH 04/16] fix(network): harden shared httpx client against stale connections (#1018) RSS loop runs every 900s but reuses the same httpx.AsyncClient connection pool, well past server keep-alive expiry (60-120s). Reusing dead sockets produced ConnectTimeout errors over time. - Configure httpx.Limits with keepalive_expiry=60s so idle connections are dropped proactively - reset_shared_client() called on retry paths to force a fresh pool after ConnectError - asyncio.Semaphore(5) in refresh_rss() caps concurrent RSS fetches to avoid tripping site rate limits Closes #1008 Related #1010, #742, #701 --- backend/src/module/network/request_url.py | 38 ++++++++- backend/src/module/rss/engine.py | 12 ++- backend/src/test/test_request_url.py | 99 +++++++++++++++++++++++ backend/src/test/test_rss_engine_new.py | 87 ++++++++++++++------ 4 files changed, 203 insertions(+), 33 deletions(-) create mode 100644 backend/src/test/test_request_url.py diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index f0c90c91..11cf9db3 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -12,6 +12,15 @@ logger = logging.getLogger(__name__) _shared_client: httpx.AsyncClient | None = None _shared_client_proxy_key: str | None = None +# RSS 循环间隔 900s, 远超服务端 keep-alive 超时(60-120s) +# keepalive_expiry=60 让空闲连接在过期前主动丢弃,避免复用过期连接 +# max_connections=20 足够覆盖典型订阅数量 +_CONNECTION_LIMITS = httpx.Limits( + max_keepalive_connections=5, + max_connections=20, + keepalive_expiry=60.0, +) + def _proxy_config_key() -> str: if settings.proxy.enable: @@ -33,22 +42,37 @@ async def get_shared_client() -> httpx.AsyncClient: proxy_url = f"http://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}" else: proxy_url = f"http://{settings.proxy.host}:{settings.proxy.port}" - _shared_client = httpx.AsyncClient(proxy=proxy_url, timeout=timeout) + _shared_client = httpx.AsyncClient( + proxy=proxy_url, timeout=timeout, limits=_CONNECTION_LIMITS + ) elif settings.proxy.type == "socks5": if settings.proxy.username: socks_url = f"socks5://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}" else: socks_url = f"socks5://{settings.proxy.host}:{settings.proxy.port}" transport = AsyncProxyTransport.from_url(socks_url, rdns=True) - _shared_client = httpx.AsyncClient(transport=transport, timeout=timeout) + _shared_client = httpx.AsyncClient( + transport=transport, timeout=timeout, limits=_CONNECTION_LIMITS + ) else: - _shared_client = httpx.AsyncClient(timeout=timeout) + _shared_client = httpx.AsyncClient( + timeout=timeout, limits=_CONNECTION_LIMITS + ) else: - _shared_client = httpx.AsyncClient(timeout=timeout) + _shared_client = httpx.AsyncClient(timeout=timeout, limits=_CONNECTION_LIMITS) _shared_client_proxy_key = current_key return _shared_client +async def reset_shared_client(): + """关闭并清除共享客户端,下次请求时自动创建新连接池。""" + global _shared_client, _shared_client_proxy_key + if _shared_client is not None: + await _shared_client.aclose() + _shared_client = None + _shared_client_proxy_key = None + + class RequestURL: # More complete User-Agent to avoid Cloudflare blocking DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" @@ -91,6 +115,9 @@ class RequestURL: try_time += 1 if try_time >= retry: break + # 连接错误时重建客户端以清除过期连接 + await reset_shared_client() + self._client = await get_shared_client() await asyncio.sleep(5) except Exception as e: logger.warning(f"[Network] Unexpected error for {url}: {e}") @@ -114,6 +141,9 @@ class RequestURL: try_time += 1 if try_time >= retry: break + # 连接错误时重建客户端,清除过期连接 + await reset_shared_client() + self._client = await get_shared_client() await asyncio.sleep(5) except Exception as e: logger.debug(e) diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index a9dc9dac..e126b5ef 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -149,11 +149,15 @@ class RSSEngine(Database): else: rss_item = self.rss.search_id(rss_id) rss_items = [rss_item] if rss_item else [] - # From RSS Items, fetch all torrents concurrently + # From RSS Items, fetch all torrents with concurrency limit logger.debug("[Engine] Get %s RSS items", len(rss_items)) - results = await asyncio.gather( - *[self._pull_rss_with_status(rss_item) for rss_item in rss_items] - ) + semaphore = asyncio.Semaphore(5) + + async def _limited_pull(item): + async with semaphore: + return await self._pull_rss_with_status(item) + + results = await asyncio.gather(*[_limited_pull(item) for item in rss_items]) now = datetime.now(timezone.utc).isoformat() # Process results sequentially (DB operations) for rss_item, (new_torrents, error) in zip(rss_items, results): diff --git a/backend/src/test/test_request_url.py b/backend/src/test/test_request_url.py new file mode 100644 index 00000000..4b85de3d --- /dev/null +++ b/backend/src/test/test_request_url.py @@ -0,0 +1,99 @@ +"""Tests for network request_url: shared client configuration and reset.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from module.network.request_url import get_shared_client, reset_shared_client + + +@pytest.fixture(autouse=True) +async def _clean_shared_client(): + """Ensure shared client is reset after each test.""" + yield + import module.network.request_url as mod + + if mod._shared_client is not None: + await mod._shared_client.aclose() + mod._shared_client = None + mod._shared_client_proxy_key = None + + +class TestSharedClientLimits: + async def test_client_has_keepalive_expiry(self): + """Shared client should use a finite keepalive_expiry.""" + client = await get_shared_client() + pool = client._transport._pool + assert pool._keepalive_expiry is not None + assert pool._keepalive_expiry > 0 + + async def test_client_has_max_connections(self): + """Shared client should have a connection pool limit.""" + client = await get_shared_client() + pool = client._transport._pool + assert pool._max_connections is not None + assert pool._max_connections > 0 + + +class TestResetSharedClient: + async def test_reset_closes_existing_client(self): + """reset_shared_client should close and clear the shared client.""" + client = await get_shared_client() + assert client is not None + + await reset_shared_client() + + import module.network.request_url as mod + + assert mod._shared_client is None + assert mod._shared_client_proxy_key is None + + async def test_reset_idempotent_when_no_client(self): + """reset_shared_client should be safe when no client exists.""" + import module.network.request_url as mod + + mod._shared_client = None + mod._shared_client_proxy_key = None + await reset_shared_client() + + async def test_new_client_after_reset(self): + """After reset, get_shared_client returns a fresh client.""" + old_client = await get_shared_client() + await reset_shared_client() + new_client = await get_shared_client() + assert new_client is not old_client + + +class TestRetryWithReset: + async def test_get_url_resets_on_connect_error(self): + """get_url should call reset_shared_client after ConnectTimeout.""" + import httpx + from module.network.request_url import RequestURL + + call_count = 0 + + async def mock_get(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.ConnectTimeout("Connection timed out") + resp = MagicMock() + resp.status_code = 200 + resp.raise_for_status = MagicMock() + return resp + + with ( + patch("module.network.request_url.get_shared_client") as mock_get_client, + patch( + "module.network.request_url.reset_shared_client", + new_callable=AsyncMock, + ) as mock_reset, + ): + mock_client = AsyncMock() + mock_client.get = mock_get + mock_get_client.return_value = mock_client + + async with RequestURL() as req: + result = await req.get_url("https://example.com/test", retry=2) + + mock_reset.assert_called() + assert call_count == 2 diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 1a925d3f..14ea73c2 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -1,5 +1,7 @@ """Tests for RSS engine: pull_rss, match_torrent, refresh_rss, add_rss.""" +import asyncio + import pytest from unittest.mock import AsyncMock, patch @@ -51,7 +53,9 @@ class TestPullRss: Torrent(name="new1", url="https://example.com/new1.torrent"), Torrent(name="new2", url="https://example.com/new2.torrent"), ] - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: mock_get.return_value = all_torrents result = await rss_engine.pull_rss(rss_item) @@ -67,7 +71,9 @@ class TestPullRss: existing = make_torrent(url="https://example.com/only.torrent", rss_id=1) rss_engine.torrent.add(existing) - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: mock_get.return_value = [ Torrent(name="only", url="https://example.com/only.torrent") ] @@ -81,7 +87,9 @@ class TestPullRss: rss_engine.rss.add(rss_item) rss_item = rss_engine.rss.search_id(1) - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: mock_get.return_value = [] result = await rss_engine.pull_rss(rss_item) @@ -99,9 +107,7 @@ class TestMatchTorrent: bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="") rss_engine.bangumi.add(bangumi) - torrent = make_torrent( - name="[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv" - ) + torrent = make_torrent(name="[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv") result = rss_engine.match_torrent(torrent) assert result is not None @@ -122,9 +128,7 @@ class TestMatchTorrent: bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="720") rss_engine.bangumi.add(bangumi) - torrent = make_torrent( - name="[Sub] Mushoku Tensei - 01 [720p].mkv" - ) + torrent = make_torrent(name="[Sub] Mushoku Tensei - 01 [720p].mkv") result = rss_engine.match_torrent(torrent) assert result is None @@ -134,9 +138,7 @@ class TestMatchTorrent: bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="") rss_engine.bangumi.add(bangumi) - torrent = make_torrent( - name="[Sub] Mushoku Tensei - 01 [720p].mkv" - ) + torrent = make_torrent(name="[Sub] Mushoku Tensei - 01 [720p].mkv") result = rss_engine.match_torrent(torrent) assert result is not None @@ -147,9 +149,7 @@ class TestMatchTorrent: rss_engine.bangumi.add(bangumi) # Torrent has "hevc" in lowercase - should still be filtered - torrent = make_torrent( - name="[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv" - ) + torrent = make_torrent(name="[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv") result = rss_engine.match_torrent(torrent) assert result is None @@ -201,7 +201,9 @@ class TestRefreshRss: name="[Sub] Mushoku Tensei - 12 [1080p].mkv", url="https://example.com/ep12.torrent", ) - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: mock_get.return_value = [new_torrent] # Create a mock client @@ -227,7 +229,9 @@ class TestRefreshRss: name="[Sub] Unknown Anime - 01 [1080p].mkv", url="https://example.com/unknown.torrent", ) - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: mock_get.return_value = [unmatched] client = AsyncMock() await rss_engine.refresh_rss(client) @@ -244,7 +248,9 @@ class TestRefreshRss: rss_engine.rss.add(rss1) rss_engine.rss.add(rss2) - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: mock_get.return_value = [] client = AsyncMock() await rss_engine.refresh_rss(client, rss_id=2) @@ -254,7 +260,9 @@ class TestRefreshRss: async def test_refresh_nonexistent_rss_id(self, rss_engine): """refresh_rss with non-existent rss_id does nothing.""" - with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get: + with patch.object( + RSSEngine, "_get_torrents", new_callable=AsyncMock + ) as mock_get: client = AsyncMock() await rss_engine.refresh_rss(client, rss_id=999) @@ -284,9 +292,7 @@ class TestAddRss: async def test_add_without_name_fetches_title(self, rss_engine): """add_rss without name calls get_rss_title to auto-discover title.""" - with patch( - "module.rss.engine.RequestContent" - ) as MockReq: + with patch("module.rss.engine.RequestContent") as MockReq: mock_instance = AsyncMock() mock_instance.get_rss_title = AsyncMock(return_value="Fetched Title") MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_instance) @@ -303,9 +309,7 @@ class TestAddRss: async def test_add_without_name_fetch_fails(self, rss_engine): """add_rss returns error when title fetch fails.""" - with patch( - "module.rss.engine.RequestContent" - ) as MockReq: + with patch("module.rss.engine.RequestContent") as MockReq: mock_instance = AsyncMock() mock_instance.get_rss_title = AsyncMock(return_value=None) MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_instance) @@ -332,3 +336,36 @@ class TestAddRss: assert result.status is False assert result.status_code == 406 + + +class TestRefreshRssConcurrency: + async def test_concurrent_requests_limited(self, rss_engine): + """refresh_rss should limit concurrent requests via semaphore.""" + rss_items = [ + make_rss_item(name=f"Feed {i}", url=f"https://feed{i}.com/rss") + for i in range(10) + ] + for item in rss_items: + rss_engine.rss.add(item) + + active_count = 0 + max_active = 0 + lock = asyncio.Lock() + + async def track_concurrency(rss_item): + nonlocal active_count, max_active + async with lock: + active_count += 1 + max_active = max(max_active, active_count) + await asyncio.sleep(0.01) + async with lock: + active_count -= 1 + return [], None + + with patch.object( + rss_engine, "_pull_rss_with_status", side_effect=track_concurrency + ): + client = AsyncMock() + await rss_engine.refresh_rss(client) + + assert max_active <= 5 From 4e9d8b34ab2562599c1fdf376863865bfa234b15 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 19 Apr 2026 12:56:29 +0200 Subject: [PATCH 05/16] fix(downloader): parse Windows qB save_path on Linux hosts (#1016) When AB runs in a Linux container and qBittorrent runs on a Windows host, qB returns save_path strings with backslash separators. PurePosixPath treats those as a single unsplittable segment, so _path_to_bangumi never matched "Season N" and every non-S1 bangumi collapsed to Season 1. PureWindowsPath accepts both "\\" and "/" as separators, so using it for the parsing side handles both layouts. Path generation (_gen_save_path / _join_path) still uses the platform-conditional Path alias, so AB keeps emitting native paths for the host it runs on. Diagnosis and fix credited to the reporter in #1016. Closes #1016 --- backend/src/module/downloader/path.py | 9 ++++++--- backend/src/test/test_path_parser.py | 28 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index 6e6caf74..af5232db 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -1,6 +1,7 @@ import logging import re from os import PathLike +from pathlib import PureWindowsPath from module.conf import PLATFORM, settings from module.models import Bangumi, BangumiUpdate @@ -36,9 +37,11 @@ class TorrentPath: @staticmethod def _path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str = ""): - # Split save path and download path - save_parts = Path(save_path).parts - download_parts = Path(settings.downloader.path).parts + # Use PureWindowsPath regardless of the host AB runs on: it accepts + # both "\" and "/" separators, so a qBittorrent-on-Windows save_path + # reaching a Linux AB still splits into segments correctly (#1016). + save_parts = PureWindowsPath(save_path).parts + download_parts = PureWindowsPath(settings.downloader.path).parts # Get bangumi name and season bangumi_name = "" season = 1 diff --git a/backend/src/test/test_path_parser.py b/backend/src/test/test_path_parser.py index 9221b69f..afb841a4 100644 --- a/backend/src/test/test_path_parser.py +++ b/backend/src/test/test_path_parser.py @@ -13,6 +13,34 @@ def test_path_to_bangumi(): assert season == 2 +def test_path_to_bangumi_windows_style_save_path(): + """Regression for #1016: when qBittorrent runs on Windows and AB runs on + Linux, qB returns backslash paths. PurePosixPath treats the whole string + as one segment, leaving season stuck at 1.""" + from module.downloader.path import TorrentPath + + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = r"D:\video\Bangumis" + path = r"D:\video\Bangumis\小书痴的下克上\Season 4" + bangumi_name, season = TorrentPath._path_to_bangumi(path) + + assert bangumi_name == "小书痴的下克上" + assert season == 4 + + +def test_path_to_bangumi_posix_path_on_linux_ab(): + """Regression guard: POSIX paths still parse correctly after the fix.""" + from module.downloader.path import TorrentPath + + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + path = "/downloads/Bangumi/葬送的芙莉莲/Season 2" + bangumi_name, season = TorrentPath._path_to_bangumi(path) + + assert bangumi_name == "葬送的芙莉莲" + assert season == 2 + + class TestGenSavePath: """Tests for TorrentPath._gen_save_path with season_offset.""" From 813078913a753d28f5e331f60d3a1f8245630157 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 19 Apr 2026 12:57:40 +0200 Subject: [PATCH 06/16] fix(network): follow 302 redirects in shared httpx client (#983) Mikanime's mirror (and some CDN-fronted sources) respond with 302 to the canonical host. httpx AsyncClient defaults to follow_redirects=False, so raise_for_status surfaced the 302 as an HTTPStatusError and the RSS pull failed in a retry loop. Enable follow_redirects=True for every construction of the shared client (proxy, socks5, and direct branches) via a shared kwargs dict. Closes #983 --- backend/src/module/network/request_url.py | 40 ++++++++++++++--------- backend/src/test/test_request_url.py | 6 ++++ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index 11cf9db3..ab33d192 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -36,30 +36,32 @@ async def get_shared_client() -> httpx.AsyncClient: if _shared_client is not None: await _shared_client.aclose() timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0) + # follow_redirects=True: Mikan mirrors and some CDNs respond with 302 to the + # canonical host; without this, raise_for_status treats the redirect as an + # error and the RSS pull fails (#983). + common_kwargs = { + "timeout": timeout, + "limits": _CONNECTION_LIMITS, + "follow_redirects": True, + } if settings.proxy.enable: if "http" in settings.proxy.type: if settings.proxy.username: proxy_url = f"http://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}" else: proxy_url = f"http://{settings.proxy.host}:{settings.proxy.port}" - _shared_client = httpx.AsyncClient( - proxy=proxy_url, timeout=timeout, limits=_CONNECTION_LIMITS - ) + _shared_client = httpx.AsyncClient(proxy=proxy_url, **common_kwargs) elif settings.proxy.type == "socks5": if settings.proxy.username: socks_url = f"socks5://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}" else: socks_url = f"socks5://{settings.proxy.host}:{settings.proxy.port}" transport = AsyncProxyTransport.from_url(socks_url, rdns=True) - _shared_client = httpx.AsyncClient( - transport=transport, timeout=timeout, limits=_CONNECTION_LIMITS - ) + _shared_client = httpx.AsyncClient(transport=transport, **common_kwargs) else: - _shared_client = httpx.AsyncClient( - timeout=timeout, limits=_CONNECTION_LIMITS - ) + _shared_client = httpx.AsyncClient(**common_kwargs) else: - _shared_client = httpx.AsyncClient(timeout=timeout, limits=_CONNECTION_LIMITS) + _shared_client = httpx.AsyncClient(**common_kwargs) _shared_client_proxy_key = current_key return _shared_client @@ -91,7 +93,9 @@ class RequestURL: } # For torrent files, use different Accept header if url.endswith(".torrent") or "/download/" in url: - base_headers["Accept"] = "application/x-bittorrent, application/octet-stream, */*" + base_headers["Accept"] = ( + "application/x-bittorrent, application/octet-stream, */*" + ) else: base_headers["Accept"] = "application/xml, text/xml, */*" return base_headers @@ -102,7 +106,11 @@ class RequestURL: while True: try: req = await self._client.get(url=url, headers=headers) - logger.debug("[Network] Successfully connected to %s. Status: %s", url, req.status_code) + logger.debug( + "[Network] Successfully connected to %s. Status: %s", + url, + req.status_code, + ) req.raise_for_status() return req except httpx.HTTPStatusError as e: @@ -122,16 +130,16 @@ class RequestURL: except Exception as e: logger.warning(f"[Network] Unexpected error for {url}: {e}") break - logger.error(f"[Network] Unable to connect to {url}, Please check your network settings") + logger.error( + f"[Network] Unable to connect to {url}, Please check your network settings" + ) return None async def post_url(self, url: str, data: dict, retry=3): try_time = 0 while True: try: - req = await self._client.post( - url=url, headers=self.header, data=data - ) + req = await self._client.post(url=url, headers=self.header, data=data) req.raise_for_status() return req except httpx.RequestError: diff --git a/backend/src/test/test_request_url.py b/backend/src/test/test_request_url.py index 4b85de3d..f5be50c6 100644 --- a/backend/src/test/test_request_url.py +++ b/backend/src/test/test_request_url.py @@ -33,6 +33,12 @@ class TestSharedClientLimits: assert pool._max_connections is not None assert pool._max_connections > 0 + async def test_client_follows_redirects(self): + """Regression for #983: mikanime mirror returns 302 to the canonical + URL but httpx refuses to follow by default, so the RSS fetch fails.""" + client = await get_shared_client() + assert client.follow_redirects is True + class TestResetSharedClient: async def test_reset_closes_existing_client(self): From 90239b08432b7dff92637341c4db7cae2b76f8dc Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 19 Apr 2026 13:00:35 +0200 Subject: [PATCH 07/16] fix(parser): stop destroying titles without a [group] prefix (#1025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prefix_process called re.sub(f".{group}.", "", raw) unconditionally. When group was empty (torrents that don't start with [group]), the pattern degenerated to ".." and every pair of characters in the title was deleted, leaving a stub the downstream splitter couldn't turn into title_en/zh/jp. Guarded the substitution with `if group:`. Titles like "冰之城墙「氷の城壁」The Ramparts of Ice S01E02 1080p 日英双语-多国字幕" and "Girls Band Cry S01E05 ..." now parse correctly. Updated #764's test which had been pinned to the broken behavior. Closes #1025 --- .../src/module/parser/analyser/raw_parser.py | 6 ++- backend/src/test/test_raw_parser.py | 42 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/backend/src/module/parser/analyser/raw_parser.py b/backend/src/module/parser/analyser/raw_parser.py index df94a528..f34594b7 100644 --- a/backend/src/module/parser/analyser/raw_parser.py +++ b/backend/src/module/parser/analyser/raw_parser.py @@ -59,7 +59,11 @@ def pre_process(raw_name: str) -> str: def prefix_process(raw: str, group: str) -> str: - raw = re.sub(f".{re.escape(group)}.", "", raw) + # Guard against empty group: without this, the pattern degenerates to ".." + # and every pair of characters gets deleted, destroying titles that lack a + # [group] prefix (#1025). + if group: + raw = re.sub(f".{re.escape(group)}.", "", raw) raw_process = PREFIX_RE.sub("/", raw) arg_group = raw_process.split("/") while "" in arg_group: diff --git a/backend/src/test/test_raw_parser.py b/backend/src/test/test_raw_parser.py index 398da338..6b36fd03 100644 --- a/backend/src/test/test_raw_parser.py +++ b/backend/src/test/test_raw_parser.py @@ -56,7 +56,9 @@ def test_raw_parser(): assert info.episode == 9 assert info.season == 1 - content = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" + content = ( + "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" + ) info = raw_parser(content) assert info.group == "梦蓝字幕组" assert info.title_zh == "哆啦A梦新番" @@ -65,7 +67,9 @@ def test_raw_parser(): assert info.episode == 747 assert info.season == 1 - content = "[织梦字幕组][尼尔:机械纪元 NieR Automata Ver1.1a][02集][1080P][AVC][简日双语]" + content = ( + "[织梦字幕组][尼尔:机械纪元 NieR Automata Ver1.1a][02集][1080P][AVC][简日双语]" + ) info = raw_parser(content) assert info.group == "织梦字幕组" assert info.title_zh == "尼尔:机械纪元" @@ -160,7 +164,9 @@ def test_raw_parser(): assert info.season == 1 # Issue #990: Title starting with number — should not misparse "29" as episode - content = "[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]" + content = ( + "[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]" + ) info = raw_parser(content) assert info.group == "ANi" assert info.title_zh == "29 岁单身中坚冒险家的日常" @@ -310,8 +316,9 @@ class TestIssue764WesternFormat: assert info.resolution == "1080p" # No brackets → group detection fails assert info.group == "" - # No CJK chars → no title_zh/jp; EN detection also fails (short segments) - assert info.title_en is None + # After the #1025 fix, prefix_process no longer destroys titles without + # a [group] prefix, so the English title is now extracted correctly. + assert info.title_en == "Girls Band Cry" assert info.title_zh is None @@ -323,7 +330,9 @@ class TestIssue986AtlasFormat: "[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC]", ] - @pytest.mark.xfail(reason="Atlas bracket-delimited format not supported by TITLE_RE") + @pytest.mark.xfail( + reason="Atlas bracket-delimited format not supported by TITLE_RE" + ) def test_parse_atlas_format(self): info = raw_parser(self.TITLES[0]) assert info is not None @@ -362,3 +371,24 @@ class TestIssue805TitleWithCht: assert info.source == "Baha" assert info.sub == "CHT" + +class TestIssue1025NoGroupPrefix: + """Issue #1025: Titles without a [group] prefix must still parse. + + prefix_process was calling re.sub(f".{group}.", "", raw) even when + group was empty, which reduced the pattern to `..` and deleted every + pair of characters, leaving a stub like `1` that name_process couldn't + split into en/zh/jp. + """ + + def test_mixed_cjk_and_en_without_group(self): + content = ( + "冰之城墙「氷の城壁」The Ramparts of Ice S01E02 1080p 日英双语-多国字幕" + ) + info = raw_parser(content) + assert info is not None + assert info.episode == 2 + assert info.season == 1 + # Before the fix all three title fields were None and title_parser + # raised "Cannot extract title_raw". At least one must now be set. + assert any([info.title_en, info.title_zh, info.title_jp]) From 07522ae284beddf5ff460c568a6eb2bdef9cd11e Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 19 Apr 2026 13:01:27 +0200 Subject: [PATCH 08/16] ci(release): include pyproject.toml / uv.lock / requirements.txt (#994, #1015) The release artifact only zipped backend/src, omitting pyproject.toml and uv.lock, so local deployments couldn't install dependencies. #994 also reported the missing requirements.txt after the uv migration in 3.2.0. Install uv in the release job and generate a production-only requirements.txt via `uv export --no-dev`, then bundle all three alongside src in the app zip. Closes #994 Closes #1015 --- .github/workflows/build.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d3e9b3d..3380b355 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -314,9 +314,18 @@ jobs: echo ${{ needs.version-info.outputs.version }} echo "VERSION='${{ needs.version-info.outputs.version }}'" >> module/__version__.py + - uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Generate requirements.txt for non-uv consumers (#994) + run: | + cd backend && uv export --format requirements-txt --no-hashes --no-dev -o requirements.txt + - name: Zip app run: | - cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip src + cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip \ + src pyproject.toml uv.lock requirements.txt - name: Generate Release info id: release-info From 8169a2cc1330de9a4dde337b172208e55e0da208 Mon Sep 17 00:00:00 2001 From: Estrella Pan <33726646+EstrellaXD@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:10:30 +0200 Subject: [PATCH 09/16] fix(downloader): cap qB httpx keepalive to prevent stale-socket storms (#1028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #984 Apply httpx.Limits(keepalive_expiry=30, max_connections=10, max_keepalive_connections=5) to the qBittorrent AsyncClient — same recipe as #1018 for the RSS side. Prevents the "Server disconnected" cascade when a proxy / NAS silently reaps idle sockets between renamer cycles. --- .../module/downloader/client/qb_downloader.py | 15 +- backend/src/test/test_qb_downloader.py | 129 ++++++++++++++---- 2 files changed, 118 insertions(+), 26 deletions(-) diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index 7f888f6f..fc08bde7 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -28,9 +28,19 @@ class QbDownloader: times = 0 use_https = self.host.startswith("https://") timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0) + # Keepalive_expiry keeps idle TCP sockets short-lived so they can't + # outlive a proxy / NAS idle-reap, which would otherwise surface as + # "Server disconnected without sending a response" when the next + # renamer cycle reuses the pool (#984). max_connections caps parallel + # load on the downloader and anything fronting it. + limits = httpx.Limits( + max_keepalive_connections=5, + max_connections=10, + keepalive_expiry=30.0, + ) # Never verify certificates - self-signed certs are the norm for # home-server / NAS / Docker qBittorrent setups. - self._client = httpx.AsyncClient(timeout=timeout, verify=False) + self._client = httpx.AsyncClient(timeout=timeout, limits=limits, verify=False) while times < retry: try: resp = await self._client.post( @@ -242,7 +252,8 @@ class QbDownloader: break # Final attempt failed logger.debug( - "[Downloader] Rename API returned 200 but file unchanged: %s", old_path + "[Downloader] Rename API returned 200 but file unchanged: %s", + old_path, ) return False # new_path found or old_path not found diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index 1630b4f6..f90a370c 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -24,12 +24,16 @@ class TestQbDownloaderConstructor: def test_ssl_true_no_scheme_uses_https(self): """ssl=True with bare host prepends https://.""" - qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=True) + qb = QbDownloader( + host="192.168.1.10:8080", username="admin", password="pass", ssl=True + ) assert qb.host == "https://192.168.1.10:8080" def test_ssl_false_no_scheme_uses_http(self): """ssl=False with bare host prepends http://.""" - qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=False) + qb = QbDownloader( + host="192.168.1.10:8080", username="admin", password="pass", ssl=False + ) assert qb.host == "http://192.168.1.10:8080" def test_explicit_http_scheme_preserved_when_ssl_true(self): @@ -42,18 +46,25 @@ class TestQbDownloaderConstructor: def test_explicit_https_scheme_preserved_when_ssl_false(self): """Explicit https:// scheme is kept even if ssl=False.""" qb = QbDownloader( - host="https://192.168.1.10:8080", username="admin", password="pass", ssl=False + host="https://192.168.1.10:8080", + username="admin", + password="pass", + ssl=False, ) assert qb.host == "https://192.168.1.10:8080" def test_explicit_http_scheme_preserved_ssl_false(self): """Explicit http:// URL with ssl=False stays as http://.""" - qb = QbDownloader(host="http://nas.local:8080", username="u", password="p", ssl=False) + qb = QbDownloader( + host="http://nas.local:8080", username="u", password="p", ssl=False + ) assert qb.host == "http://nas.local:8080" def test_explicit_https_scheme_preserved_ssl_true(self): """Explicit https:// URL with ssl=True stays as https://.""" - qb = QbDownloader(host="https://nas.local:8080", username="u", password="p", ssl=True) + qb = QbDownloader( + host="https://nas.local:8080", username="u", password="p", ssl=True + ) assert qb.host == "https://nas.local:8080" def test_credentials_stored(self): @@ -67,7 +78,9 @@ class TestQbDownloaderConstructor: def test_client_initially_none(self): """_client starts as None before any auth call.""" - qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False) + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) assert qb._client is None @@ -110,7 +123,9 @@ class TestAuthClientCreation: async def test_auth_creates_client_with_verify_false_when_ssl_true(self): """verify=False is used even when ssl=True (self-signed certs are common).""" - qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=True) + qb = QbDownloader( + host="192.168.1.10:8080", username="admin", password="pass", ssl=True + ) captured: list[dict] = [] @@ -127,7 +142,9 @@ class TestAuthClientCreation: async def aclose(self): pass - with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient): + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + ): result = await qb.auth() assert result is True @@ -136,7 +153,9 @@ class TestAuthClientCreation: async def test_auth_creates_client_with_verify_false_when_ssl_false(self): """verify=False is used even when ssl=False.""" - qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=False) + qb = QbDownloader( + host="192.168.1.10:8080", username="admin", password="pass", ssl=False + ) captured: list[dict] = [] @@ -153,7 +172,9 @@ class TestAuthClientCreation: async def aclose(self): pass - with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient): + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + ): result = await qb.auth() assert result is True @@ -178,12 +199,46 @@ class TestAuthClientCreation: async def aclose(self): pass - with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient): + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + ): await qb.auth() assert len(captured_timeouts) == 1 assert captured_timeouts[0].connect == pytest.approx(5.0) + async def test_auth_sets_connection_limits_for_keepalive(self): + """Regression for #984: qB client must cap keepalive so idle TCP + sockets don't linger past proxy / NAS idle-reap timeouts, otherwise + parallel renamer calls cascade into 'Server disconnected' errors.""" + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + + captured: list[dict] = [] + + class _FakeClient: + def __init__(self, **kwargs): + captured.append(kwargs) + + async def post(self, url, data=None): + resp = MagicMock() + resp.status_code = 200 + resp.text = "Ok." + return resp + + async def aclose(self): + pass + + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + ): + await qb.auth() + + limits = captured[0].get("limits") + assert limits is not None + assert limits.keepalive_expiry is not None + assert limits.keepalive_expiry > 0 + assert limits.max_connections is not None + # --------------------------------------------------------------------------- # auth: success / failure paths @@ -195,7 +250,9 @@ class TestAuthSuccessFailure: async def test_auth_returns_true_on_ok_response(self): """Returns True when server responds 200 + 'Ok.'.""" - qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False) + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) mock_client = AsyncMock() mock_resp = MagicMock() @@ -213,7 +270,9 @@ class TestAuthSuccessFailure: async def test_auth_returns_false_on_403(self): """Returns False and stops retrying immediately on 403 Forbidden.""" - qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False) + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) mock_client = AsyncMock() mock_resp = MagicMock() @@ -233,7 +292,9 @@ class TestAuthSuccessFailure: async def test_auth_retries_up_to_limit_on_server_error(self): """Retries up to the retry limit on non-200/non-403 responses.""" - qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False) + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) mock_client = AsyncMock() mock_resp = MagicMock() @@ -271,7 +332,9 @@ class TestAuthConnectErrorLogging: ) mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + mock_client.post = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) with patch( "module.downloader.client.qb_downloader.httpx.AsyncClient", @@ -287,7 +350,9 @@ class TestAuthConnectErrorLogging: result = await qb.auth(retry=1) assert result is False - error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] + error_messages = [ + r.message for r in caplog.records if r.levelno == logging.ERROR + ] assert any("HTTPS" in msg for msg in error_messages) assert any( "disable SSL" in msg or "plain HTTP" in msg for msg in error_messages @@ -296,7 +361,9 @@ class TestAuthConnectErrorLogging: async def test_https_url_derived_from_ssl_flag_logs_https_guidance(self, caplog): """HTTPS guidance also fires when scheme comes from ssl=True (bare host).""" # Bare host + ssl=True -> self.host becomes https://... -> use_https=True in auth() - qb = QbDownloader(host="192.168.1.10:8080", username="u", password="p", ssl=True) + qb = QbDownloader( + host="192.168.1.10:8080", username="u", password="p", ssl=True + ) assert qb.host.startswith("https://") mock_client = AsyncMock() @@ -315,7 +382,9 @@ class TestAuthConnectErrorLogging: ): await qb.auth(retry=1) - error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] + error_messages = [ + r.message for r in caplog.records if r.levelno == logging.ERROR + ] assert any("HTTPS" in msg for msg in error_messages) async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog): @@ -325,7 +394,9 @@ class TestAuthConnectErrorLogging: ) mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + mock_client.post = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) with patch( "module.downloader.client.qb_downloader.httpx.AsyncClient", @@ -341,14 +412,20 @@ class TestAuthConnectErrorLogging: result = await qb.auth(retry=1) assert result is False - error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] - assert any("Cannot connect to qBittorrent Server" in msg for msg in error_messages) + error_messages = [ + r.message for r in caplog.records if r.levelno == logging.ERROR + ] + assert any( + "Cannot connect to qBittorrent Server" in msg for msg in error_messages + ) # SSL-disable hint must NOT appear for plain HTTP connections assert not any("disable SSL" in msg for msg in error_messages) async def test_http_url_derived_from_ssl_flag_false_no_ssl_hint(self, caplog): """SSL-disable hint is absent when scheme comes from ssl=False (bare host).""" - qb = QbDownloader(host="192.168.1.10:8080", username="u", password="p", ssl=False) + qb = QbDownloader( + host="192.168.1.10:8080", username="u", password="p", ssl=False + ) assert qb.host.startswith("http://") mock_client = AsyncMock() @@ -420,7 +497,9 @@ class TestAuthConnectErrorLogging: ): await qb.auth(retry=1) - error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] + error_messages = [ + r.message for r in caplog.records if r.levelno == logging.ERROR + ] assert not any("disable SSL" in msg for msg in error_messages) assert not any("HTTPS" in msg for msg in error_messages) @@ -445,5 +524,7 @@ class TestUrlHelper: def test_url_with_explicit_http_scheme_overriding_ssl_true(self): """_url works correctly when explicit http:// scheme overrides ssl=True.""" - qb = QbDownloader(host="http://nas.local:8080", username="u", password="p", ssl=True) + qb = QbDownloader( + host="http://nas.local:8080", username="u", password="p", ssl=True + ) assert qb._url("torrents/info") == "http://nas.local:8080/api/v2/torrents/info" From 930dd01220798876de19436580a4d0f2d7c8c539 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:39:10 +0200 Subject: [PATCH 10/16] fix(downloader): accept qBittorrent 5.2 login response and close leaked client (#1044, #1034, #1043) qBittorrent >= 5.2 answers a successful login with HTTP 204 and an empty body instead of 200 + 'Ok.', so every login was treated as bad credentials. Accept both variants and keep 200 + 'Fails.' as a failure. Also close the httpx.AsyncClient when auth() fails: DownloadClient.__aenter__ raises before __aexit__ can run, so each failed connect leaked a client and its connection pool (qb and aria2). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- .../downloader/client/aria2_downloader.py | 4 + .../module/downloader/client/qb_downloader.py | 25 +++- backend/src/test/test_qb_downloader.py | 113 ++++++++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index dfa78a5f..d68e52b8 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -49,6 +49,10 @@ class Aria2Downloader: ) await asyncio.sleep(5) times += 1 + # Failed auth: close the client here or its connection pool leaks, + # because DownloadClient.__aenter__ raises before __aexit__ can run. + await self._client.aclose() + self._client = None return False async def logout(self): diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index fc08bde7..bbf229dc 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -47,7 +47,9 @@ class QbDownloader: self._url("auth/login"), data={"username": self.username, "password": self.password}, ) - if resp.status_code == 200 and resp.text == "Ok.": + # qBittorrent < 5.2 answers 200 + "Ok." / "Fails."; + # qBittorrent >= 5.2 answers 204 with an empty body on success (#1044). + if resp.status_code in (200, 204) and "fails" not in resp.text.lower(): return True elif resp.status_code == 403: logger.error("Login refused by qBittorrent Server") @@ -80,6 +82,10 @@ class QbDownloader: else: logger.error(f"Unknown error: {e}") break + # Failed auth: DownloadClient.__aenter__ raises before __aexit__ can run, + # so the client must be closed here or its connection pool leaks. + await self._client.aclose() + self._client = None return False async def logout(self): @@ -201,11 +207,22 @@ class QbDownloader: resp = await self._client.get(self._url("torrents/info"), params={"tag": tag}) return resp.json() - async def torrents_delete(self, hash, delete_files: bool = True): - await self._client.post( + async def torrents_delete(self, hash, delete_files: bool = True) -> bool: + # qBittorrent expects one pipe-joined "hashes" field; a Python list would + # be form-encoded as repeated fields and silently ignored (#1046). + hashes = "|".join(hash) if isinstance(hash, (list, tuple)) else hash + resp = await self._client.post( self._url("torrents/delete"), - data={"hashes": hash, "deleteFiles": str(delete_files).lower()}, + data={"hashes": hashes, "deleteFiles": str(delete_files).lower()}, ) + if resp.status_code != 200: + logger.error( + "[Downloader] Failed to delete torrents %s: HTTP %s", + hashes, + resp.status_code, + ) + return False + return True async def torrents_pause(self, hashes: str): await self._client.post( diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index f90a370c..81ee209f 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -528,3 +528,116 @@ class TestUrlHelper: host="http://nas.local:8080", username="u", password="p", ssl=True ) assert qb._url("torrents/info") == "http://nas.local:8080/api/v2/torrents/info" + + +# --------------------------------------------------------------------------- +# qBittorrent 5.2 login compatibility (#1044, #1034, #1043) +# --------------------------------------------------------------------------- + + +class TestAuthQb52Compat: + """qBittorrent >= 5.2 returns HTTP 204 with an empty body on success.""" + + async def test_auth_returns_true_on_204_empty_body(self): + """Returns True when server responds 204 + empty body (qB >= 5.2).""" + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) + + mock_client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 204 + mock_resp.text = "" + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ): + result = await qb.auth() + + assert result is True + + async def test_auth_returns_false_on_200_fails_body(self): + """Returns False on 200 + 'Fails.' (bad credentials, qB < 5.2).""" + qb = QbDownloader( + host="localhost:8080", username="admin", password="wrong", ssl=False + ) + + mock_client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = "Fails." + mock_client.post = AsyncMock(return_value=mock_resp) + + with ( + patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ), + patch( + "module.downloader.client.qb_downloader.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await qb.auth(retry=1) + + assert result is False + + async def test_auth_closes_client_on_failure(self): + """A failed auth must aclose() the httpx client (leak fix).""" + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) + + mock_client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ): + result = await qb.auth() + + assert result is False + mock_client.aclose.assert_awaited_once() + assert qb._client is None + + +# --------------------------------------------------------------------------- +# torrents_delete (#1046) +# --------------------------------------------------------------------------- + + +class TestTorrentsDelete: + """torrents_delete must pipe-join hash lists and report failures.""" + + async def test_delete_joins_hash_list_with_pipe(self): + """A list of hashes is sent as a single pipe-joined string.""" + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + qb._client.post = AsyncMock(return_value=mock_resp) + + result = await qb.torrents_delete(["aaa", "bbb"], delete_files=True) + + assert result is True + sent = qb._client.post.call_args.kwargs["data"] + assert sent["hashes"] == "aaa|bbb" + assert sent["deleteFiles"] == "true" + + async def test_delete_returns_false_on_error_status(self): + """Non-200 response returns False instead of silently succeeding.""" + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 403 + qb._client.post = AsyncMock(return_value=mock_resp) + + result = await qb.torrents_delete("aaa", delete_files=True) + + assert result is False From e6aac59954b88e833e9bbd0116b58666b44bf194 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:39:10 +0200 Subject: [PATCH 11/16] fix(downloader): make torrent deletion actually delete and report failures (#1046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit torrents_delete received a Python list, which httpx form-encodes as repeated 'hashes' fields that qBittorrent ignores — pipe-join it as the API expects. Check the response status instead of logging success unconditionally, and propagate the failure up through delete_torrent to the API ResponseModel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- .../src/module/downloader/client/mock_downloader.py | 10 ++++++++-- backend/src/module/downloader/download_client.py | 10 +++++++--- backend/src/module/manager/torrent.py | 8 +++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/src/module/downloader/client/mock_downloader.py b/backend/src/module/downloader/client/mock_downloader.py index e900242d..49428031 100644 --- a/backend/src/module/downloader/client/mock_downloader.py +++ b/backend/src/module/downloader/client/mock_downloader.py @@ -110,13 +110,19 @@ class MockDownloader: ) return True - async def torrents_delete(self, hash: str, delete_files: bool = True): - hashes = hash.split("|") if "|" in hash else [hash] + async def torrents_delete( + self, hash: str | list, delete_files: bool = True + ) -> bool: + if isinstance(hash, (list, tuple)): + hashes = list(hash) + else: + hashes = hash.split("|") if "|" in hash else [hash] for h in hashes: self._torrents.pop(h, None) logger.debug( "[MockDownloader] torrents_delete(%s, delete_files=%s)", hash, delete_files ) + return True async def torrents_pause(self, hashes: str): for h in hashes.split("|"): diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index a587eec9..8e06f960 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -144,9 +144,13 @@ class DownloadClient(TorrentPath): logger.debug("[Downloader] Rename failed: %s >> %s", old_path, new_path) return result - async def delete_torrent(self, hashes, delete_files: bool = True): - await self.client.torrents_delete(hashes, delete_files=delete_files) - logger.info("[Downloader] Remove torrents.") + async def delete_torrent(self, hashes, delete_files: bool = True) -> bool: + ok = await self.client.torrents_delete(hashes, delete_files=delete_files) + if ok: + logger.info("[Downloader] Remove torrents.") + else: + logger.error("[Downloader] Failed to remove torrents.") + return ok async def pause_torrent(self, hashes: str): await self.client.torrents_pause(hashes) diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index 1bd28f94..34f2e971 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -25,7 +25,13 @@ class TorrentManager(Database): async def delete_torrents(self, data: Bangumi, client: DownloadClient): hash_list = await self.__match_torrents_list(data) if hash_list: - await client.delete_torrent(hash_list) + if not await client.delete_torrent(hash_list): + return ResponseModel( + status_code=500, + status=False, + msg_en=f"Failed to delete torrents for {data.official_title}", + msg_zh=f"删除 {data.official_title} 种子失败", + ) logger.info(f"Delete rule and torrents for {data.official_title}") return ResponseModel( status_code=200, From 487bdfec545e805ae416e6ddf28651bd274d6a73 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:39:10 +0200 Subject: [PATCH 12/16] fix(api): harden pre-auth setup endpoints (#1041, #1044) /setup/test-downloader now validates the URL scheme (http/https only) like test-rss already did, and accepts qBittorrent 5.2's 204 login response. Raw exception and response detail is no longer echoed back from the pre-authentication setup endpoints; it goes to the server log only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/module/api/setup.py | 41 ++++++++++++------ backend/src/test/test_setup.py | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py index cac73d27..92c879b7 100644 --- a/backend/src/module/api/setup.py +++ b/backend/src/module/api/setup.py @@ -31,14 +31,19 @@ def _require_setup_needed(): raise HTTPException(status_code=403, detail="Setup already completed.") -def _validate_url(url: str) -> None: - """Reject non-HTTP schemes and private/reserved/loopback IPs.""" +def _validate_scheme(url: str) -> None: + """Reject non-HTTP schemes and URLs without a hostname.""" parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Only http/https URLs are allowed.") - hostname = parsed.hostname - if not hostname: + if not parsed.hostname: raise HTTPException(status_code=400, detail="Invalid URL: no hostname.") + + +def _validate_url(url: str) -> None: + """Reject non-HTTP schemes and private/reserved/loopback IPs.""" + _validate_scheme(url) + hostname = urlparse(url).hostname try: addrs = socket.getaddrinfo(hostname, None) except socket.gaierror: @@ -132,6 +137,9 @@ async def test_downloader(req: TestDownloaderRequest): scheme = "https" if req.ssl else "http" host = req.host if "://" in req.host else f"{scheme}://{req.host}" + # Private/loopback IPs stay allowed (a LAN NAS is the normal case), but + # only http/https schemes may be probed from this pre-auth endpoint (#1041). + _validate_scheme(host) try: async with httpx.AsyncClient(timeout=5.0) as client: @@ -153,7 +161,12 @@ async def test_downloader(req: TestDownloaderRequest): login_url, data={"username": req.username, "password": req.password}, ) - if login_resp.status_code == 200 and "ok" in login_resp.text.lower(): + # qBittorrent < 5.2 answers 200 + "Ok."; >= 5.2 answers 204 with + # an empty body on success (#1044). + if ( + login_resp.status_code in (200, 204) + and "fails" not in login_resp.text.lower() + ): return TestResultResponse( success=True, message_en="Connection successful.", @@ -184,11 +197,13 @@ async def test_downloader(req: TestDownloaderRequest): message_zh="无法连接到主机。", ) except Exception as e: + # Log the detail server-side only — this endpoint is reachable before + # authentication, so raw errors must not be echoed back (#1041). logger.error(f"[Setup] Downloader test failed: {e}") return TestResultResponse( success=False, - message_en=f"Connection failed: {e}", - message_zh=f"连接失败:{e}", + message_en="Connection failed.", + message_zh="连接失败。", ) @@ -221,8 +236,8 @@ async def test_rss(req: TestRSSRequest): logger.error(f"[Setup] RSS test failed: {e}") return TestResultResponse( success=False, - message_en=f"Failed to fetch RSS feed: {e}", - message_zh=f"获取 RSS 源失败:{e}", + message_en="Failed to fetch RSS feed.", + message_zh="获取 RSS 源失败。", ) @@ -266,8 +281,8 @@ async def test_notification(req: TestNotificationRequest): logger.error(f"[Setup] Notification test failed: {e}") return TestResultResponse( success=False, - message_en=f"Notification test failed: {e}", - message_zh=f"通知测试失败:{e}", + message_en="Notification test failed.", + message_zh="通知测试失败。", ) @@ -338,6 +353,6 @@ async def complete_setup(req: SetupCompleteRequest): return ResponseModel( status=False, status_code=500, - msg_en=f"Setup failed: {e}", - msg_zh=f"设置失败:{e}", + msg_en="Setup failed. Check the server log for details.", + msg_zh="设置失败,请查看服务器日志。", ) diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py index 20e29e11..f386e07b 100644 --- a/backend/src/test/test_setup.py +++ b/backend/src/test/test_setup.py @@ -297,3 +297,79 @@ class TestSentinelPath: def test_sentinel_path_is_in_config_dir(self): assert str(SENTINEL_PATH) == "config/.setup_complete" assert SENTINEL_PATH.parent == Path("config") + + +class TestTestDownloaderHardening: + """qB 5.2 login compat (#1044) and SSRF hardening (#1041).""" + + @staticmethod + def _mock_client(get_resp=None, login_resp=None, get_exc=None): + mock_instance = AsyncMock() + if get_exc is not None: + mock_instance.get.side_effect = get_exc + else: + mock_instance.get.return_value = get_resp + mock_instance.post.return_value = login_resp + cls_patch = patch("module.api.setup.httpx.AsyncClient") + mock_cls = cls_patch.start() + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_instance) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + return cls_patch + + def _post(self, client, host="192.168.1.100:8080"): + return client.post( + "/api/v1/setup/test-downloader", + json={ + "type": "qbittorrent", + "host": host, + "username": "admin", + "password": "admin", + "ssl": False, + }, + ) + + def test_login_accepts_204_empty_body(self, client, mock_first_run): + """qBittorrent >= 5.2 returns 204 + empty body on successful login.""" + from unittest.mock import MagicMock + + get_resp = MagicMock(text="qBittorrent WebUI") + login_resp = MagicMock(status_code=204, text="") + cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_login_rejects_200_fails_body(self, client, mock_first_run): + """200 + 'Fails.' (bad credentials) is still a failure.""" + from unittest.mock import MagicMock + + get_resp = MagicMock(text="qBittorrent WebUI") + login_resp = MagicMock(status_code=200, text="Fails.") + cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + assert response.json()["success"] is False + + def test_non_http_scheme_rejected(self, client, mock_first_run): + """Non-http(s) schemes must be rejected before any request is made.""" + response = self._post(client, host="ftp://internal-server:21") + assert response.status_code == 400 + + def test_exception_detail_not_echoed(self, client, mock_first_run): + """Raw exception text must not leak into the API response (#1041).""" + cls_patch = self._mock_client(get_exc=Exception("secret-detail-xyz")) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "secret-detail-xyz" not in data["message_en"] + assert "secret-detail-xyz" not in data["message_zh"] From 6c39227aedba96dc413124843bc4bc3aaa731d75 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:39:10 +0200 Subject: [PATCH 13/16] fix(rss): throttle concurrent fetches per host to avoid HTTP 429 (#1026) refresh_rss fired every feed at once, so sites with many subscribed feeds (e.g. nyaa.si) rate-limited the whole batch. Feeds are now grouped by host: serial with a 2s delay within a host, still parallel across hosts, with the existing global concurrency cap kept. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/module/rss/engine.py | 35 +++++++++++--- backend/src/test/test_rss_engine_new.py | 63 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index e126b5ef..9f2b6d13 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -1,8 +1,10 @@ import asyncio import logging import re +from collections import defaultdict from datetime import datetime, timezone from typing import Optional +from urllib.parse import urlparse from module.database import Database, engine from module.downloader import DownloadClient @@ -11,6 +13,10 @@ from module.network import RequestContent logger = logging.getLogger(__name__) +# Delay between consecutive requests to the same host. Firing all feeds of one +# site at once gets the whole batch rate-limited with HTTP 429 (#1026). +RSS_PER_HOST_DELAY = 2.0 + class RSSEngine(Database): def __init__(self, _engine=engine): @@ -149,18 +155,35 @@ class RSSEngine(Database): else: rss_item = self.rss.search_id(rss_id) rss_items = [rss_item] if rss_item else [] - # From RSS Items, fetch all torrents with concurrency limit + # From RSS Items, fetch all torrents: parallel across hosts, serial + # (with a delay) within one host so the site never sees a burst (#1026). logger.debug("[Engine] Get %s RSS items", len(rss_items)) semaphore = asyncio.Semaphore(5) - async def _limited_pull(item): - async with semaphore: - return await self._pull_rss_with_status(item) + async def _pull_host_group(items: list[RSSItem]): + group_results = [] + for i, item in enumerate(items): + if i and RSS_PER_HOST_DELAY: + await asyncio.sleep(RSS_PER_HOST_DELAY) + async with semaphore: + group_results.append(await self._pull_rss_with_status(item)) + return group_results - results = await asyncio.gather(*[_limited_pull(item) for item in rss_items]) + host_groups: dict[str, list[RSSItem]] = defaultdict(list) + for item in rss_items: + host_groups[urlparse(item.url).netloc].append(item) + group_lists = list(host_groups.values()) + grouped_results = await asyncio.gather( + *[_pull_host_group(items) for items in group_lists] + ) + item_results = [ + (item, result) + for items, results in zip(group_lists, grouped_results) + for item, result in zip(items, results) + ] now = datetime.now(timezone.utc).isoformat() # Process results sequentially (DB operations) - for rss_item, (new_torrents, error) in zip(rss_items, results): + for rss_item, (new_torrents, error) in item_results: # Update connection status rss_item.connection_status = "error" if error else "healthy" rss_item.last_checked_at = now diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 14ea73c2..3512c756 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -369,3 +369,66 @@ class TestRefreshRssConcurrency: await rss_engine.refresh_rss(client) assert max_active <= 5 + + +# --------------------------------------------------------------------------- +# refresh_rss per-host throttling (#1026) +# --------------------------------------------------------------------------- + + +class TestRefreshRssPerHostThrottle: + async def test_same_host_requests_never_overlap(self, rss_engine): + """Feeds on the same host are fetched serially; other hosts stay parallel.""" + for i in range(3): + rss_engine.rss.add( + make_rss_item(url=f"https://nyaa.example/rss/{i}", name=f"nyaa{i}") + ) + rss_engine.rss.add(make_rss_item(url="https://mikan.example/rss", name="mikan")) + + from urllib.parse import urlparse + + active: dict[str, int] = {} + max_active: dict[str, int] = {} + + async def fake_pull(item): + host = urlparse(item.url).netloc + active[host] = active.get(host, 0) + 1 + max_active[host] = max(max_active.get(host, 0), active[host]) + # Give concurrently-scheduled pulls a chance to overlap. + await asyncio.sleep(0.01) + active[host] -= 1 + return [], None + + client = AsyncMock() + with ( + patch.object( + RSSEngine, + "_pull_rss_with_status", + new_callable=lambda: AsyncMock(side_effect=fake_pull), + ), + patch("module.rss.engine.RSS_PER_HOST_DELAY", 0), + ): + await rss_engine.refresh_rss(client) + + assert max_active["nyaa.example"] == 1 + assert max_active["mikan.example"] == 1 + + async def test_all_feeds_still_processed(self, rss_engine): + """Grouping by host must not drop any feed's status update.""" + rss_engine.rss.add(make_rss_item(url="https://a.example/rss", name="a")) + rss_engine.rss.add(make_rss_item(url="https://b.example/rss", name="b")) + + client = AsyncMock() + with ( + patch.object( + RSSEngine, + "_pull_rss_with_status", + new_callable=lambda: AsyncMock(return_value=([], None)), + ), + patch("module.rss.engine.RSS_PER_HOST_DELAY", 0), + ): + await rss_engine.refresh_rss(client) + + for rss_id in (1, 2): + item = rss_engine.rss.search_id(rss_id) + assert item.connection_status == "healthy" From fa24ec4e2af2bcfa1751ca0e6ef156cdbb356085 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:39:10 +0200 Subject: [PATCH 14/16] fix(core): stop blocking the event loop in OpenAI parsing and static routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAIParser now uses AsyncOpenAI — the previous sync SDK call ran directly on the event loop (submitting to a ThreadPoolExecutor and immediately blocking on .result() serialized it anyway). TitleParser.raw_parser and its callers become async accordingly. The SPA catch-all route listed dist/ on every request; snapshot it once at startup. The module-level bangumi TTL cache is now lock-guarded — it is written from asyncio.to_thread workers in notification paths while the event loop reads it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/main.py | 7 +++-- backend/src/module/database/bangumi.py | 29 ++++++++++++-------- backend/src/module/parser/analyser/openai.py | 16 ++++------- backend/src/module/parser/title_parser.py | 4 +-- backend/src/module/rss/analyser.py | 4 +-- backend/src/test/test_issue_bugs.py | 8 +++--- backend/src/test/test_openai.py | 4 +-- backend/src/test/test_title_parser.py | 8 +++--- 8 files changed, 43 insertions(+), 37 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index cd2b6bc1..25d33ed2 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -83,10 +83,13 @@ if VERSION != "DEV_VERSION": # app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons") templates = Jinja2Templates(directory="dist") + # dist/ is immutable inside the container — snapshot once instead of + # hitting the filesystem on every request. + _DIST_FILES = frozenset(os.listdir("dist")) + @app.get("/{path:path}") def html(request: Request, path: str): - files = os.listdir("dist") - if path in files: + if path in _DIST_FILES: return FileResponse(f"dist/{path}") else: context = {"request": request} diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 07649122..92f550d0 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -1,6 +1,7 @@ import json import logging import re +import threading import time from typing import Optional @@ -64,16 +65,20 @@ def _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None: bangumi.title_aliases = json.dumps(unique_aliases, ensure_ascii=False) -# Module-level TTL cache for search_all results +# Module-level TTL cache for search_all results. +# Guarded by a lock: notification paths read/write it from asyncio.to_thread +# worker threads while the event loop uses it concurrently. _bangumi_cache: list[Bangumi] | None = None _bangumi_cache_time: float = 0 _BANGUMI_CACHE_TTL: float = 300.0 # 5 minutes - extended from 60s to reduce DB queries +_bangumi_cache_lock = threading.Lock() def _invalidate_bangumi_cache(): global _bangumi_cache, _bangumi_cache_time - _bangumi_cache = None - _bangumi_cache_time = 0 + with _bangumi_cache_lock: + _bangumi_cache = None + _bangumi_cache_time = 0 class BangumiDatabase: @@ -365,11 +370,12 @@ class BangumiDatabase: def search_all(self) -> list[Bangumi]: global _bangumi_cache, _bangumi_cache_time now = time.time() - if ( - _bangumi_cache is not None - and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL - ): - return _bangumi_cache + with _bangumi_cache_lock: + if ( + _bangumi_cache is not None + and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL + ): + return _bangumi_cache statement = select(Bangumi) result = self.session.execute(statement) bangumis = list(result.scalars().all()) @@ -377,9 +383,10 @@ class BangumiDatabase: # cached objects are accessed from a different session/request context for b in bangumis: self.session.expunge(b) - _bangumi_cache = bangumis - _bangumi_cache_time = now - return _bangumi_cache + with _bangumi_cache_lock: + _bangumi_cache = bangumis + _bangumi_cache_time = now + return bangumis def search_id(self, _id: int) -> Optional[Bangumi]: statement = select(Bangumi).where(Bangumi.id == _id) diff --git a/backend/src/module/parser/analyser/openai.py b/backend/src/module/parser/analyser/openai.py index 5aa7a981..eb31b595 100644 --- a/backend/src/module/parser/analyser/openai.py +++ b/backend/src/module/parser/analyser/openai.py @@ -1,9 +1,8 @@ import json import logging -from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional -from openai import AzureOpenAI, OpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI from pydantic import BaseModel from module.models import Bangumi @@ -62,19 +61,19 @@ class OpenAIParser: if not api_key: raise ValueError("API key is required.") if api_type == "azure": - self.client = AzureOpenAI( + self.client = AsyncAzureOpenAI( api_key=api_key, base_url=api_base, azure_deployment=kwargs.get("deployment_id", ""), api_version=kwargs.get("api_version", "2023-05-15"), ) else: - self.client = OpenAI(api_key=api_key, base_url=api_base) + self.client = AsyncOpenAI(api_key=api_key, base_url=api_base) self.model = model self.openai_kwargs = kwargs - def parse( + async def parse( self, text: str, prompt: str | None = None, asdict: bool = True ) -> dict | str: """parse text with openai @@ -96,11 +95,8 @@ class OpenAIParser: params = self._prepare_params(text, prompt) - with ThreadPoolExecutor(max_workers=1) as worker: - future = worker.submit(self.client.beta.chat.completions.parse, **params) - resp = future.result() - - result = resp.choices[0].message.parsed + resp = await self.client.beta.chat.completions.parse(**params) + result = resp.choices[0].message.parsed if asdict: if hasattr(result, "model_dump"): diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index b56b23fb..e19fc86e 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -57,14 +57,14 @@ class TitleParser: logger.warning("Please change bangumi info manually.") @staticmethod - def raw_parser(raw: str) -> Bangumi | None: + async def raw_parser(raw: str) -> Bangumi | None: language = settings.rss_parser.language try: # use OpenAI ChatGPT to parse raw title and get structured data if settings.experimental_openai.enable: kwargs = settings.experimental_openai.dict(exclude={"enable"}) gpt = OpenAIParser(**kwargs) - episode_dict = gpt.parse(raw, asdict=True) + episode_dict = await gpt.parse(raw, asdict=True) episode = Episode(**episode_dict) else: episode = raw_parser(raw) diff --git a/backend/src/module/rss/analyser.py b/backend/src/module/rss/analyser.py index cbd7a9af..d4279b26 100644 --- a/backend/src/module/rss/analyser.py +++ b/backend/src/module/rss/analyser.py @@ -49,7 +49,7 @@ class RSSAnalyser(TitleParser): new_data = [] seen_titles: set[str] = set() for torrent in torrents: - bangumi = self.raw_parser(raw=torrent.name) + bangumi = await self.raw_parser(raw=torrent.name) if bangumi and bangumi.title_raw not in seen_titles: await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) if not full_parse: @@ -60,7 +60,7 @@ class RSSAnalyser(TitleParser): return new_data async def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Bangumi: - bangumi = self.raw_parser(raw=torrent.name) + bangumi = await self.raw_parser(raw=torrent.name) if bangumi: await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) bangumi.rss_link = rss.url diff --git a/backend/src/test/test_issue_bugs.py b/backend/src/test/test_issue_bugs.py index 80f3114a..71d6a40d 100644 --- a/backend/src/test/test_issue_bugs.py +++ b/backend/src/test/test_issue_bugs.py @@ -340,11 +340,11 @@ class TestIssue990NumberPrefixTitle: assert result.resolution == "1080P" assert result.group == "ANi" - def test_title_parser_returns_bangumi_for_number_prefix_title(self): + async def test_title_parser_returns_bangumi_for_number_prefix_title(self): """TitleParser.raw_parser returns a valid Bangumi for number-prefixed titles.""" from module.parser.title_parser import TitleParser - result = TitleParser.raw_parser(self.PROBLEM_TITLE) + result = await TitleParser.raw_parser(self.PROBLEM_TITLE) assert result is not None assert result.official_title == "29 岁单身中坚冒险家的日常" assert result.title_raw == "29 岁单身中坚冒险家的日常" @@ -504,11 +504,11 @@ class TestIssue992NonEpisodicAttributeError: ] @pytest.mark.parametrize("title", NON_EPISODIC_TITLES) - def test_title_parser_returns_none_for_non_episodic(self, title): + async def test_title_parser_returns_none_for_non_episodic(self, title): """TitleParser.raw_parser should return None instead of crashing.""" from module.parser.title_parser import TitleParser - result = TitleParser.raw_parser(title) + result = await TitleParser.raw_parser(title) assert result is None def test_raw_parser_returns_none_for_unparseable(self): diff --git a/backend/src/test/test_openai.py b/backend/src/test/test_openai.py index db25cf0c..4d2ee08e 100644 --- a/backend/src/test/test_openai.py +++ b/backend/src/test/test_openai.py @@ -51,7 +51,7 @@ class TestOpenAIParser: params = azure_parser._prepare_params(text, DEFAULT_PROMPT) assert expected == params - def test_parse(self): + async def test_parse(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" expected = { "group": "梦蓝字幕组", @@ -69,5 +69,5 @@ class TestOpenAIParser: with mock.patch("module.parser.analyser.OpenAIParser.parse") as mocker: mocker.return_value = json.dumps(expected) - result = self.parser.parse(text=text, asdict=False) + result = await self.parser.parse(text=text, asdict=False) assert json.loads(result) == expected diff --git a/backend/src/test/test_title_parser.py b/backend/src/test/test_title_parser.py index b23ab4a2..012ac25f 100644 --- a/backend/src/test/test_title_parser.py +++ b/backend/src/test/test_title_parser.py @@ -4,9 +4,9 @@ from module.parser.title_parser import TitleParser class TestTitleParser: - def test_parse_without_openai(self): + async def test_parse_without_openai(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" - result = TitleParser.raw_parser(text) + result = await TitleParser.raw_parser(text) assert result.group_name == "梦蓝字幕组" assert result.title_raw == "New Doraemon" assert result.dpi == "1080P" @@ -17,9 +17,9 @@ class TestTitleParser: not settings.experimental_openai.enable, reason="OpenAI is not enabled in settings", ) - def test_parse_with_openai(self): + async def test_parse_with_openai(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" - result = TitleParser.raw_parser(text) + result = await TitleParser.raw_parser(text) assert result.group_name == "梦蓝字幕组" assert result.title_raw == "New Doraemon" assert result.dpi == "1080P" From fe7298689a568e0ad8b7fec21355012648b18528 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 11:57:35 +0200 Subject: [PATCH 15/16] fix(review): tighten qB login check, propagate delete failures end-to-end, close cache race Post-review fixes for the batch-3.2.8 branch: - auth() and the setup wizard again require a positive success marker on 200 responses ('Ok.' body) so a proxy or non-qB service answering 200 + HTML is not mistaken for a login; 204 stays the qB >= 5.2 success. - delete_rule no longer wraps a failed torrent deletion into a 200 reply, and the WebUI torrents-delete endpoint reports the failure instead of answering 'Torrents deleted' unconditionally. - The close-on-failed-auth teardown now lives once in DownloadClient.__aenter__ (client.logout() before raising) instead of being copy-pasted into each concrete client. - search_all() records a cache generation before querying and skips the cache write if an invalidation landed in between, so a stale snapshot can no longer overwrite a newer invalidation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/module/api/downloader.py | 10 ++++++-- backend/src/module/api/setup.py | 9 +++---- backend/src/module/database/bangumi.py | 12 +++++++--- .../downloader/client/aria2_downloader.py | 4 ---- .../module/downloader/client/qb_downloader.py | 13 +++++----- .../src/module/downloader/download_client.py | 4 ++++ backend/src/module/manager/torrent.py | 8 +++++++ backend/src/test/conftest.py | 2 +- backend/src/test/test_api_downloader.py | 23 +++++++++++++++++- backend/src/test/test_download_client.py | 13 ++++++++++ backend/src/test/test_qb_downloader.py | 24 +++++++++++-------- backend/src/test/test_setup.py | 16 +++++++++++++ 12 files changed, 107 insertions(+), 31 deletions(-) diff --git a/backend/src/module/api/downloader.py b/backend/src/module/api/downloader.py index 88712355..ac7a90dd 100644 --- a/backend/src/module/api/downloader.py +++ b/backend/src/module/api/downloader.py @@ -53,8 +53,14 @@ async def resume_torrents(req: TorrentHashesRequest): async def delete_torrents(req: TorrentDeleteRequest): hashes = "|".join(req.hashes) async with DownloadClient() as client: - await client.delete_torrent(hashes, delete_files=req.delete_files) - return {"msg_en": "Torrents deleted", "msg_zh": "种子已删除"} + ok = await client.delete_torrent(hashes, delete_files=req.delete_files) + if not ok: + return { + "status": False, + "msg_en": "Failed to delete torrents", + "msg_zh": "删除种子失败", + } + return {"status": True, "msg_en": "Torrents deleted", "msg_zh": "种子已删除"} @router.post("/torrents/tag", dependencies=[Depends(get_current_user)]) diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py index 92c879b7..fec53455 100644 --- a/backend/src/module/api/setup.py +++ b/backend/src/module/api/setup.py @@ -162,10 +162,11 @@ async def test_downloader(req: TestDownloaderRequest): data={"username": req.username, "password": req.password}, ) # qBittorrent < 5.2 answers 200 + "Ok."; >= 5.2 answers 204 with - # an empty body on success (#1044). - if ( - login_resp.status_code in (200, 204) - and "fails" not in login_resp.text.lower() + # an empty body on success (#1044). Keep the positive body check + # for 200 so a proxy answering 200 + HTML is not reported as a + # working login. + if login_resp.status_code == 204 or ( + login_resp.status_code == 200 and "ok" in login_resp.text.lower() ): return TestResultResponse( success=True, diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 92f550d0..30998340 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -72,13 +72,17 @@ _bangumi_cache: list[Bangumi] | None = None _bangumi_cache_time: float = 0 _BANGUMI_CACHE_TTL: float = 300.0 # 5 minutes - extended from 60s to reduce DB queries _bangumi_cache_lock = threading.Lock() +# Bumped on every invalidation so a search_all() that was already past the +# cache check cannot overwrite a newer invalidation with its stale snapshot. +_bangumi_cache_gen = 0 def _invalidate_bangumi_cache(): - global _bangumi_cache, _bangumi_cache_time + global _bangumi_cache, _bangumi_cache_time, _bangumi_cache_gen with _bangumi_cache_lock: _bangumi_cache = None _bangumi_cache_time = 0 + _bangumi_cache_gen += 1 class BangumiDatabase: @@ -376,6 +380,7 @@ class BangumiDatabase: and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL ): return _bangumi_cache + gen_at_query = _bangumi_cache_gen statement = select(Bangumi) result = self.session.execute(statement) bangumis = list(result.scalars().all()) @@ -384,8 +389,9 @@ class BangumiDatabase: for b in bangumis: self.session.expunge(b) with _bangumi_cache_lock: - _bangumi_cache = bangumis - _bangumi_cache_time = now + if _bangumi_cache_gen == gen_at_query: + _bangumi_cache = bangumis + _bangumi_cache_time = now return bangumis def search_id(self, _id: int) -> Optional[Bangumi]: diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index d68e52b8..dfa78a5f 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -49,10 +49,6 @@ class Aria2Downloader: ) await asyncio.sleep(5) times += 1 - # Failed auth: close the client here or its connection pool leaks, - # because DownloadClient.__aenter__ raises before __aexit__ can run. - await self._client.aclose() - self._client = None return False async def logout(self): diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index bbf229dc..cff3c6ee 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -48,8 +48,13 @@ class QbDownloader: data={"username": self.username, "password": self.password}, ) # qBittorrent < 5.2 answers 200 + "Ok." / "Fails."; - # qBittorrent >= 5.2 answers 204 with an empty body on success (#1044). - if resp.status_code in (200, 204) and "fails" not in resp.text.lower(): + # qBittorrent >= 5.2 answers 204 with an empty body on success + # (#1044). Keep the positive body check for 200 so a proxy or + # non-qB service answering 200 + HTML is not mistaken for a + # successful login. + if ( + resp.status_code == 200 and resp.text.startswith("Ok") + ) or resp.status_code == 204: return True elif resp.status_code == 403: logger.error("Login refused by qBittorrent Server") @@ -82,10 +87,6 @@ class QbDownloader: else: logger.error(f"Unknown error: {e}") break - # Failed auth: DownloadClient.__aenter__ raises before __aexit__ can run, - # so the client must be closed here or its connection pool leaks. - await self._client.aclose() - self._client = None return False async def logout(self): diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 8e06f960..7278ed95 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -52,6 +52,10 @@ class DownloadClient(TorrentPath): if not self.authed: await self.auth() if not self.authed: + # __aexit__ never runs when we raise here, so close the + # concrete client's connection pool now or it leaks on every + # failed connect (#1043). + await self.client.logout() raise ConnectionError("Download client authentication failed") else: logger.error("[Downloader] Already authed.") diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index 34f2e971..219ccd1f 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -58,6 +58,14 @@ class TorrentManager(Database): torrent_message = None if file: torrent_message = await self.delete_torrents(data, client) + if torrent_message.status_code == 500: + return ResponseModel( + status_code=500, + status=False, + msg_en=f"Deleted rule for {data.official_title}, " + "but deleting its torrents failed.", + msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。", + ) logger.info(f"[Manager] Delete rule for {data.official_title}") return ResponseModel( status_code=200, diff --git a/backend/src/test/conftest.py b/backend/src/test/conftest.py index fa856fd7..61c13c73 100644 --- a/backend/src/test/conftest.py +++ b/backend/src/test/conftest.py @@ -222,5 +222,5 @@ def mock_download_client(): ] client.pause_torrent.return_value = None client.resume_torrent.return_value = None - client.delete_torrent.return_value = None + client.delete_torrent.return_value = True return client diff --git a/backend/src/test/test_api_downloader.py b/backend/src/test/test_api_downloader.py index 44d4f512..92680851 100644 --- a/backend/src/test/test_api_downloader.py +++ b/backend/src/test/test_api_downloader.py @@ -62,7 +62,7 @@ def mock_download_client(): ] client.pause_torrent.return_value = None client.resume_torrent.return_value = None - client.delete_torrent.return_value = None + client.delete_torrent.return_value = True return client @@ -433,3 +433,24 @@ class TestAutoTagTorrents: assert data["unmatched_count"] == 1 assert len(data["unmatched"]) == 1 mock_download_client.add_tag.assert_not_called() + + +class TestDeleteTorrentsFailure: + def test_delete_failure_is_reported(self, authed_client, mock_download_client): + """A rejected qB delete must not be reported as success (#1046).""" + mock_download_client.delete_torrent.return_value = False + with patch("module.api.downloader.DownloadClient") as MockClient: + MockClient.return_value.__aenter__ = AsyncMock( + return_value=mock_download_client + ) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + + response = authed_client.post( + "/api/v1/downloader/torrents/delete", + json={"hashes": ["abc123"], "delete_files": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] is False + assert data["msg_en"] == "Failed to delete torrents" diff --git a/backend/src/test/test_download_client.py b/backend/src/test/test_download_client.py index 2ce43af9..bc10e2e5 100644 --- a/backend/src/test/test_download_client.py +++ b/backend/src/test/test_download_client.py @@ -48,6 +48,19 @@ class TestAuth: await download_client.auth() assert download_client.authed is False + async def test_aenter_closes_client_on_failed_auth( + self, download_client, mock_qb_client + ): + """__aenter__ must close the concrete client's pool before raising, + because __aexit__ never runs on a failed connect (leak fix, #1043).""" + mock_qb_client.auth.return_value = False + + with pytest.raises(ConnectionError): + async with download_client: + pass + + mock_qb_client.logout.assert_awaited_once() + # --------------------------------------------------------------------------- # init_downloader diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index 81ee209f..40d7ed72 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -584,27 +584,31 @@ class TestAuthQb52Compat: assert result is False - async def test_auth_closes_client_on_failure(self): - """A failed auth must aclose() the httpx client (leak fix).""" + async def test_auth_returns_false_on_200_html_body(self): + """A 200 + HTML page (proxy, wrong service) is not a successful login.""" qb = QbDownloader( host="localhost:8080", username="admin", password="pass", ssl=False ) mock_client = AsyncMock() mock_resp = MagicMock() - mock_resp.status_code = 403 - mock_resp.text = "Forbidden" + mock_resp.status_code = 200 + mock_resp.text = "Sign in" mock_client.post = AsyncMock(return_value=mock_resp) - with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", - return_value=mock_client, + with ( + patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ), + patch( + "module.downloader.client.qb_downloader.asyncio.sleep", + new_callable=AsyncMock, + ), ): - result = await qb.auth() + result = await qb.auth(retry=1) assert result is False - mock_client.aclose.assert_awaited_once() - assert qb._client is None # --------------------------------------------------------------------------- diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py index f386e07b..d342a21a 100644 --- a/backend/src/test/test_setup.py +++ b/backend/src/test/test_setup.py @@ -373,3 +373,19 @@ class TestTestDownloaderHardening: assert data["success"] is False assert "secret-detail-xyz" not in data["message_en"] assert "secret-detail-xyz" not in data["message_zh"] + + def test_login_rejects_200_html_body(self, client, mock_first_run): + """A proxy answering 200 + HTML to the login POST is not a success.""" + from unittest.mock import MagicMock + + get_resp = MagicMock(text="qBittorrent WebUI") + login_resp = MagicMock( + status_code=200, text="portal" + ) + cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + assert response.json()["success"] is False From 656c83bd434bf838fe56a912e32413e57a92646f Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 12:06:59 +0200 Subject: [PATCH 16/16] chore: bump version to 3.2.8 and update changelog for release Re-home the stale [Unreleased] content under its actual release (3.2.6) and document the 3.2.8 fix batch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ backend/pyproject.toml | 2 +- backend/uv.lock | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a54bcd8..3cf311c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # [Unreleased] +# [3.2.8] - 2026-07-02 + +## Backend + +### Fixed + +- 修复 qBittorrent ≥ 5.2 登录失败:新版登录成功返回 HTTP 204 空响应体,旧代码要求 200 + "Ok." 导致认证失败 (#1044, #1034, #1043) +- 修复删除番剧/种子时文件不被删除:hashes 列表未按 qB API 要求以 `|` 拼接且响应状态未校验,删除失败现在会正确上报到 WebUI (#1046) +- 修复下载器认证失败时 httpx 连接池泄漏:连接失败后正确关闭客户端 (#1043) +- 加固初始设置接口:`/setup/test-downloader` 增加 URL 协议校验,所有 setup 接口不再回显原始错误详情 (#1041) +- 修复同一站点多个 RSS 并发请求触发 HTTP 429:按主机分组串行抓取并加入间隔,不同主机仍并行 (#1026) +- 修复 OpenAI 解析阻塞事件循环:切换到 AsyncOpenAI 异步客户端 +- 修复番剧缓存的失效竞态:加锁并引入代数计数,防止过期快照覆盖较新的失效 +- 修复无 `[字幕组]` 前缀的标题被解析器破坏 (#1025) +- 共享 httpx 客户端跟随 302 重定向(mikanime.tv 镜像域名) (#983) +- 修复 Linux 主机解析 Windows qBittorrent 保存路径导致季度总被置为 01 (#1016) +- 加固共享 httpx 客户端:缩短 keepalive 防止陈旧连接风暴 (#1018, #1028, #984) +- 删除 RSS 订阅时级联删除其种子记录,侧边栏不再残留条目 (#1019) +- 探测 qBittorrent 时遵循 SSL 设置 (#1014) + +### Maintenance + +- 发布包纳入 `pyproject.toml` / `uv.lock` / `requirements.txt` (#994, #1015) +- 容器启动 chown 跳过 `.venv`,加快启动 (#1011) + +# [3.2.6] - 2026-03-01 + ## Backend ### Added diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b9c5b4f5..3bfe0007 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "auto-bangumi" -version = "3.2.4" +version = "3.2.8" description = "AutoBangumi - Automated anime download manager" requires-python = ">=3.13" dependencies = [ diff --git a/backend/uv.lock b/backend/uv.lock index 00473cb5..1330f937 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -61,7 +61,7 @@ wheels = [ [[package]] name = "auto-bangumi" -version = "3.2.4" +version = "3.2.8" source = { virtual = "." } dependencies = [ { name = "aiosqlite" },