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 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/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/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_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.""" 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]) 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):