From 520873f0358ccb928fe53a8e06c30c53e1ae91a5 Mon Sep 17 00:00:00 2001 From: Estrella Pan <33726646+EstrellaXD@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:33:36 +0200 Subject: [PATCH 1/7] test(e2e): align standalone movie contract (#1080) --- webui/e2e/specs/tmdb-language.spec.ts | 6 ++++-- webui/e2e/support/issue-data.ts | 21 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/webui/e2e/specs/tmdb-language.spec.ts b/webui/e2e/specs/tmdb-language.spec.ts index 60dfd49f..c0f2c418 100644 --- a/webui/e2e/specs/tmdb-language.spec.ts +++ b/webui/e2e/specs/tmdb-language.spec.ts @@ -69,8 +69,10 @@ test('changing parser language re-queries the same TMDB title in that locale', a ); expect(movie).toMatchObject({ official_title: 'ローカライズ映画', - episode_type: 'movie', - }); + title_raw: 'Localized Movie', + year: 2026, + group_name: 'E2E', + } satisfies Partial); await activateTmdbScenario(environment, 'localized-retry-jp'); const retried = await analyseTmdbFixture( diff --git a/webui/e2e/support/issue-data.ts b/webui/e2e/support/issue-data.ts index cd5b32a3..a7f57770 100644 --- a/webui/e2e/support/issue-data.ts +++ b/webui/e2e/support/issue-data.ts @@ -22,6 +22,13 @@ export interface BangumiRecord { [key: string]: unknown; } +export interface MovieRecord { + official_title: string; + title_raw: string | null; + year: number | null; + group_name: string | null; +} + interface SeedRuleOptions { title: string; uniqueName: string; @@ -99,11 +106,21 @@ export async function mockRequests( return payload.requests; } +export function analyseTmdbFixture( + request: APIRequestContext, + environment: E2EEnvironment, + fixture: 'tmdb-movie.xml' +): Promise; +export function analyseTmdbFixture( + request: APIRequestContext, + environment: E2EEnvironment, + fixture?: 'tmdb-tv.xml' | 'tmdb-retry.xml' +): Promise; export async function analyseTmdbFixture( request: APIRequestContext, environment: E2EEnvironment, fixture = 'tmdb-tv.xml' -): Promise { +): Promise { const response = await requireStatus( await request.post('/api/v1/rss/analysis', { data: { @@ -116,7 +133,7 @@ export async function analyseTmdbFixture( }), 200 ); - return response.json(); + return (await response.json()) as BangumiRecord | MovieRecord; } async function analyseParserFixture( From e3b6e03067de22a83e45ab37a1a2d1b9ae4ca627 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 11:08:19 +0200 Subject: [PATCH 2/7] fix(rss): re-enable disabled RSS on re-subscribe (#1095) Deleting a bangumi disables its orphan standalone RSS feed (#1053), but re-subscribing the same feed hit the duplicate-URL early return in RSSDatabase.add() and never reset enabled, so the rebuilt bangumi silently stopped updating. Re-enable the existing row and report it as a successful add. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- backend/src/module/database/rss.py | 8 ++++++++ backend/src/test/test_database.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/backend/src/module/database/rss.py b/backend/src/module/database/rss.py index cc345b24..2316ab26 100644 --- a/backend/src/module/database/rss.py +++ b/backend/src/module/database/rss.py @@ -17,6 +17,14 @@ class RSSDatabase: result = await self.session.execute(statement) db_data = result.scalar_one_or_none() if db_data: + if not db_data.enabled: + # 删除番剧时孤儿 RSS 会被停用(#1053);重新订阅同一 URL 表达了 + # 恢复更新的意图,必须重新启用,否则番剧会静默断更(#1095)。 + db_data.enabled = True + self.session.add(db_data) + await self.session.commit() + logger.info("Re-enabled disabled RSS Item %s.", data.url) + return True logger.debug("RSS Item %s already exists.", data.url) return False else: diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index b827f598..d60039d3 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -112,6 +112,20 @@ async def test_rss_database(db_session): assert result.url == rss_url +async def test_rss_add_reenables_disabled_duplicate(db_session): + """重新订阅已存在但被停用的 RSS(删除番剧后孤儿停用,#1095)应重新启用。""" + rss_url = "https://mikanani.me/RSS/Bangumi?bangumiId=4020&subgroupid=583" + db = RSSDatabase(db_session) + + await db.add(RSSItem(url=rss_url, name="Test RSS")) + item = await db.search_url(rss_url) + await db.disable(item.id) + + assert await db.add(RSSItem(url=rss_url, name="Test RSS")) is True + item = await db.search_url(rss_url) + assert item.enabled is True + + # --------------------------------------------------------------------------- # TorrentDatabase qb_hash methods # --------------------------------------------------------------------------- From cadaf1fe59c16b50a2fb4d5279476e1d2f66cee3 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 11:13:15 +0200 Subject: [PATCH 3/7] fix(notification): send Telegram posters by source URL with text fallback (#1094) Download-complete notifications were silently dropped when sendPhoto failed: post_form returned None on any error and the provider crashed on resp.status_code, with no text fallback. HTTP rejections were also logged as "Cannot connect", hiding the real cause. - save_image now records the poster's source URL in a .url sidecar; Telegram prefers sendPhoto with that URL (server-side fetch), falls back to local multipart upload, then to a plain text message. - load_image returns None instead of raising when the cached file is missing. - post_url/post_form log HTTP status and response body separately from connection errors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- backend/src/module/network/request_url.py | 22 +++++++- .../module/notification/providers/telegram.py | 26 +++++++-- .../module/parser/analyser/mikan_parser.py | 9 ++- .../src/module/parser/analyser/tmdb_parser.py | 11 ++-- backend/src/module/update/cross_version.py | 4 +- backend/src/module/utils/__init__.py | 2 +- backend/src/module/utils/cache_image.py | 45 +++++++++++++-- backend/src/test/test_cache_image.py | 54 +++++++++++++++++- backend/src/test/test_notification.py | 56 +++++++++++++++++++ 9 files changed, 208 insertions(+), 21 deletions(-) diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index 85983060..567474ba 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -181,6 +181,16 @@ class RequestURL: req = await self._client.post(url=url, headers=self.header, data=data) req.raise_for_status() return req + except httpx.HTTPStatusError as e: + # 服务端拒绝不是连接失败,重试无意义;把状态码和响应体 + # (如 Telegram 的 JSON description)记下来便于排查(#1094)。 + logger.warning( + "HTTP %s from %s: %s", + e.response.status_code, + url, + e.response.text[:200], + ) + break except httpx.RequestError: logger.warning(f"Cannot connect to {url}. Wait for 5 seconds.") try_time += 1 @@ -219,7 +229,17 @@ class RequestURL: ) req.raise_for_status() return req - except (httpx.RequestError, httpx.HTTPStatusError): + except httpx.HTTPStatusError as e: + # 别把服务端拒绝(4xx/5xx)误报成连接失败:响应体里通常带有明确的 + # 拒绝原因(如 Telegram 的 JSON description),排查 #1094 全靠它。 + logger.warning( + "HTTP %s from %s: %s", + e.response.status_code, + url, + e.response.text[:200], + ) + return None + except httpx.RequestError: logger.warning(f"Cannot connect to {url}.") return None diff --git a/backend/src/module/notification/providers/telegram.py b/backend/src/module/notification/providers/telegram.py index eccef11e..f4e92633 100644 --- a/backend/src/module/notification/providers/telegram.py +++ b/backend/src/module/notification/providers/telegram.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from module.models.bangumi import Notification from module.notification.base import NotificationProvider -from module.utils import load_image +from module.utils import load_image, load_poster_url if TYPE_CHECKING: from module.models.config import NotificationProvider as ProviderConfig @@ -33,12 +33,28 @@ class TelegramProvider(NotificationProvider): "disable_notification": True, } - photo = await load_image(notification.poster_path) - if photo: - resp = await self.post_files(self.photo_url, data, files={"photo": photo}) - else: + # 发图优先级:源 URL(Telegram 服务端拉图)→ 本地缓存 multipart 上传。 + # 任一失败都降级发纯文本,通知内容不能因海报问题丢失(#1094)。 + resp = None + photo_url = await load_poster_url(notification.poster_path) + if photo_url: + resp = await self.post_data(self.photo_url, {**data, "photo": photo_url}) + if resp is None or resp.status_code != 200: + photo = await load_image(notification.poster_path) + if photo: + resp = await self.post_files( + self.photo_url, data, files={"photo": photo} + ) + if resp is None or resp.status_code != 200: + if resp is not None: + logger.warning( + "Telegram sendPhoto failed (status %s), falling back to text.", + resp.status_code, + ) resp = await self.post_data(self.message_url, data) + if resp is None: + return False logger.debug("Telegram notification: %s", resp.status_code) return resp.status_code == 200 diff --git a/backend/src/module/parser/analyser/mikan_parser.py b/backend/src/module/parser/analyser/mikan_parser.py index 017c6565..3f969475 100644 --- a/backend/src/module/parser/analyser/mikan_parser.py +++ b/backend/src/module/parser/analyser/mikan_parser.py @@ -54,10 +54,15 @@ async def mikan_parser(homepage: str): # (AttributeValueList); "style" is never multi-valued in practice. poster_path = poster_div.split("url('")[1].split("')")[0] # type: ignore[union-attr] poster_path = poster_path.split("?")[0] - img = await req.get_content(f"https://{root_path}{poster_path}") + poster_url = f"https://{root_path}{poster_path}" + img = await req.get_content(poster_url) suffix = poster_path.split(".")[-1] # img can be None if the poster download failed; don't crash on it. - poster_link = (await save_image(img, suffix) or "") if img else "" + poster_link = ( + (await save_image(img, suffix, source_url=poster_url) or "") + if img + else "" + ) return _cache_result(homepage, (poster_link, official_title)) return _cache_result(homepage, ("", official_title)) diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index f102eaac..6dcbb4d5 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -374,11 +374,14 @@ async def tmdb_parser( year_number = (info_content.get("first_air_date") or "").split("-")[0] if poster_path: if not test: - img = await req.get_content( - f"https://image.tmdb.org/t/p/w780{poster_path}" - ) + poster_url = f"https://image.tmdb.org/t/p/w780{poster_path}" + img = await req.get_content(poster_url) # img is None if the poster download failed; don't crash on it. - poster_link = await save_image(img, "jpg") if img else None + poster_link = ( + await save_image(img, "jpg", source_url=poster_url) + if img + else None + ) else: poster_link = "https://image.tmdb.org/t/p/w780" + poster_path else: diff --git a/backend/src/module/update/cross_version.py b/backend/src/module/update/cross_version.py index 55f843a2..706bf80d 100644 --- a/backend/src/module/update/cross_version.py +++ b/backend/src/module/update/cross_version.py @@ -63,7 +63,9 @@ async def cache_image(): # Hash local path img = await req.get_content(bangumi.poster_link) suffix = bangumi.poster_link.split(".")[-1] - img_path = await save_image(img, suffix) + img_path = await save_image( + img, suffix, source_url=bangumi.poster_link + ) if img_path: bangumi.poster_link = img_path except Exception as e: diff --git a/backend/src/module/utils/__init__.py b/backend/src/module/utils/__init__.py index 5c975f45..377db1bb 100644 --- a/backend/src/module/utils/__init__.py +++ b/backend/src/module/utils/__init__.py @@ -1 +1 @@ -from .cache_image import load_image, save_image +from .cache_image import load_image, load_poster_url, save_image diff --git a/backend/src/module/utils/cache_image.py b/backend/src/module/utils/cache_image.py index 5f3e0bb4..fd2df865 100644 --- a/backend/src/module/utils/cache_image.py +++ b/backend/src/module/utils/cache_image.py @@ -1,10 +1,20 @@ import asyncio import hashlib +import logging + +logger = logging.getLogger(__name__) -async def save_image(img: bytes | None, suffix: str) -> str | None: +async def save_image( + img: bytes | None, suffix: str, source_url: str | None = None +) -> str | None: """保存图片到本地缓存。文件写入是同步阻塞的,放到线程池中执行,避免 - 在 RSS/通知等异步热路径上阻塞事件循环。""" + 在 RSS/通知等异步热路径上阻塞事件循环。 + + 传入 source_url 时在图片旁写一个 ``.url`` sidecar 记录源地址: + 通知端(Telegram sendPhoto 等)可以直接发 URL 让服务端拉图,绕开本地 + multipart 上传(#1094)。 + """ if img is None: # Fetching the poster failed upstream; skip caching instead of # crashing on hashlib.md5(None). @@ -15,18 +25,41 @@ async def save_image(img: bytes | None, suffix: str) -> str | None: def _write() -> None: with open(image_path, "wb") as f: f.write(img) + if source_url: + with open(f"{image_path}.url", "w", encoding="utf-8") as f: + f.write(source_url) await asyncio.to_thread(_write) return f"posters/{img_hash}.{suffix}" async def load_image(img_path: str | None) -> bytes | None: - """读取缓存图片。文件读取是同步阻塞的,放到线程池中执行。""" + """读取缓存图片。文件读取是同步阻塞的,放到线程池中执行。 + 缓存文件丢失(手动清理、迁移不完整)返回 None,不炸穿通知发送。""" if not img_path: return None - def _read() -> bytes: - with open(f"data/{img_path}", "rb") as f: - return f.read() + def _read() -> bytes | None: + try: + with open(f"data/{img_path}", "rb") as f: + return f.read() + except OSError: + logger.warning("Cached poster %s is missing or unreadable.", img_path) + return None + + return await asyncio.to_thread(_read) + + +async def load_poster_url(img_path: str | None) -> str | None: + """读取缓存图片对应的源 URL sidecar;旧缓存没有 sidecar 时返回 None。""" + if not img_path: + return None + + def _read() -> str | None: + try: + with open(f"data/{img_path}.url", encoding="utf-8") as f: + return f.read().strip() or None + except OSError: + return None return await asyncio.to_thread(_read) diff --git a/backend/src/test/test_cache_image.py b/backend/src/test/test_cache_image.py index 9b0ddaf2..381cfcbe 100644 --- a/backend/src/test/test_cache_image.py +++ b/backend/src/test/test_cache_image.py @@ -7,7 +7,7 @@ tmp_path so they never touch the real backend/data/ directory. import asyncio -from module.utils.cache_image import load_image, save_image +from module.utils.cache_image import load_image, load_poster_url, save_image class TestSaveImage: @@ -66,6 +66,14 @@ class TestLoadImage: assert result == b"cached-poster-bytes" + async def test_load_image_returns_none_when_file_missing( + self, tmp_path, monkeypatch + ): + """海报缓存文件丢失(手动清理、迁移不完整)不应异常炸穿通知发送。""" + monkeypatch.chdir(tmp_path) + + assert await load_image("posters/missing.jpg") is None + async def test_load_image_offloads_read_to_a_thread(self, tmp_path, monkeypatch): """The read must go through asyncio.to_thread, not run inline on the event loop (regression test for the blocking-I/O fix).""" @@ -86,3 +94,47 @@ class TestLoadImage: await load_image("posters/abc123.jpg") assert len(calls) == 1 + + +class TestPosterUrlSidecar: + """保存海报时同时记录源 URL(#1094):Telegram 等通知可以直接发 URL, + 让服务端自行拉图,绕开本地 multipart 上传的失败路径。""" + + async def test_save_image_writes_url_sidecar_when_source_url_given( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "data" / "posters").mkdir(parents=True) + + result = await save_image( + b"fake-image-bytes", "jpg", source_url="https://mikanani.me/img/a.jpg" + ) + + assert result is not None + sidecar = tmp_path / "data" / f"{result}.url" + assert sidecar.read_text() == "https://mikanani.me/img/a.jpg" + + async def test_load_poster_url_returns_stored_source_url( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "data" / "posters").mkdir(parents=True) + path = await save_image( + b"fake-image-bytes", "jpg", source_url="https://mikanani.me/img/a.jpg" + ) + + assert await load_poster_url(path) == "https://mikanani.me/img/a.jpg" + + async def test_load_poster_url_returns_none_without_sidecar( + self, tmp_path, monkeypatch + ): + """升级前缓存的旧海报没有 .url sidecar:降级为本地上传,不报错。""" + monkeypatch.chdir(tmp_path) + (tmp_path / "data" / "posters").mkdir(parents=True) + path = await save_image(b"fake-image-bytes", "jpg") + + assert await load_poster_url(path) is None + + async def test_load_poster_url_returns_none_for_falsy_path(self): + assert await load_poster_url(None) is None + assert await load_poster_url("") is None diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index 7aee0dee..bba6852b 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -241,6 +241,62 @@ class TestTelegramProvider: assert result is True mock_post.assert_called_once() + async def test_send_prefers_poster_source_url(self, provider): + """有源 URL 时默认让 Telegram 服务端按 URL 拉图,不做本地上传(#1094)。""" + notify = Notification( + official_title="Test Anime", + season=1, + episode=5, + poster_path="posters/abc123.jpg", + ) + + with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post: + mock_post.return_value = MagicMock(status_code=200) + with patch( + "module.notification.providers.telegram.load_poster_url", + new_callable=AsyncMock, + ) as mock_url: + mock_url.return_value = "https://mikanani.me/img/a.jpg" + with patch( + "module.notification.providers.telegram.load_image", + new_callable=AsyncMock, + ) as mock_load: + result = await provider.send(notify) + + assert result is True + mock_post.assert_called_once() + assert mock_post.call_args[0][0] == provider.photo_url + assert mock_post.call_args[0][1]["photo"] == "https://mikanani.me/img/a.jpg" + mock_load.assert_not_called() + + async def test_send_falls_back_to_text_when_photo_send_fails(self, provider): + """sendPhoto 失败(返回 None)不再崩溃,降级发纯文本消息(#1094)。""" + notify = Notification( + official_title="Test Anime", + season=1, + episode=5, + poster_path="posters/abc123.jpg", + ) + + with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post: + mock_post.side_effect = [None, MagicMock(status_code=200)] + with patch( + "module.notification.providers.telegram.load_poster_url", + new_callable=AsyncMock, + ) as mock_url: + mock_url.return_value = "https://mikanani.me/img/a.jpg" + with patch( + "module.notification.providers.telegram.load_image", + new_callable=AsyncMock, + ) as mock_load: + mock_load.return_value = None + result = await provider.send(notify) + + assert result is True + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == provider.photo_url + assert mock_post.call_args_list[1][0][0] == provider.message_url + async def test_test_success(self, provider): """Test method sends test message.""" with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post: From dee536cdc9ef0ab495cb909ff0dda90d3d30d8fd Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 11:39:24 +0200 Subject: [PATCH 4/7] fix(parser): admit unmarked episode-less releases on classic engine (#1092) Subtitle groups sometimes release TV specials without an episode number or any special/movie marker (e.g. LoliHouse's ONE PIECE HEROINES). The Preview engine's admission policy already accepts such evidence-backed single releases as eps_collect bangumi, but the classic engine's legacy Episode projection rejected them outright, so default installs silently skipped the torrent. Align classic admission with the Preview persistence policy for exactly this shape (no episode, unknown media type, admissible under persistence_target). The frozen legacy Episode contract and its golden corpus stay untouched; numbered legacy rejections (e.g. fractional episodes) keep their behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- backend/src/module/parser/title_parser.py | 15 ++++- backend/src/test/test_issue_bugs.py | 74 +++++++++++++++++++++++ backend/src/test/test_title_parser.py | 28 ++++++--- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index 49015b60..17013e88 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -456,7 +456,20 @@ class TitleParser: ) deterministic = parse_outcome.result if parse_outcome.engine == "classic": - deterministic = _project_classic_release(raw, deterministic) + projected = _project_classic_release(raw, deterministic) + if ( + projected is None + and deterministic is not None + and deterministic.episode is None + and deterministic.media_type is MediaType.UNKNOWN + and persistence_target(deterministic) is not None + ): + # 字幕组未标记特别篇/剧场版等字样的无集数单发资源(#1092): + # 旧版投影契约会整条拒绝,导致订阅静默漏抓。与 Preview 的 + # 准入策略对齐,按 eps_collect 整理集接纳;带集数的旧版 + # 拒绝(如小数集)不受影响。 + projected = deterministic + deterministic = projected if ( deterministic is None and parse_outcome.trace is not None diff --git a/backend/src/test/test_issue_bugs.py b/backend/src/test/test_issue_bugs.py index 7fb6565c..b5b8cece 100644 --- a/backend/src/test/test_issue_bugs.py +++ b/backend/src/test/test_issue_bugs.py @@ -555,3 +555,77 @@ class TestIssue1005SearchOfficialTitle: db = BangumiDatabase(db_session) result = await db.search_official_title("不存在的番剧") assert result is None + + +# --------------------------------------------------------------------------- +# Issue #1092: 字幕组未标记特别篇等字样时,无集数标题无法解析 +# https://github.com/EstrellaXD/Auto_Bangumi/issues/1092 +# +# LoliHouse 发布的 TV 特别篇(如 ONE PIECE HEROINES)标题中没有集数也没有 +# 特别篇/剧场版标记。Preview 引擎的准入策略按 eps_collect 整理集接纳这类 +# 带发布证据的单发资源,但 classic 引擎的旧版兼容投影会整条拒绝,导致 +# 默认配置下订阅静默漏抓。 +# --------------------------------------------------------------------------- + + +class TestIssue1092UnmarkedEpisodelessRelease: + """Issue #1092: classic admission now matches the Preview policy.""" + + REPORTED_TITLE = ( + "[LoliHouse] 海贼王:女英雄们的故事 / ONE PIECE HEROINES " + "[WebRip 1080p HEVC-10bit AAC][简繁内封字幕]" + ) + + async def test_classic_admits_reported_title_as_collect_bangumi(self): + from unittest.mock import patch + + from module.conf import settings + from module.models import Bangumi + from module.models.config import LLM + from module.parser import TitleParser + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "classic"), + ): + result = await TitleParser.raw_parser(self.REPORTED_TITLE) + + assert isinstance(result, Bangumi) + assert result.official_title == "海贼王:女英雄们的故事" + assert result.group_name == "LoliHouse" + assert result.episode_type == "episode" + assert result.eps_collect is True + + async def test_classic_and_preview_agree_on_reported_title(self): + from unittest.mock import patch + + from module.conf import settings + from module.models import Bangumi + from module.models.config import LLM + from module.parser import TitleParser + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "tokenizer"), + ): + preview_result = await TitleParser.raw_parser(self.REPORTED_TITLE) + + assert isinstance(preview_result, Bangumi) + assert preview_result.official_title == "海贼王:女英雄们的故事" + + async def test_classic_still_rejects_title_without_release_evidence(self): + from unittest.mock import patch + + from module.conf import settings + from module.models.config import LLM + from module.parser import TitleParser + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "classic"), + ): + result = await TitleParser.raw_parser( + "Random Non Matching Title No Digits At All" + ) + + assert result is None diff --git a/backend/src/test/test_title_parser.py b/backend/src/test/test_title_parser.py index e944dfd9..450069ca 100644 --- a/backend/src/test/test_title_parser.py +++ b/backend/src/test/test_title_parser.py @@ -233,14 +233,8 @@ class TestConfiguredParserEngine: assert result is None - @pytest.mark.parametrize( - "raw", - ( - "[Group] Anime [1080p]", - "[Group] Anime EP07.5 [1080p]", - ), - ) - async def test_classic_preserves_legacy_compatibility_rejections(self, raw): + async def test_classic_preserves_legacy_compatibility_rejections(self): + raw = "[Group] Anime EP07.5 [1080p]" parsed = parse_classic_release_title(raw) assert parsed is not None @@ -254,6 +248,24 @@ class TestConfiguredParserEngine: assert result is None + async def test_classic_admits_episodeless_release_with_evidence(self): + """无集数且无类型标记但带发布证据的单发资源(#1092):旧版 Episode + 投影仍拒绝(冻结契约),但 classic 准入与 Preview 策略对齐后放行。""" + raw = "[Group] Anime [1080p]" + parsed = parse_classic_release_title(raw) + + assert parsed is not None + assert to_legacy_episode(parsed) is None + + with ( + patch.object(settings, "llm", LLM(enable=False)), + patch.object(settings.rss_parser, "engine", "classic"), + ): + result = await TitleParser.raw_parser(raw) + + assert isinstance(result, Bangumi) + assert result.eps_collect is True + async def test_classic_projects_admitted_result_through_legacy_episode(self): raw = "[Group] Anime - 01 [2024][1080p]" parsed = parse_classic_release_title(raw) From f1ca52cbe887ba4288d9570f836813566807c0f4 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 15:36:02 +0200 Subject: [PATCH 5/7] fix(database): clear FK references before bangumi reset-all BangumiDatabase.delete_all() issued a bare DELETE FROM bangumi while torrent.bangumi_id and aria2_gid.bangumi_id rows still referenced it; with PRAGMA foreign_keys=ON the whole reset rolled back with IntegrityError, and the surviving torrent dedup rows made re-added bangumi skip episodes as already downloaded. Delete bangumi-linked torrent records (same semantics as delete_rule) and detach aria2_gid mappings before the table wipe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- backend/src/module/database/bangumi.py | 20 +++++++++++++++++--- backend/src/test/test_database.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index d3f48f73..75499cf6 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import func -from sqlmodel import and_, delete, false, or_, select +from sqlmodel import and_, delete, false, or_, select, update from module.models import Bangumi, BangumiUpdate @@ -559,8 +559,22 @@ class BangumiDatabase: logger.debug("Delete bangumi id: %s.", _id) async def delete_all(self): - statement = delete(Bangumi) - await self.session.execute(statement) + # torrent / aria2_gid 行经外键引用 bangumi.id(PRAGMA foreign_keys=ON), + # 必须先清理引用,否则整表重置在约束上直接回滚。种子记录随规则一起删 + # (与 delete_rule 语义一致,重新添加后能重新下载);aria2 映射行保留 + # 但解除关联。 + from module.models.aria2 import Aria2Gid + from module.models.torrent import Torrent + + await self.session.execute( + delete(Torrent).where(Torrent.bangumi_id.is_not(None)) # type: ignore[attr-defined] + ) + await self.session.execute( + update(Aria2Gid) + .where(Aria2Gid.bangumi_id.is_not(None)) # type: ignore[attr-defined] + .values(bangumi_id=None) + ) + await self.session.execute(delete(Bangumi)) await self.session.commit() async def search_all(self) -> list[Bangumi]: diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index b827f598..69b8f14e 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -112,6 +112,30 @@ async def test_rss_database(db_session): assert result.url == rss_url +async def test_bangumi_delete_all_with_fk_references_succeeds(db_session): + """reset/all 全表重置:torrent/aria2_gid 行经外键引用 bangumi 时, + 必须先清理引用,否则 PRAGMA foreign_keys=ON 下整个删除直接回滚。""" + from sqlalchemy import text + from sqlmodel import select as sql_select + + from module.models.aria2 import Aria2Gid + + await db_session.execute(text("PRAGMA foreign_keys=ON")) + await _ensure_bangumi(db_session, 1) + db_session.add(Torrent(name="ep01", url="https://example.com/1", bangumi_id=1)) + db_session.add(Aria2Gid(gid="gid-1", bangumi_id=1)) + await db_session.commit() + + db = BangumiDatabase(db_session) + await db.delete_all() + + assert await db.search_all() == [] + torrents = (await db_session.execute(sql_select(Torrent))).scalars().all() + assert torrents == [] + gid_row = (await db_session.execute(sql_select(Aria2Gid))).scalars().one() + assert gid_row.bangumi_id is None + + # --------------------------------------------------------------------------- # TorrentDatabase qb_hash methods # --------------------------------------------------------------------------- From 2f1a18f37e6302c0cd6fd66029ae266a1afc1844 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 15:39:11 +0200 Subject: [PATCH 6/7] fix(config): stop masking empty secrets and scrub corrupted mask sentinels Empty sensitive fields were masked to '********' in GET /config, so the UI showed a phantom password where none was set. Worse, 3.2.3/3.2.4 masked without restoring on save, so users who saved config on those versions had the literal sentinel written into stored proxy/downloader credentials, breaking authentication until manually cleared. - _sanitize_dict now masks only non-empty values. - Settings._migrate_old_config scrubs literal '********' sentinels from sensitive fields on load (original values are unrecoverable), logging a warning so users know to re-enter real secrets. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- backend/src/module/api/config.py | 4 +++- backend/src/module/conf/config.py | 31 +++++++++++++++++++++++++++++ backend/src/test/test_api_config.py | 15 ++++++++++---- backend/src/test/test_config.py | 31 +++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py index cc000554..10a91fe5 100644 --- a/backend/src/module/api/config.py +++ b/backend/src/module/api/config.py @@ -34,7 +34,9 @@ def _sanitize_dict(d: dict) -> dict: result[k] = [ _sanitize_dict(item) if isinstance(item, dict) else item for item in v ] - elif isinstance(v, str) and _is_sensitive(k): + elif isinstance(v, str) and v and _is_sensitive(k): + # 空值不打掩码:否则未设置密码的字段在前端永远显示一串幻影 + # 掩码,用户误以为存了密码(并可能因此反复"删除保存")。 result[k] = _MASK else: result[k] = v diff --git a/backend/src/module/conf/config.py b/backend/src/module/conf/config.py index 83aca1fd..10c1dd56 100644 --- a/backend/src/module/conf/config.py +++ b/backend/src/module/conf/config.py @@ -12,6 +12,35 @@ from .const import DEFAULT_SETTINGS, ENV_TO_ATTR logger = logging.getLogger(__name__) CONFIG_ROOT = Path("config") +# 与 api/config.py 的掩码约定保持一致(此处不 import,避免 conf ← api 循环) +_MASK_SENTINEL = "********" +_SENSITIVE_KEYS = ("password", "api_key", "token", "secret") + + +def _scrub_corrupted_masks(config: dict, path: str = "") -> None: + """把字面写入配置的掩码哨兵按空值清洗。 + + 3.2.3/3.2.4 的配置接口只掩码不恢复,期间保存过配置的用户把 ``********`` + 字面写进了密码/密钥字段(代理/下载器认证因此失败)。原值已不可恢复, + 置空并告警,提示用户重新输入。 + """ + for k, v in config.items(): + key_path = f"{path}.{k}" if path else k + if isinstance(v, dict): + _scrub_corrupted_masks(v, key_path) + elif isinstance(v, list): + for i, item in enumerate(v): + if isinstance(item, dict): + _scrub_corrupted_masks(item, f"{key_path}[{i}]") + elif v == _MASK_SENTINEL and any(s in k.lower() for s in _SENSITIVE_KEYS): + config[k] = "" + logger.warning( + "Config field '%s' contained a literal mask sentinel " + "(corrupted by a 3.2.3/3.2.4 save); cleared it — re-enter " + "the secret if one is required.", + key_path, + ) + try: from module.__version__ import VERSION @@ -111,6 +140,8 @@ class Settings(Config): "mode": "primary", } + _scrub_corrupted_masks(config) + return config def save(self, config_dict: dict | None = None): diff --git a/backend/src/test/test_api_config.py b/backend/src/test/test_api_config.py index 59b6f0c1..9a5bb45b 100644 --- a/backend/src/test/test_api_config.py +++ b/backend/src/test/test_api_config.py @@ -339,6 +339,13 @@ class TestSanitizeDict: result = _sanitize_dict({"API_KEY": "abc"}) assert result["API_KEY"] == "********" + def test_empty_sensitive_value_is_not_masked(self): + """空密码不打掩码:否则前端每次打开都显示一串幻影密码(TG 报告), + 用户误以为存了密码。""" + result = _sanitize_dict({"password": "", "api_key": ""}) + assert result["password"] == "" + assert result["api_key"] == "" + def test_non_sensitive_keys_pass_through(self): """Non-sensitive keys are returned unchanged.""" result = _sanitize_dict({"host": "localhost", "port": 8080, "enable": True}) @@ -412,10 +419,10 @@ class TestSanitizeDict: data = response.json() # Downloader password should be masked assert data["downloader"]["password"] == "********" - # LLM api_key should be masked (it's an empty string but still masked) - assert data["llm"]["api_key"] == "********" - # Legacy OpenAI api_key should be masked too - assert data["experimental_openai"]["api_key"] == "********" + # Unset (empty) secrets must NOT be masked: a phantom mask makes the + # UI show a password where none exists (TG report) + assert data["llm"]["api_key"] == "" + assert data["experimental_openai"]["api_key"] == "" # --------------------------------------------------------------------------- diff --git a/backend/src/test/test_config.py b/backend/src/test/test_config.py index 8b52756f..e8974d0c 100644 --- a/backend/src/test/test_config.py +++ b/backend/src/test/test_config.py @@ -742,3 +742,34 @@ class TestNetworkBaseUrls: await bgm_calendar.fetch_bgm_calendar() assert captured["url"] == "https://bgm.mirror.test/calendar" + + +# --------------------------------------------------------------------------- +# Settings._migrate_old_config: 掩码哨兵清洗 +# --------------------------------------------------------------------------- + + +class TestScrubCorruptedMasks: + """3.2.3/3.2.4 只掩码不恢复,保存过配置的用户把字面 ******** 写进了 + 密码/密钥字段(代理认证因此失败,TG 报告)。加载时按空值清洗。""" + + def test_literal_mask_in_sensitive_field_is_scrubbed(self): + config = { + "program": {}, + "rss_parser": {}, + "proxy": {"password": "********", "username": "u"}, + "downloader": {"password": "********"}, + } + result = Settings._migrate_old_config(config) + assert result["proxy"]["password"] == "" + assert result["downloader"]["password"] == "" + + def test_real_values_and_non_sensitive_keys_are_untouched(self): + config = { + "program": {}, + "rss_parser": {}, + "proxy": {"password": "real-pass", "host": "********"}, + } + result = Settings._migrate_old_config(config) + assert result["proxy"]["password"] == "real-pass" + assert result["proxy"]["host"] == "********" From f54df55754c09490987874c5b7bbc6ca96ab3b6a Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sun, 26 Jul 2026 15:49:06 +0200 Subject: [PATCH 7/7] chore(release): prepare 3.3.5 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WTfJZ3EzCpY8SLhzXT6Sf --- CHANGELOG.md | 10 ++++++++++ backend/pyproject.toml | 2 +- docs/changelog/3.3.md | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b292700..d4d6cd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# [3.3.5] - 2026-07-26 + +3.3.5 是问题修复版本,包含五个订阅、通知与配置相关的修复。 + +- **修复(#1095,RSS)**:删除番剧后重新订阅同一 RSS 时,自动重新启用被孤儿停用机制禁用的订阅,番剧不再静默断更 +- **修复(#1094,通知)**:Telegram 海报默认改为发送源 URL(由 Telegram 服务端拉图),失败时降级为本地上传、再降级为纯文本消息;下载完成通知不再因海报发送失败而整条丢失,服务端拒绝(4xx/5xx)与连接失败的日志分开记录并附带响应内容 +- **修复(#1092,解析器)**:classic 引擎与 Preview 准入策略对齐,接纳字幕组未标记特别篇/剧场版且无集数、但带发布证据(组名/分辨率/片源)的单发资源,按整理集处理;纯文字弱标题仍被拒绝 +- **修复(数据库)**:「重置所有规则」先清理种子记录与 aria2 映射的外键引用再删除番剧,不再因 `FOREIGN KEY constraint failed` 整体回滚;重置失败残留的去重记录导致番剧补全跳过已有集数的问题随之解决 +- **修复(配置)**:未设置的密码/密钥不再在界面上显示为 `********` 幻影掩码;自动清洗 3.2.3/3.2.4 版本窗口期间被字面写入配置的掩码哨兵(该窗口只掩码不恢复,导致代理/下载器认证失败),清洗时告警提示重新输入 + # [3.3.4] - 2026-07-13 3.3.4 正式版汇总 beta.1–beta.2。经典解析器仍为默认;通用解析器 Preview 与修订版自动替换均需手动启用。 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0b013b3d..7dcc7fc3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "auto-bangumi" -version = "3.3.4" +version = "3.3.5" description = "AutoBangumi - Automated anime download manager" requires-python = ">=3.13" dependencies = [ diff --git a/docs/changelog/3.3.md b/docs/changelog/3.3.md index 608da4fe..abe1f112 100644 --- a/docs/changelog/3.3.md +++ b/docs/changelog/3.3.md @@ -2,6 +2,17 @@ 3.3 is the largest AutoBangumi release in a while: a fully async backend architecture, aria2 promoted to a first-class download backend, movie/OVA/special support, per-bangumi release preferences, a hardened security stack, and a WebUI that updates over SSE instead of polling. REST API behavior is backward-compatible; existing databases upgrade automatically through schema v24. +## 3.3.5 — Subscription, notification, and configuration fixes + +3.3.5 is a bugfix release with five fixes across RSS subscriptions, +notifications, parsing, and configuration. + +- **RSS re-subscribe (#1095):** re-subscribing a bangumi whose standalone RSS feed was disabled by the orphan-cleanup on deletion now re-enables the feed, so the rebuilt bangumi keeps updating. +- **Telegram notifications (#1094):** posters are now sent by their source URL (fetched server-side by Telegram), falling back to a local multipart upload and then to a plain text message. Completion notifications are no longer dropped when the poster send fails, and server rejections are logged with status and response body instead of as connection failures. +- **Parser (#1092):** the classic engine now matches the Preview admission policy for episode-less single releases that carry release evidence (group, resolution, source) but no special/movie marker — such as unmarked TV specials — admitting them as season-collect entries. Weak title-only strings remain rejected. +- **Reset all rules:** the bulk reset clears torrent records and aria2 mappings referencing bangumi before deleting the table, instead of rolling back with `FOREIGN KEY constraint failed`. Stale dedup records that made season-collect skip existing episodes are cleaned up with it. +- **Configuration:** unset passwords and API keys are no longer rendered as phantom `********` masks in the UI, and literal mask sentinels written into stored credentials by the 3.2.3/3.2.4 window (which masked without restoring) are scrubbed on load with a warning to re-enter the secret. + ## 3.3.4 — Authentication, Parser Preview, and safe revision handling 3.3.4 is the stable consolidation of 3.3.4-beta.1 and beta.2. Classic remains