fix: RSS matching, proxy pool, parser, healthcheck and log issues (#1103 #1104 #1105 #1106 #1108 #1114 #1117)

- match_bangumi_in_list: also accept a torrent whose rebuilt title equals
  the stored title_raw (#1103, #1114)
- download_bangumi / collect_season: drop items whose season or type
  conflicts with the bangumi (#1105)
- request_url: swap in a fresh shared client on PoolTimeout without
  closing the old one (#1104)
- classic parser: Unicode Roman numeral season, strip fullwidth dash
  wrapper (#1108)
- Docker HEALTHCHECK: healthcheck.py reads the configured WebUI port and
  probes IPv4 then IPv6 loopback (#1106)
- scheduler: non-positive interval falls back to 1s; deploy docs cap
  container logs at 10 MB x 3 (#1117)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KU9GqKzULrNb7MN5T9TYix
This commit is contained in:
Estrella Pan
2026-09-19 17:58:17 +02:00
parent 5cc2fabc64
commit 37fa6b4ea5
25 changed files with 284 additions and 5 deletions

View File

@@ -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"]

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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:<id> 标签,
# 种子行也要用它关联 bangumi_id——否则种子会被记成孤儿,

View File

@@ -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

View File

@@ -43,6 +43,8 @@ _SEASON_EPISODE_WORDS = re.compile(
_SEASON = re.compile(r"(?<!\w)(S\d{1,2}|Season\s+\d{1,2})(?!\w)", re.I)
_ORDINAL_SEASON = re.compile(r"\b(\d{1,2})(?:st|nd|rd|th)\s+Season\b", re.I)
_CHINESE_SEASON = re.compile(r"第([零〇一二两三四五六七八九十百\d]+)[季期]")
# Unicode 罗马数字 Ⅰ-Ⅻ(U+2160-216B)作季度标记,如 "Clevatess Ⅱ"(#1108)
_ROMAN_SEASON = re.compile(r"(?<![A-Za-z])[Ⅰ-Ⅻ](?![A-Za-z])")
_EXPLICIT_EPISODE = re.compile(
r"(?<!\w)(?:Episode|EP?\.?|#)\s*[-_. ]?\s*" r"(\d{1,4}(?:\.\d+)?)(?:v(\d+))?(?!\w)",
re.I,
@@ -536,6 +538,12 @@ def _extract_numbers(working: list[_WorkingSegment], state: _State) -> 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()

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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
```

View File

@@ -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

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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

View File

@@ -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
```

View File

@@ -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:

View File

@@ -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)