Merge pull request #1101 from EstrellaXD/3.3-dev

Release 3.3.5
This commit is contained in:
Estrella Pan
2026-07-26 15:49:29 +02:00
committed by GitHub
24 changed files with 500 additions and 43 deletions

View File

@@ -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 与修订版自动替换均需手动启用。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
from .cache_image import load_image, save_image
from .cache_image import load_image, load_poster_url, save_image

View File

@@ -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 时在图片旁写一个 ``<name>.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)

View File

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

View File

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

View File

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

View File

@@ -112,6 +112,44 @@ 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
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
# ---------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof movie>);
await activateTmdbScenario(environment, 'localized-retry-jp');
const retried = await analyseTmdbFixture(

View File

@@ -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<MovieRecord>;
export function analyseTmdbFixture(
request: APIRequestContext,
environment: E2EEnvironment,
fixture?: 'tmdb-tv.xml' | 'tmdb-retry.xml'
): Promise<BangumiRecord>;
export async function analyseTmdbFixture(
request: APIRequestContext,
environment: E2EEnvironment,
fixture = 'tmdb-tv.xml'
): Promise<BangumiRecord> {
): Promise<BangumiRecord | MovieRecord> {
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(