diff --git a/Dockerfile b/Dockerfile index cb980ef8..c293b0bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,9 @@ VOLUME ["/app/config", "/app/data"] # Liveness probe against the unauthenticated /health route; give the app # room to run migrations on first boot before failures count. +# healthcheck.py reads the configured WebUI port and probes IPv4 then IPv6 +# loopback (#1106). HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD wget -qO- http://localhost:7892/health || exit 1 + CMD python /app/healthcheck.py ENTRYPOINT ["tini", "-g", "--", "/entrypoint.sh"] diff --git a/backend/src/healthcheck.py b/backend/src/healthcheck.py new file mode 100644 index 00000000..69335eee --- /dev/null +++ b/backend/src/healthcheck.py @@ -0,0 +1,40 @@ +"""Docker HEALTHCHECK:探测 /health,退出码 0 表示健康。 + +端口取自 config.json(WebUI 可改,AB_WEBUI_PORT 只在首次启动时写入), +不能写死 7892。先连 IPv4 回环再连 IPv6:busybox wget 把 localhost 解析成 +::1,而默认只监听 0.0.0.0(#1106);IPV6/HOST=::1 时只有 ::1 可达。 +只用标准库,避免每 30 秒导入整个应用。 +""" + +import json +import sys +import urllib.request +from pathlib import Path + +CONFIG_PATH = Path("config/config.json") +DEFAULT_PORT = 7892 + + +def webui_port(config_path: Path = CONFIG_PATH) -> int: + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + return int(config["program"]["webui_port"]) + except (OSError, ValueError, KeyError, TypeError): + return DEFAULT_PORT + + +def main() -> int: + port = webui_port() + # 忽略 HTTP_PROXY 等环境变量:回环探测不能走代理 + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + for host in ("127.0.0.1", "[::1]"): + try: + with opener.open(f"http://{host}:{port}/health", timeout=2): + return 0 + except OSError: + continue + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/src/module/core/scheduler.py b/backend/src/module/core/scheduler.py index 7eca00fb..208eb80c 100644 --- a/backend/src/module/core/scheduler.py +++ b/backend/src/module/core/scheduler.py @@ -70,7 +70,9 @@ class PeriodicTask: raise except Exception as e: logger.error("%s tick failed: %s", self._name, e) - if await self._wait(self._interval()): + # 间隔 <= 0(配置为 0)会让循环空转、日志暴涨(#1117),退回 1 秒 + interval = self._interval() + if await self._wait(interval if interval > 0 else 1): return except asyncio.CancelledError: pass diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 00ff2d5b..11c5f0e9 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -175,13 +175,18 @@ def match_bangumi_in_list( or persistence_target(release) is not PersistenceTarget.BANGUMI ): return None + # title_raw 由解析器重组(剔除中间的集数、拼接 " / " 分隔的别名),未必是 + # 种子名的子串(#1103、#1114);与本种子重组出的标题完全相等也算命中。 + release_title = release and ( + release.title_en or release.title_zh or release.title_jp + ) best_match: Optional[Bangumi] = None best_rank = (-1, -1, -1) for bangumi in bangumi_list: if bangumi.deleted or not _release_matches_bangumi(release, bangumi): continue for pattern in _all_title_patterns(bangumi): - if pattern not in torrent_name: + if pattern not in torrent_name and pattern != release_title: continue group_match = int( release is not None @@ -217,6 +222,16 @@ def _release_matches_bangumi(release: "ParsedRelease | None", bangumi: Bangumi) return release.season is None or bangumi.season == release.season +def release_fits_bangumi(torrent_name: str, bangumi: Bangumi) -> bool: + """种子的季度/类型与番剧不冲突。整订阅下载(subscribe/collect)不做标题 + 匹配,搜索类 RSS 会混入其他季,需用此过滤(#1105)。""" + from module.parser.analyser.selector import parse_configured_release_title + + return _release_matches_bangumi( + parse_configured_release_title(torrent_name), bangumi + ) + + class BangumiDatabase: def __init__(self, session: AsyncSession): self.session = session diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index 5b02ff38..30e8c8f7 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -1,6 +1,7 @@ import logging from module.database import Database +from module.database.bangumi import release_fits_bangumi from module.downloader import AddResult, DownloadClient from module.models import Bangumi, ResponseModel from module.network import RequestContent @@ -53,6 +54,7 @@ class SeasonCollector: torrents = await req.get_torrents( link, bangumi.filter.replace(",", "|") ) + torrents = [t for t in torrents if release_fits_bangumi(t.name, bangumi)] async with Database() as db: # bangumi 必须先落库拿到 id:add_torrent 用它打 ab: 标签, # 种子行也要用它关联 bangumi_id——否则种子会被记成孤儿, diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index 567474ba..f57a32fc 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -88,6 +88,17 @@ async def get_shared_client() -> httpx.AsyncClient: return _shared_client +async def _replace_stale_client(stale: httpx.AsyncClient) -> httpx.AsyncClient: + """#1104: httpcore 在 HTTP 代理 CONNECT 成功、TLS 握手失败时泄漏隧道连接, + 连接池被占满后永久 PoolTimeout。换一个新 client 给后续请求;旧的不 aclose + (其他协程可能仍在使用,见 dc786a29),交给 GC 回收。""" + global _shared_client + # ponytail: 旧连接要等 GC 才释放;httpcore 修复 encode/httpcore#1087 发布后升级依赖并删除此函数 + if _shared_client is stale: + _shared_client = None + return await get_shared_client() + + async def reset_shared_client(): """关闭并清除共享客户端,下次请求时自动创建新连接池。""" global _shared_client, _shared_client_proxy_key @@ -156,6 +167,8 @@ class RequestURL: logger.warning( f"Request error for {url}: {type(e).__name__}. Retry {try_time + 1}/{retry}" ) + if isinstance(e, httpx.PoolTimeout): + self._client = await _replace_stale_client(self._client) try_time += 1 if try_time >= retry: break @@ -191,8 +204,10 @@ class RequestURL: e.response.text[:200], ) break - except httpx.RequestError: + except httpx.RequestError as e: logger.warning(f"Cannot connect to {url}. Wait for 5 seconds.") + if isinstance(e, httpx.PoolTimeout): + self._client = await _replace_stale_client(self._client) try_time += 1 if try_time >= retry: break diff --git a/backend/src/module/parser/analyser/tokenizer/classic.py b/backend/src/module/parser/analyser/tokenizer/classic.py index ffb650c6..6ba62b02 100644 --- a/backend/src/module/parser/analyser/tokenizer/classic.py +++ b/backend/src/module/parser/analyser/tokenizer/classic.py @@ -43,6 +43,8 @@ _SEASON_EPISODE_WORDS = re.compile( _SEASON = re.compile(r"(? None: state.season_raw = match.group(0) state.evidence.append("season") + for match in _consume_all(work, _ROMAN_SEASON): + work.consume(match) + state.season = ord(match.group()) - 0x215F + state.season_raw = match.group() + state.evidence.append("season") + for match in _consume_all(work, _ORDINAL_SEASON): if state.season is None: work.consume(match) @@ -876,6 +884,8 @@ def _clean_fragment(text: str) -> str: def _clean_title(text: str) -> str: text = _clean_fragment(text) + # 全角破折号包裹整段标题:"-魔獸之王…-" → "魔獸之王…"(#1108) + text = re.sub(r"^-(.+)-$", r"\1", text).strip() return re.sub(r"_+", " ", text).strip() diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index 58d4b5b8..99acb7e0 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -8,7 +8,11 @@ from urllib.parse import urlparse from module.conf import settings from module.database import Database -from module.database.bangumi import _groups_are_similar, match_bangumi_in_list +from module.database.bangumi import ( + _groups_are_similar, + match_bangumi_in_list, + release_fits_bangumi, +) from module.downloader import AddResult, DownloadClient from module.models import Bangumi, Movie, ResponseModel, RSSItem, Torrent from module.network import RequestContent @@ -462,6 +466,7 @@ class RSSEngine: torrents = await req.get_torrents( bangumi.rss_link, bangumi.filter.replace(",", "|") ) + torrents = [t for t in torrents if release_fits_bangumi(t.name, bangumi)] if torrents: async with DownloadClient() as client: result = await client.add_torrent(torrents, bangumi) diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index 44cc4bcb..6705dcbd 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -579,6 +579,37 @@ def test_numeric_mixed_collection_does_not_fall_through_to_title_matching( assert match_bangumi_in_list(raw, [bangumi]) is None +@pytest.mark.parametrize( + ("title_raw", "raw"), + ( + # #1103: 集数从标题中间被剔除,title_raw 不再是种子名的子串 + ( + "攻壳机动队「攻壳机动队 THE GHOST IN THE SHELL」 日英双语-多国字幕", + "攻壳机动队「攻壳机动队 THE GHOST IN THE SHELL」 S01E03 1080p 日英双语-多国字幕", + ), + # #1114: " / " 分隔的多个英文名被拼接成一个 title_raw + ( + "20 Seiki Denki Mokuroku Nijusseiki Denki Mokuroku", + "[喵萌奶茶屋&LoliHouse] 二十世纪电气目录 / 20 Seiki Denki Mokuroku / " + "Nijusseiki Denki Mokuroku - 07 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕]", + ), + ), +) +def test_match_bangumi_in_list_reconstructed_title_raw_matches( + title_raw: str, raw: str, monkeypatch +) -> None: + monkeypatch.setattr(settings.rss_parser, "engine", "classic") + bangumi = Bangumi( + official_title=title_raw, + title_raw=title_raw, + rss_link="rss", + season=1, + episode_type="episode", + ) + + assert match_bangumi_in_list(raw, [bangumi]) is bangumi + + def test_titleless_mixed_collection_does_not_bypass_typed_matching(monkeypatch) -> None: monkeypatch.setattr(settings.rss_parser, "engine", "tokenizer") bangumi = Bangumi( diff --git a/backend/src/test/test_healthcheck.py b/backend/src/test/test_healthcheck.py new file mode 100644 index 00000000..75e8ec9c --- /dev/null +++ b/backend/src/test/test_healthcheck.py @@ -0,0 +1,17 @@ +"""Docker HEALTHCHECK 端口解析(#1106)。""" + +import json +from pathlib import Path + +import healthcheck + + +def test_webui_port_reads_configured_port(tmp_path: Path) -> None: + config = tmp_path / "config.json" + config.write_text(json.dumps({"program": {"webui_port": 8080}})) + + assert healthcheck.webui_port(config) == 8080 + + +def test_webui_port_defaults_when_config_missing(tmp_path: Path) -> None: + assert healthcheck.webui_port(tmp_path / "config.json") == 7892 diff --git a/backend/src/test/test_raw_parser.py b/backend/src/test/test_raw_parser.py index e531c156..58ff18c0 100644 --- a/backend/src/test/test_raw_parser.py +++ b/backend/src/test/test_raw_parser.py @@ -514,3 +514,26 @@ class TestIssue1025NoGroupPrefix: # 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]) + + +class TestIssue1108RomanNumeralSeason: + """Issue #1108: Unicode 罗马数字(Ⅱ)标记季度,全角破折号包裹中文标题。""" + + CONTENT = ( + "[ANi] Clevatess Ⅱ-魔獸之王與虛假的勇者傳承- - 05 " + "[1080P][Baha][WEB-DL][AAC AVC][CHT].mp4" + ) + + def _parse(self): + info = raw_parser(self.CONTENT) + assert info is not None + return info + + def test_roman_numeral_is_season(self): + assert self._parse().season == 2 + + def test_zh_title_strips_numeral_and_fullwidth_dashes(self): + assert self._parse().title_zh == "魔獸之王與虛假的勇者傳承" + + def test_en_title_kept(self): + assert self._parse().title_en == "Clevatess" diff --git a/backend/src/test/test_request_url.py b/backend/src/test/test_request_url.py index abc209da..cffc1df0 100644 --- a/backend/src/test/test_request_url.py +++ b/backend/src/test/test_request_url.py @@ -117,6 +117,42 @@ class TestRetryWithReset: assert result.status_code == 200 +class TestPoolTimeoutRecovery: + """#1104: httpcore 在 HTTP 代理 CONNECT 成功、TLS 握手失败时泄漏隧道连接, + 共享连接池被占满后永久 PoolTimeout。遇到 PoolTimeout 时应换新 client。""" + + async def _get_with_poisoned_pool(self): + import httpx + + import module.network.request_url as mod + + old, new = AsyncMock(), AsyncMock() + old.get.side_effect = httpx.PoolTimeout("pool full") + resp = MagicMock(status_code=200, raise_for_status=MagicMock()) + new.get.return_value = resp + mod._shared_client = old + mod._shared_client_proxy_key = mod._proxy_config_key() + try: + with ( + patch("module.network.request_url.asyncio.sleep", new=AsyncMock()), + patch("module.network.request_url.httpx.AsyncClient", return_value=new), + ): + async with RequestURL() as req: + result = await req.get_url("https://example.com/x", retry=2) + finally: + mod._shared_client = None + mod._shared_client_proxy_key = None + return old, resp, result + + async def test_get_url_pool_timeout_retries_on_fresh_client(self): + _, resp, result = await self._get_with_poisoned_pool() + assert result is resp + + async def test_get_url_pool_timeout_does_not_close_old_client(self): + old, _, _ = await self._get_with_poisoned_pool() + old.aclose.assert_not_called() + + class TestReentrantContextManager: """Regression for the notification-dispatch concurrency restore. diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index deaffb7d..9c5df32c 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -683,6 +683,36 @@ class TestDownloadBangumi: assert resp.status_code == 502 assert await rss_engine.db.torrent.search_all() == [] + async def test_skips_torrents_of_another_season(self, rss_engine): + """#1105: 搜索 RSS 同时返回第一季和第二季,第二季不能按第一季下载。""" + bangumi = make_bangumi( + official_title="无职转生", rss_link="https://example.com/rss", filter="" + ) + s1 = Torrent( + name="[沸班亚马制作组] 无职转生~到了异世界就拿出真本事~ - 01 [2160p]", + url="https://example.com/s1e01.torrent", + ) + s2 = Torrent( + name="[沸班亚马制作组] 无职转生 第二季 ~到了异世界就拿出真本事~ - 01 [2160p]", + url="https://example.com/s2e01.torrent", + ) + with ( + patch("module.rss.engine.RequestContent") as MockReq, + patch("module.rss.engine.DownloadClient") as MockClient, + ): + req = AsyncMock() + req.get_torrents = AsyncMock(return_value=[s1, s2]) + MockReq.return_value.__aenter__ = AsyncMock(return_value=req) + MockReq.return_value.__aexit__ = AsyncMock(return_value=False) + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + MockClient.return_value.__aenter__ = AsyncMock(return_value=client) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + + await rss_engine.download_bangumi(bangumi) + + client.add_torrent.assert_awaited_once_with([s1], bangumi) + # --------------------------------------------------------------------------- # add_rss diff --git a/backend/src/test/test_scheduler.py b/backend/src/test/test_scheduler.py index 82f0c1a5..df239c58 100644 --- a/backend/src/test/test_scheduler.py +++ b/backend/src/test/test_scheduler.py @@ -27,6 +27,20 @@ class TestPeriodicTask: assert task.running is False assert counter["n"] >= 2 + async def test_nonpositive_interval_does_not_spin(self): + """#1117: rss_time/rename_time 配成 0 时循环空转、日志暴涨;应退回 1 秒间隔。""" + counter = {"n": 0} + + async def body(): + counter["n"] += 1 + + task = PeriodicTask("t", run=body, interval=lambda: 0) + task.start() + await asyncio.sleep(0.2) + await task.stop() + + assert counter["n"] == 1 + async def test_stop_is_idempotent(self): """Calling stop() twice (or before start) does not raise.""" task = PeriodicTask("t", run=lambda: asyncio.sleep(0), interval=lambda: 0.01) diff --git a/docs/deploy/docker-cli.md b/docs/deploy/docker-cli.md index a14d283a..4a053ec9 100644 --- a/docs/deploy/docker-cli.md +++ b/docs/deploy/docker-cli.md @@ -40,6 +40,8 @@ docker run -d \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ ghcr.io/estrellaxd/auto_bangumi:latest ``` diff --git a/docs/deploy/docker-compose.md b/docs/deploy/docker-compose.md index 9371ca1a..12ffe38c 100644 --- a/docs/deploy/docker-compose.md +++ b/docs/deploy/docker-compose.md @@ -41,6 +41,11 @@ services: ports: - "7892:7892" restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" dns: - 8.8.8.8 network_mode: bridge diff --git a/docs/deploy/quick-start.md b/docs/deploy/quick-start.md index 94bfb80d..cd317e01 100644 --- a/docs/deploy/quick-start.md +++ b/docs/deploy/quick-start.md @@ -42,6 +42,8 @@ docker run -d \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ ghcr.io/estrellaxd/auto_bangumi:latest ``` diff --git a/docs/en/deploy/docker-cli.md b/docs/en/deploy/docker-cli.md index 1f8af6d8..10f9bb17 100644 --- a/docs/en/deploy/docker-cli.md +++ b/docs/en/deploy/docker-cli.md @@ -40,6 +40,8 @@ docker run -d \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ ghcr.io/estrellaxd/auto_bangumi:latest ``` diff --git a/docs/en/deploy/docker-compose.md b/docs/en/deploy/docker-compose.md index bf441e98..a0b89f8f 100644 --- a/docs/en/deploy/docker-compose.md +++ b/docs/en/deploy/docker-compose.md @@ -41,6 +41,11 @@ services: ports: - "7892:7892" restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" dns: - 8.8.8.8 network_mode: bridge diff --git a/docs/en/deploy/quick-start.md b/docs/en/deploy/quick-start.md index ce41026a..220e2c37 100644 --- a/docs/en/deploy/quick-start.md +++ b/docs/en/deploy/quick-start.md @@ -42,6 +42,8 @@ docker run -d \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ ghcr.io/estrellaxd/auto_bangumi:latest ``` diff --git a/docs/ja/deploy/docker-cli.md b/docs/ja/deploy/docker-cli.md index 6078d4d8..452978c1 100644 --- a/docs/ja/deploy/docker-cli.md +++ b/docs/ja/deploy/docker-cli.md @@ -40,6 +40,8 @@ docker run -d \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ ghcr.io/estrellaxd/auto_bangumi:latest ``` diff --git a/docs/ja/deploy/docker-compose.md b/docs/ja/deploy/docker-compose.md index 7637ba99..2cce40fd 100644 --- a/docs/ja/deploy/docker-compose.md +++ b/docs/ja/deploy/docker-compose.md @@ -41,6 +41,11 @@ services: ports: - "7892:7892" restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" dns: - 8.8.8.8 network_mode: bridge diff --git a/docs/ja/deploy/quick-start.md b/docs/ja/deploy/quick-start.md index 12662074..50509e02 100644 --- a/docs/ja/deploy/quick-start.md +++ b/docs/ja/deploy/quick-start.md @@ -42,6 +42,8 @@ docker run -d \ --network=bridge \ --dns=8.8.8.8 \ --restart unless-stopped \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ ghcr.io/estrellaxd/auto_bangumi:latest ``` diff --git a/docs/resource/docker-compose/AutoBangumi/docker-compose.yml b/docs/resource/docker-compose/AutoBangumi/docker-compose.yml index 056cd445..e0998d47 100644 --- a/docs/resource/docker-compose/AutoBangumi/docker-compose.yml +++ b/docs/resource/docker-compose/AutoBangumi/docker-compose.yml @@ -11,6 +11,11 @@ services: # 应用内“一键更新”依赖此重启策略:更新后容器会自行退出,由 Docker 重启 # 以应用更新,因此必须保留 restart: unless-stopped。 restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" dns: - 223.5.5.5 environment: diff --git a/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml b/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml index 8da7775a..14c98568 100644 --- a/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml +++ b/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml @@ -28,6 +28,11 @@ services: # 应用内“一键更新”依赖此重启策略:更新后容器会自行退出,由 Docker 重启 # 以应用更新,因此必须保留 restart: unless-stopped。 restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" environment: - TZ=Asia/Shanghai - PGID=$(id -g)