diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2ff4de..741dd159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# [3.3.1] - 2026-07-07 + +- **修复**:搜索/订阅/收集添加的种子未关联番剧被记成孤儿,导致孤儿记录无法通过 `track_orphans` 关闭;重复订阅时正确解析已存在的规则(不覆盖用户调好的 offset/filter),重新订阅已禁用的规则时自动重新启用;收集失败不再留下幽灵订阅规则 +- **修复**(#721):标题中的 Windows/qB 保留字符(`< > : " / \ | ? *`)会把保存路径拆成多级目录或丢字——生成保存路径时清洗(仅新订阅,不触碰既有做种库) +- **修复**(#667/#704):总集篇等半集(12.5)不再被取整成第 12 集覆盖原文件,重命名保留小数(`S01E12.5`),通知同步保留 +- **新增**(#904):Server酱³ 推送支持——`sctp` 开头的 sendkey 自动走 `push.ft07.com` 端点 +- **新增**(#147):重命名完成的种子在下载器中打 `ab:renamed` 标签,供外部脚本(filebot / hlink 等)过滤已处理任务 +- **新增**(#975 / #1042):TMDB API Key 可自配(留空回退内置共享 key);设置页新增"网络设置"面板(TMDB / Bangumi API 地址与 Key) + # [3.3.0] - 2026-07-07 3.3.0 正式版。主要变化(相对 3.2.x;逐项细节见下方 beta.1–beta.8 条目): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3237311c..0bebc430 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "auto-bangumi" -version = "3.3.0" +version = "3.3.1" description = "AutoBangumi - Automated anime download manager" requires-python = ">=3.13" dependencies = [ diff --git a/backend/src/module/api/downloader.py b/backend/src/module/api/downloader.py index 72fbc667..6de81a96 100644 --- a/backend/src/module/api/downloader.py +++ b/backend/src/module/api/downloader.py @@ -1,4 +1,5 @@ import logging +import re from fastapi import APIRouter, Depends from pydantic import BaseModel @@ -123,8 +124,9 @@ async def auto_tag_torrents(): save_path = torrent["save_path"] tags = torrent.get("tags", "") - # Skip if already has ab: tag - if "ab:" in tags: + # Skip if already has an ab: link tag。必须精确匹配数字 id: + # ab:renamed(处理完成标记)等同前缀标签不代表已关联番剧 + if re.search(r"ab:\d+", tags): continue # First try by torrent name, then fall back to save_path diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 5baf7ed0..5782e2cd 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -246,16 +246,37 @@ class BangumiDatabase: """Get all title patterns for matching (title_raw + all aliases).""" return _all_title_patterns(bangumi) - async def _is_duplicate(self, data: Bangumi) -> bool: - """Check if a bangumi rule already exists based on title_raw and group_name.""" - statement = select(Bangumi).where( - and_( - Bangumi.title_raw == data.title_raw, - Bangumi.group_name == data.group_name, + async def find_duplicate(self, data: Bangumi) -> Optional[Bangumi]: + """Find an existing bangumi with the same title_raw and group_name. + + Legacy data may contain multiple rows for the same key; return the + oldest (lowest id) deterministically and warn instead of raising. + """ + statement = ( + select(Bangumi) + .where( + and_( + Bangumi.title_raw == data.title_raw, + Bangumi.group_name == data.group_name, + ) ) + .order_by(Bangumi.id) # type: ignore[arg-type] ) result = await self.session.execute(statement) - return result.scalar_one_or_none() is not None + rows = list(result.scalars().all()) + if len(rows) > 1: + logger.warning( + "Multiple bangumi rows share (title_raw=%s, group_name=%s); " + "using the oldest (id=%s)", + data.title_raw, + data.group_name, + rows[0].id, + ) + return rows[0] if rows else None + + async def _is_duplicate(self, data: Bangumi) -> bool: + """Check if a bangumi rule already exists based on title_raw and group_name.""" + return await self.find_duplicate(data) is not None async def add(self, data: Bangumi) -> bool: if await self._is_duplicate(data): @@ -436,6 +457,25 @@ class BangumiDatabase: await self.session.commit() logger.debug("Update %s poster_link to %s.", title_raw, poster_link) + async def restore_one(self, _id: int) -> bool: + """取消软删除(重新启用规则)。行不存在或本就未删除时不写库。""" + bangumi = await self.session.get(Bangumi, _id) + if not bangumi or not bangumi.deleted: + return False + bangumi.deleted = False + self.session.add(bangumi) + await self.session.commit() + logger.info("Restored disabled bangumi id: %s.", _id) + return True + + async def mark_eps_collect(self, _id: int) -> None: + """只把 eps_collect 置位,不触碰行内其他字段(offset/filter 等)。""" + bangumi = await self.session.get(Bangumi, _id) + if bangumi: + bangumi.eps_collect = True + self.session.add(bangumi) + await self.session.commit() + async def delete_one(self, _id: int): statement = select(Bangumi).where(Bangumi.id == _id) result = await self.session.execute(statement) diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index c0bbd183..6cc3b46d 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -21,6 +21,21 @@ else: _MEDIA_SUFFIXES = frozenset({".mp4", ".mkv"}) _SUBTITLE_SUFFIXES = frozenset({".ass", ".srt"}) +# Windows/qB 保留字符 + 控制字符:出现在路径片段里会被下载器拆成多级 +# 目录或直接丢字(qB 静默截断),必须在拼路径前替换掉 (#721) +_ILLEGAL_PATH_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def sanitize_path_fragment(name: str) -> str: + """把单个路径片段(文件夹名或文件名,不含分隔符)里的保留字符替换为空格。 + + 多余空白折叠为单个空格;Windows 不允许目录/文件名以点或空格结尾, + 一并去掉。幂等:对已合法的名字原样返回。 + """ + cleaned = _ILLEGAL_PATH_CHARS_RE.sub(" ", name) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned.rstrip(". ") + def check_files(files: list[dict]): media_list = [] @@ -72,9 +87,13 @@ def gen_save_path(data: Bangumi | BangumiUpdate): specials/OVA/OAD land in "Season 0" (Jellyfin/Plex convention) instead of being interleaved with regular episodes. """ - folder = ( + folder = sanitize_path_fragment( f"{data.official_title} ({data.year})" if data.year else data.official_title ) + if not folder: + # 标题全由保留字符组成时清洗结果为空——不能让所有这类番剧 + # 坍缩到同一个 ``…//Season N`` 目录里 + folder = "Unknown Bangumi" episode_type = getattr(data, "episode_type", "episode") if episode_type == "movie": # 电影/剧场版:Title (Year)/Title (Year).ext,不建 Season 子目录 diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index 2238f4e2..5b02ff38 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -10,6 +10,33 @@ from module.searcher import SearchTorrent logger = logging.getLogger(__name__) +async def _ensure_bangumi_id(db: Database, data: Bangumi) -> bool: + """确保 ``data.id`` 可用:新番剧插入拿 id,重复番剧解析出已存在行的 id。 + + ``add()`` 对精确重复(title_raw+group_name)与语义重复(并入别名)都 + 返回 False 且不回填 id——不解析已存在行的话,add_torrent 的 ab: + 标签与种子行的 bangumi_id 关联都会丢失,种子被记成孤儿。 + + 解析到被软删除(禁用)的行时会重新启用它:显式订阅/收集一个已禁用 + 的规则只能是用户想要它回来——不启用的话种子要么变孤儿、要么挂到 + 对下游所有查询都不可见的行上。传入的 id 指向已不存在的行时清空, + 避免种子挂到悬空外键。返回是否插入了新行(调用方据此决定失败时 + 是否回滚删除)。 + """ + if await db.bangumi.add(data): + return True + existing = await db.bangumi.find_duplicate(data) + if existing is None: + existing = await db.bangumi.find_semantic_duplicate(data) + if existing is not None: + if existing.deleted: + await db.bangumi.restore_one(existing.id) + data.id = existing.id + elif data.id is not None and await db.bangumi.search_id(data.id) is None: + data.id = None # type: ignore[assignment] + return False + + class SeasonCollector: def __init__(self, client: DownloadClient): self.client = client @@ -27,18 +54,32 @@ class SeasonCollector: link, bangumi.filter.replace(",", "|") ) async with Database() as db: + # bangumi 必须先落库拿到 id:add_torrent 用它打 ab: 标签, + # 种子行也要用它关联 bangumi_id——否则种子会被记成孤儿, + # track_orphans 开关对这些"已匹配"的种子完全失效。 + # update() returns False when no existing row matches + # bangumi.id (i.e. this is a brand-new bangumi), in which + # case it needs to be inserted (or resolved to the existing + # duplicate row) instead. + inserted = False + if bangumi.id is None or not await db.bangumi.update(bangumi): + inserted = await _ensure_bangumi_id(db, bangumi) + else: + # 载荷带着已禁用行的 id 也能 update 成功:显式收集 + # 即用户想重新启用,否则种子会挂到不可见的行上 + await db.bangumi.restore_one(bangumi.id) if await self.client.add_torrent(torrents, bangumi) is AddResult.ADDED: logger.info( f"Collections of {bangumi.official_title} Season {bangumi.season} completed." ) for torrent in torrents: torrent.downloaded = True + torrent.bangumi_id = bangumi.id bangumi.eps_collect = True - # update() returns False when no existing row matches - # bangumi.id (i.e. this is a brand-new bangumi), in which - # case it needs to be inserted instead. - if not await db.bangumi.update(bangumi): - await db.bangumi.add(bangumi) + # 只更新 eps_collect 单个字段:解析到已存在行时,整行 + # update 会用刚解析的默认值覆盖用户调好的 offset/filter + if bangumi.id is not None: + await db.bangumi.mark_eps_collect(bangumi.id) await db.torrent.add_all(torrents) return ResponseModel( status=True, @@ -47,6 +88,10 @@ class SeasonCollector: msg_zh=f"收集 {bangumi.official_title} 第 {bangumi.season} 季完成。", ) else: + if inserted and bangumi.id is not None: + # 收集失败时回滚刚插入的行,不留下幽灵订阅规则 + await db.bangumi.delete_one(bangumi.id) + bangumi.id = None # type: ignore[assignment] logger.warning( f"Already collected {bangumi.official_title} Season {bangumi.season}." ) @@ -69,9 +114,10 @@ class SeasonCollector: aggregate=False, parser=parser, ) - result = await engine.download_bangumi(data) - await db.bangumi.add(data) - return result + # 先落库拿到 id(重复订阅时解析已存在行的 id):download_bangumi + # 里 add_torrent 的 ab: 标签和种子行的 bangumi_id 关联都依赖它 + await _ensure_bangumi_id(db, data) + return await engine.download_bangumi(data) async def eps_complete(): diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 7fb7512c..1c28044a 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -25,6 +25,11 @@ _PENDING_RENAME_COOLDOWN = 300 # 5 minutes cooldown before retrying same rename _CLEANUP_INTERVAL = 60 # Clean up pending cache at most once per minute _last_cleanup_time: float = 0 +# 处理完成标记:供外部脚本(filebot、hlink 等)过滤 AB 已重命名的任务 (#147)。 +# 语义 = 顶层媒体文件全部就位;字幕在同一轮循环里紧随其后重命名,深层 +# 嵌套文件(特典/花絮)设计上从不重命名——两者都不阻塞打标 +_RENAMED_TAG = "ab:renamed" + class Renamer: def __init__(self, client: DownloadClient): @@ -56,6 +61,30 @@ class Renamer: ) logger.debug("Checked %s files", torrent_count) + @staticmethod + def _adjust_episode(original: int | float, episode_offset: int) -> int | float: + if original == 0 and episode_offset != 0: + # Episode 0 is a special/OVA — never apply offset to avoid + # overwriting regular episodes (see issue #977) + return 0 + adjusted = original + episode_offset + # An offset producing a non-positive result (e.g., EP5 + offset -10) + # is almost always a misconfiguration, so revert to original. + if adjusted < 0 or (adjusted == 0 and original > 0): + logger.warning( + f"Episode offset {episode_offset} would make episode {original} non-positive, ignoring offset" + ) + return original + return adjusted + + @staticmethod + def _format_episode(episode: int | float) -> str: + # 总集篇等半集(12.5)保留小数,否则会覆盖同季的整数集 (#667); + # 整数值沿用两位补零 + if isinstance(episode, float) and episode.is_integer(): + episode = int(episode) + return f"0{episode}" if episode < 10 else str(episode) + @staticmethod def gen_path( file_info: EpisodeFile | SubtitleFile, @@ -69,31 +98,22 @@ class Renamer: # So we use file_info.season directly without applying offset again season_num = file_info.season season = f"0{season_num}" if season_num < 10 else season_num - # Apply episode offset - original_episode = int(file_info.episode) - if original_episode == 0 and episode_offset != 0: - # Episode 0 is a special/OVA — never apply offset to avoid - # overwriting regular episodes (see issue #977) - adjusted_episode = 0 - else: - adjusted_episode = original_episode + episode_offset - # An offset producing a non-positive result (e.g., EP5 + offset -10) - # is almost always a misconfiguration, so revert to original. - if adjusted_episode < 0 or (adjusted_episode == 0 and original_episode > 0): - adjusted_episode = original_episode - logger.warning( - f"Episode offset {episode_offset} would make episode {original_episode} non-positive, ignoring offset" - ) - episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode + episode = Renamer._format_episode( + Renamer._adjust_episode(file_info.episode, episode_offset) + ) # 注意:group_tag 只影响 qB RSS 规则名(downloader/path.py 的 rule_name), # 从不写进重命名后的文件名——已有做种媒体库的文件名必须保持稳定, # 否则升级后会触发整库批量重命名,破坏 Plex/Jellyfin 索引与硬链接 if method == "none" or method == "subtitle_none": return file_info.media_path + # 注意:这里的 title/bangumi_name 来自已存在于磁盘上的文件/文件夹名 + # (单个路径分量,不可能含分隔符),不做保留字符清洗——追加清洗会让 + # 既有做种库(如含 ":" 的标题)在升级后被整库批量重命名 (#721 评审) + title = file_info.title if file_info.episode_type == "movie": # 电影/剧场版:Title (Year).ext,不使用 SxxExx 编号。bangumi_name 由 # 调用方传入,与 gen_save_path 的文件夹命名保持一致 (Title (Year)) - base = bangumi_name if "advance" in method else file_info.title + base = bangumi_name if "advance" in method else title if method.startswith("subtitle_"): assert isinstance( file_info, SubtitleFile @@ -101,7 +121,7 @@ class Renamer: return f"{base}.{file_info.language}{file_info.suffix}" return f"{base}{file_info.suffix}" elif method == "pn": - return f"{file_info.title} S{season}E{episode}{file_info.suffix}" + return f"{title} S{season}E{episode}{file_info.suffix}" elif method == "advance": return f"{bangumi_name} S{season}E{episode}{file_info.suffix}" elif method == "normal": @@ -111,7 +131,7 @@ class Renamer: assert isinstance( file_info, SubtitleFile ), "subtitle_pn requires a SubtitleFile" - return f"{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}" + return f"{title} S{season}E{episode}.{file_info.language}{file_info.suffix}" elif method == "subtitle_advance": assert isinstance( file_info, SubtitleFile @@ -121,6 +141,19 @@ class Renamer: logger.error(f"Unknown rename method: {method}") return file_info.media_path + async def _mark_renamed(self, _hash: str, existing_tags: str | None) -> None: + """给处理完成的种子打 ``ab:renamed`` 标签;已带标签时不再调 API。 + + 打标失败绝不能影响重命名主流程(重命名已经成功、通知必须发出、 + 本轮其余种子必须继续处理)——吞掉异常,下一轮会自动补打。 + """ + if _RENAMED_TAG in (t.strip() for t in (existing_tags or "").split(",")): + return + try: + await self.client.add_tag(_hash, _RENAMED_TAG) + except Exception as e: + logger.warning("Failed to tag %s as renamed: %s", _hash[:8], e) + async def rename_file( self, torrent_name: str, @@ -132,6 +165,7 @@ class Renamer: episode_offset: int = 0, season_offset: int = 0, episode_type: str = "episode", + existing_tags: str | None = None, **kwargs, ): ep = self._parser.torrent_parser( @@ -148,7 +182,12 @@ class Renamer: episode_offset=episode_offset, season_offset=season_offset, ) - if media_path != new_path: + if media_path == new_path: + # 已符合目标命名(如重启后再次扫描):视为处理完成。 + # none/normal 是直通方法,没有"重命名完成"的语义,不打标 + if method not in ("none", "normal"): + await self._mark_renamed(_hash, existing_tags) + else: # Check if this rename was recently attempted but didn't take effect # (qBittorrent can return 200 but delay actual rename while seeding) pending_key = (_hash, media_path, new_path) @@ -165,21 +204,13 @@ class Renamer: ): # Rename verified successful, remove from pending cache _pending_renames.pop(pending_key, None) + await self._mark_renamed(_hash, existing_tags) # Season comes from folder which already has offset applied # Only apply episode offset - original_ep = int(ep.episode) - if original_ep == 0 and episode_offset != 0: - adjusted_episode = 0 - else: - adjusted_episode = original_ep + episode_offset - if adjusted_episode < 0 or ( - adjusted_episode == 0 and original_ep > 0 - ): - adjusted_episode = original_ep return Notification( official_title=bangumi_name, season=ep.season, - episode=adjusted_episode, + episode=self._adjust_episode(ep.episode, episode_offset), ) else: # Rename API returned success but file wasn't actually renamed @@ -217,6 +248,7 @@ class Renamer: season_offset: int = 0, episode_type: str = "episode", file_sizes: dict[str, int] | None = None, + existing_tags: str | None = None, **kwargs, ): # 多文件电影种子(正片 + 特典/花絮):所有文件会解析出同一标题, @@ -229,6 +261,7 @@ class Renamer: movie_primary = max(ep_list, key=lambda p: file_sizes.get(p, 0)) else: movie_primary = ep_list[0] + all_renamed = True for media_path in media_list: if is_ep(media_path): ep = self._parser.torrent_parser( @@ -256,11 +289,19 @@ class Renamer: _hash=_hash, old_path=media_path, new_path=new_path ) if not renamed: + all_renamed = False logger.warning(f"{media_path} rename failed") # Delete bad torrent. if settings.bangumi_manage.remove_bad_torrent: await self.client.delete_torrent(_hash) break + else: + # 解析失败的媒体文件不会被重命名——不能算处理完成 + all_renamed = False + # 全部媒体文件就位(含本轮无需改名的情况)才算处理完成; + # none/normal 直通方法没有"重命名完成"的语义,不打标 + if all_renamed and method not in ("none", "normal"): + await self._mark_renamed(_hash, existing_tags) async def rename_subtitles( self, @@ -568,6 +609,8 @@ class Renamer: "episode_offset": episode_offset, "season_offset": season_offset, "episode_type": episode_type, + # 处理完成后打 ab:renamed 标签用;已带标签则跳过 API 调用 + "existing_tags": info.get("tags"), } # Rename single media file if len(media_list) == 1: diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index 1bac9478..69789087 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -113,7 +113,8 @@ class BangumiUpdate(SQLModel): class Notification(BaseModel): official_title: str = Field(..., alias="official_title", title="番剧名") season: int = Field(..., alias="season", title="番剧季度") - episode: int = Field(..., alias="episode", title="番剧集数") + # int | float:总集篇等半集(12.5)在通知里保留小数 (#667) + episode: int | float = Field(..., alias="episode", title="番剧集数") poster_path: Optional[str] = Field(None, alias="poster_path", title="番剧海报路径") diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index e6054314..09205682 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -90,6 +90,8 @@ class Network(BaseModel): tmdb_base_url: str = Field( default="https://api.themoviedb.org", description="TMDB API base URL" ) + # 留空时回退到内置共享 key;自配 key 可避开共享 key 的限流 (#975) + tmdb_api_key: str = Field(default="", description="Custom TMDB API key") bgm_base_url: str = Field( default="https://api.bgm.tv", description="Bangumi (bgm.tv) API base URL" ) diff --git a/backend/src/module/notification/providers/server_chan.py b/backend/src/module/notification/providers/server_chan.py index 03f753d3..14a9cd7f 100644 --- a/backend/src/module/notification/providers/server_chan.py +++ b/backend/src/module/notification/providers/server_chan.py @@ -1,6 +1,7 @@ """Server Chan notification provider.""" import logging +import re from typing import TYPE_CHECKING from module.models.bangumi import Notification @@ -11,6 +12,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# Server酱³ 的 sendkey 形如 sctpt,推送端点与 Turbo 版不同 (#904) +_SC3_KEY_RE = re.compile(r"^sctp(\d+)t") + class ServerChanProvider(NotificationProvider): """Server Chan (Server酱) notification provider for WeChat.""" @@ -18,7 +22,13 @@ class ServerChanProvider(NotificationProvider): def __init__(self, config: "ProviderConfig"): super().__init__(config) token = config.token - self.notification_url = f"https://sctapi.ftqq.com/{token}.send" + sc3 = _SC3_KEY_RE.match(token) + if sc3: + self.notification_url = ( + f"https://{sc3.group(1)}.push.ft07.com/send/{token}.send" + ) + else: + self.notification_url = f"https://sctapi.ftqq.com/{token}.send" async def send(self, notification: Notification) -> bool: """Send notification via Server Chan.""" diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index c823a243..d16002eb 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -18,6 +18,11 @@ def _tmdb_url() -> str: return settings.network.tmdb_base_url.rstrip("/") +def _api_key() -> str: + # 用户自配 key 优先(#975);留空回退到内置共享 key。同样读实时值 + return settings.network.tmdb_api_key or TMDB_API + + # In-memory cache for TMDB lookups to avoid repeated API calls _TMDB_CACHE_MAX = 512 _tmdb_cache: OrderedDict[str, "TMDBInfo | None"] = OrderedDict() @@ -59,19 +64,19 @@ LANGUAGE = {"zh": "zh-CN", "jp": "ja-JP", "en": "en-US"} def search_url(e): - return f"{_tmdb_url()}/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false" + return f"{_tmdb_url()}/3/search/tv?api_key={_api_key()}&page=1&query={e}&include_adult=false" def search_movie_url(e): - return f"{_tmdb_url()}/3/search/movie?api_key={TMDB_API}&page=1&query={e}&include_adult=false" + return f"{_tmdb_url()}/3/search/movie?api_key={_api_key()}&page=1&query={e}&include_adult=false" def info_url(e, key): - return f"{_tmdb_url()}/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}" + return f"{_tmdb_url()}/3/tv/{e}?api_key={_api_key()}&language={LANGUAGE[key]}" def season_url(tv_id, season_number, key): - return f"{_tmdb_url()}/3/tv/{tv_id}/season/{season_number}?api_key={TMDB_API}&language={LANGUAGE[key]}" + return f"{_tmdb_url()}/3/tv/{tv_id}/season/{season_number}?api_key={_api_key()}&language={LANGUAGE[key]}" async def is_animation(tv_id, language, req: RequestContent) -> bool: diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index 964316e3..8750ef28 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -400,6 +400,9 @@ class RSSEngine: ) for torrent in torrents: torrent.downloaded = True + # 关联 bangumi_id,避免已匹配的种子被记成孤儿 + if bangumi.id is not None: + torrent.bangumi_id = bangumi.id await self.db.torrent.add_all(torrents) return ResponseModel( status=True, diff --git a/backend/src/test/test_api_downloader.py b/backend/src/test/test_api_downloader.py index 2c2270e4..f596744e 100644 --- a/backend/src/test/test_api_downloader.py +++ b/backend/src/test/test_api_downloader.py @@ -369,7 +369,8 @@ class TestAutoTagTorrents: deleted=False, ) - # Mock torrents - one untagged, one already tagged + # Mock torrents - one untagged, one already tagged, one only carrying + # the ab:renamed completion marker (must NOT count as linked) mock_download_client.get_torrent_info.return_value = [ { "hash": "abc123", @@ -383,6 +384,12 @@ class TestAutoTagTorrents: "save_path": "/downloads/Other Anime/Season 1", "tags": "ab:456", # Already tagged }, + { + "hash": "ghi789", + "name": "[TestGroup] Test Anime - 02.mkv", + "save_path": "/downloads/Test Anime/Season 1", + "tags": "ab:renamed", # 处理完成标记 ≠ 已关联番剧 + }, ] with patch("module.api.downloader.DownloadClient") as MockClient: @@ -402,9 +409,13 @@ class TestAutoTagTorrents: assert response.status_code == 200 data = response.json() assert data["status"] is True - assert data["tagged_count"] == 1 - # Only the untagged torrent should be tagged - mock_download_client.add_tag.assert_called_once_with("abc123", "ab:123") + assert data["tagged_count"] == 2 + # The untagged torrent and the ab:renamed-only torrent get linked; + # the ab:456 one is skipped + assert mock_download_client.add_tag.call_args_list == [ + (("abc123", "ab:123"),), + (("ghi789", "ab:123"),), + ] def test_auto_tag_no_matches(self, authed_client, mock_download_client): """POST /downloader/torrents/tag/auto handles unmatched torrents.""" diff --git a/backend/src/test/test_collector.py b/backend/src/test/test_collector.py new file mode 100644 index 00000000..57668723 --- /dev/null +++ b/backend/src/test/test_collector.py @@ -0,0 +1,203 @@ +"""Tests for SeasonCollector: torrents must be persisted with bangumi_id. + +Regression tests for the orphan-torrent bug: torrents added via +subscribe/collect were stored with ``bangumi_id=None`` and showed up as +orphans regardless of the ``track_orphans`` setting. +""" + +from unittest.mock import AsyncMock, patch + +from module.database import Database +from module.downloader import AddResult +from module.manager.collector import SeasonCollector +from module.models import Torrent +from test.factories import make_bangumi + + +def _async_ctx(inner): + ctx = AsyncMock() + ctx.__aenter__ = AsyncMock(return_value=inner) + ctx.__aexit__ = AsyncMock(return_value=False) + return ctx + + +def _req_with_torrents(torrents): + req = AsyncMock() + req.get_torrents = AsyncMock(return_value=torrents) + return _async_ctx(req) + + +def _make_torrents(): + return [ + Torrent( + name="[TestGroup] Test Anime Raw - 01 [1080p].mkv", + url="https://example.com/ep1.torrent", + ), + Torrent( + name="[TestGroup] Test Anime Raw - 02 [1080p].mkv", + url="https://example.com/ep2.torrent", + ), + ] + + +class TestCollectSeason: + async def test_torrents_persisted_with_bangumi_id(self): + bangumi = make_bangumi(filter="") + torrents = _make_torrents() + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + collector = SeasonCollector(client) + + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(torrents), + ): + resp = await collector.collect_season(bangumi, "https://example.com/rss") + + assert resp.status is True + async with Database() as db: + stored = await db.torrent.search_all() + assert len(stored) == 2 + assert all(t.bangumi_id == bangumi.id for t in stored) + assert await db.torrent.count_orphans() == 0 + + async def test_duplicate_resolution_preserves_existing_row_fields(self): + """解析到已存在行时不能用刚解析的默认值覆盖用户调好的配置。""" + existing = make_bangumi(filter="", episode_offset=12) + async with Database() as db: + assert await db.bangumi.add(existing) + + payload = make_bangumi(filter="", episode_offset=0) # 同 key,默认 offset + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + collector = SeasonCollector(client) + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ): + resp = await collector.collect_season(payload, "https://example.com/rss") + + assert resp.status is True + async with Database() as db: + row = await db.bangumi.search_id(existing.id) + assert row is not None + assert row.episode_offset == 12 + assert row.eps_collect is True + + async def test_failed_collect_leaves_no_phantom_bangumi(self): + """下载器投递失败时不能留下幽灵订阅规则。""" + bangumi = make_bangumi(filter="") + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.FAILED) + collector = SeasonCollector(client) + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ): + resp = await collector.collect_season(bangumi, "https://example.com/rss") + + assert resp.status is False + async with Database() as db: + assert await db.bangumi.search_all() == [] + + async def test_add_torrent_called_with_persisted_bangumi(self): + """add_torrent 打 ab: 标签依赖调用时 bangumi.id 已存在。""" + bangumi = make_bangumi(filter="") + client = AsyncMock() + client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + collector = SeasonCollector(client) + + with patch( + "module.manager.collector.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ): + await collector.collect_season(bangumi, "https://example.com/rss") + + passed_bangumi = client.add_torrent.call_args.args[1] + assert passed_bangumi.id is not None + + +class TestSubscribeSeason: + async def test_existing_bangumi_links_torrents_to_existing_id(self): + """重复订阅已存在的番剧(add 返回 False)时,种子必须挂到 + 已存在行的 id 上,不能被记成孤儿。""" + existing = make_bangumi(filter="") + async with Database() as db: + assert await db.bangumi.add(existing) + + data = make_bangumi(filter="") # 同 title_raw+group_name -> 精确重复 + downloader_client = AsyncMock() + downloader_client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + with ( + patch( + "module.rss.engine.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ), + patch( + "module.rss.engine.DownloadClient", + return_value=_async_ctx(downloader_client), + ), + ): + result = await SeasonCollector.subscribe_season(data) + + assert result.status is True + async with Database() as db: + stored = await db.torrent.search_all() + assert len(stored) == 2 + assert all(t.bangumi_id == existing.id for t in stored) + assert await db.torrent.count_orphans() == 0 + + async def test_deleted_duplicate_is_restored_and_linked(self): + """重新订阅已禁用的规则时重新启用该行并把种子挂上去——否则种子 + 要么变孤儿、要么挂到对下游查询不可见的行上。""" + existing = make_bangumi(filter="", deleted=True) + async with Database() as db: + assert await db.bangumi.add(existing) + + data = make_bangumi(filter="") + downloader_client = AsyncMock() + downloader_client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + with ( + patch( + "module.rss.engine.RequestContent", + return_value=_req_with_torrents(_make_torrents()), + ), + patch( + "module.rss.engine.DownloadClient", + return_value=_async_ctx(downloader_client), + ), + ): + await SeasonCollector.subscribe_season(data) + + async with Database() as db: + row = await db.bangumi.search_id(existing.id) + assert row is not None + assert row.deleted is False + stored = await db.torrent.search_all() + assert stored and all(t.bangumi_id == existing.id for t in stored) + + async def test_torrents_persisted_with_bangumi_id(self): + data = make_bangumi(filter="") + torrents = _make_torrents() + + downloader_client = AsyncMock() + downloader_client.add_torrent = AsyncMock(return_value=AddResult.ADDED) + with ( + patch( + "module.rss.engine.RequestContent", + return_value=_req_with_torrents(torrents), + ), + patch( + "module.rss.engine.DownloadClient", + return_value=_async_ctx(downloader_client), + ), + ): + result = await SeasonCollector.subscribe_season(data) + + assert result.status is True + assert data.id is not None + async with Database() as db: + stored = await db.torrent.search_all() + assert len(stored) == 2 + assert all(t.bangumi_id == data.id for t in stored) + assert await db.torrent.count_orphans() == 0 diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index 80d31784..7aee0dee 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -616,6 +616,28 @@ class TestDeliverText: assert data["title"] == "标题" assert data["desp"] == "正文" + def test_server_chan_legacy_key_uses_sctapi_endpoint(self): + config = ProviderConfig(type="server-chan", enabled=True, token="SCT123abc") + provider = ServerChanProvider(config) + + assert provider.notification_url == "https://sctapi.ftqq.com/SCT123abc.send" + + def test_server_chan_sc3_key_uses_ft07_endpoint(self): + """Server酱³ 的 sendkey 以 sctpt 开头,走 push.ft07.com 端点 (#904)。""" + config = ProviderConfig(type="server-chan", enabled=True, token="sctp456tXYZ") + provider = ServerChanProvider(config) + + assert ( + provider.notification_url + == "https://456.push.ft07.com/send/sctp456tXYZ.send" + ) + + def test_server_chan_malformed_sctp_key_falls_back_to_legacy(self): + config = ProviderConfig(type="server-chan", enabled=True, token="sctpXYZ") + provider = ServerChanProvider(config) + + assert provider.notification_url == "https://sctapi.ftqq.com/sctpXYZ.send" + async def test_telegram_deliver_text_posts_combined_text(self): config = ProviderConfig( type="telegram", enabled=True, token="test_token", chat_id="12345" diff --git a/backend/src/test/test_path.py b/backend/src/test/test_path.py index 0fa198c6..07b69f62 100644 --- a/backend/src/test/test_path.py +++ b/backend/src/test/test_path.py @@ -9,9 +9,42 @@ from module.downloader.path import ( is_ep, path_to_bangumi, rule_name, + sanitize_path_fragment, ) from test.factories import make_bangumi +# --------------------------------------------------------------------------- +# sanitize_path_fragment +# --------------------------------------------------------------------------- + + +class TestSanitizePathFragment: + def test_replaces_reserved_characters(self): + assert sanitize_path_fragment('AC:D"E/F\\G|H?I*J') == "A B C D E F G H I J" + + def test_collapses_whitespace_and_strips_trailing_dots(self): + assert sanitize_path_fragment("Name ...") == "Name" + + def test_preserves_cjk_and_brackets(self): + assert ( + sanitize_path_fragment("[Sub] 孤独摇滚!(2022)") == "[Sub] 孤独摇滚!(2022)" + ) + + def test_idempotent(self): + once = sanitize_path_fragment("Fate/Zero: Part?2") + assert sanitize_path_fragment(once) == once + + def test_all_reserved_title_falls_back_in_save_path(self): + """全保留字符的标题不能让保存路径坍缩出空目录层。""" + bangumi = make_bangumi(official_title="??", year=None) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert "//" not in result + assert "Unknown Bangumi" in result + + # --------------------------------------------------------------------------- # gen_save_path # --------------------------------------------------------------------------- @@ -28,6 +61,16 @@ class TestGenSavePath: assert "My Anime (2024)" in result assert "Season 2" in result + def test_reserved_characters_sanitized_in_folder(self): + """标题里的保留字符不能把保存路径拆成多级目录 (#721)。""" + bangumi = make_bangumi(official_title="Fate/Zero: Part?2", year="2024") + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert "Fate Zero Part 2 (2024)" in result + assert result.count("/") == 4 # /downloads/Bangumi//Season 1 + def test_without_year(self): """Save path omits year parentheses when year is None.""" bangumi = make_bangumi(official_title="My Anime", year=None, season=1) diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index e71f2127..32e5af57 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -31,6 +31,19 @@ class TestGenPath: result = Renamer.gen_path(ep, "Bangumi Name", method="advance") assert result == "Bangumi Name S02E12.mkv" + def test_title_reserved_characters_kept_verbatim(self): + """既有磁盘文件解析出的标题原样保留:追加清洗会让含 ":" 等字符的 + 既有做种库在升级后被整库批量重命名。""" + ep = EpisodeFile( + media_path="old.mkv", + title="Re:Zero Season 2", + season=1, + episode=5, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "Re:Zero Season 2 S01E05.mkv" + def test_none_method(self): """none method: returns original media_path unchanged.""" ep = EpisodeFile( @@ -120,6 +133,50 @@ class TestGenPath: # --------------------------------------------------------------------------- +class TestGenPathFractionalEpisode: + """总集篇等半集(12.5)必须保留小数,否则会覆盖同季的整数集 (#667)。""" + + def test_pn_keeps_fraction(self): + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=12.5, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "My Anime S01E12.5.mkv" + + def test_fraction_below_ten_zero_padded(self): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=9.5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "My Anime S01E09.5.mkv" + + def test_offset_applies_to_fraction(self): + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=13.5, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn", episode_offset=-1) + assert result == "My Anime S01E12.5.mkv" + + def test_integer_valued_float_formats_as_int(self): + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=12.0, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi Name", method="pn") + assert result == "My Anime S01E12.mkv" + + class TestGenPathMovie: """episode_type='movie' bypasses SxxExx numbering entirely, producing a 'Title (Year).ext' layout instead.""" @@ -320,6 +377,141 @@ class TestRenameFile: assert result.season == 1 assert result.episode == 5 + async def test_fractional_episode_notification_keeps_fraction(self, renamer): + """半集重命名后通知里的集数保留小数 (#667)。""" + ep = EpisodeFile( + media_path="old.mkv", + title="My Anime", + season=1, + episode=12.5, + suffix=".mkv", + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + result = await renamer.rename_file( + torrent_name="[Sub] My Anime - 12.5.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + assert result is not None + assert result.episode == 12.5 + + async def test_successful_rename_adds_renamed_tag(self, renamer): + """重命名成功后给种子打 ab:renamed 标签,供外部脚本识别 (#147)。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_conformant_file_adds_renamed_tag(self, renamer): + """文件名已符合目标命名(如重启后再扫)也视为处理完成,补标签。""" + ep = EpisodeFile( + media_path="My Anime S01E05.mkv", + title="My Anime", + season=1, + episode=5, + suffix=".mkv", + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="My Anime S01E05.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + renamer.client.client.torrents_rename_file.assert_not_awaited() + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_existing_renamed_tag_not_re_added(self, renamer): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + existing_tags="ab:42, ab:renamed", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_tagging_failure_does_not_break_rename(self, renamer): + """打标 API 抛异常时通知仍要返回——打标绝不能影响主流程。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + renamer.client.client.add_tag.side_effect = RuntimeError("qB down") + result = await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash-tag-fail", + ) + + assert result is not None + assert result.episode == 5 + + async def test_failed_rename_does_not_tag(self, renamer): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = False + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash-fail-tag", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_none_method_does_not_tag(self, renamer): + """none 方法不做重命名,也不应打处理完成标签。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="none", + season=1, + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_parse_fails_no_remove(self, renamer): """When parser returns None and remove_bad_torrent=False, returns None.""" with patch.object(renamer._parser, "torrent_parser", return_value=None): @@ -429,6 +621,75 @@ class TestRenameCollection: assert renamer.client.client.torrents_rename_file.call_count == 3 + async def test_collection_success_adds_renamed_tag(self, renamer): + """合集全部重命名成功后打 ab:renamed 标签 (#147)。""" + media_list = ["ep01.mkv", "ep02.mkv"] + + def mock_parser(torrent_path, season, **kwargs): + ep_num = int(torrent_path.replace("ep", "").replace(".mkv", "")) + return EpisodeFile( + media_path=torrent_path, + title="Anime", + season=season, + episode=ep_num, + suffix=".mkv", + ) + + with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_collection_parse_failure_does_not_tag(self, renamer): + """有媒体文件解析失败(不会被重命名)时不能打处理完成标签。""" + media_list = ["ep01.mkv", "garbage-name.mkv"] + + def mock_parser(torrent_path, season, **kwargs): + if "garbage" in torrent_path: + return None + return EpisodeFile( + media_path=torrent_path, + title="Anime", + season=season, + episode=1, + suffix=".mkv", + ) + + with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash-parse-fail", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_collection_failure_does_not_tag(self, renamer): + ep = EpisodeFile( + media_path="ep01.mkv", title="Anime", season=1, episode=1, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = False + await renamer.rename_collection( + media_list=["ep01.mkv"], + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash-collection-fail", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_skips_deep_files(self, renamer): """Files deeper than 2 levels are skipped (not is_ep).""" media_list = ["ep01.mkv", "extras/bonus/ep_sp.mkv"] diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 6a0baeed..dfa12c90 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -323,9 +323,7 @@ class TestRefreshRss: assert len(all_torrents) == 1 assert all_torrents[0].bangumi_id is not None - async def test_track_orphans_on_persists_unmatched( - self, rss_engine, test_settings - ): + async def test_track_orphans_on_persists_unmatched(self, rss_engine, test_settings): """默认开启时行为不变:未匹配种子以 bangumi_id NULL 入库。""" rss_item = make_rss_item(enabled=True) await rss_engine.db.rss.add(rss_item) @@ -600,6 +598,38 @@ class TestRefreshRssRetry: class TestDownloadBangumi: + async def test_success_persists_torrents_with_bangumi_id(self, rss_engine): + """成功下载后种子行必须关联 bangumi_id,否则会被记成孤儿。""" + bangumi = make_bangumi( + official_title="Mushoku Tensei", + rss_link="https://example.com/rss", + filter="", + ) + await rss_engine.db.bangumi.add(bangumi) + torrent = Torrent( + name="[Sub] Mushoku Tensei - 12 [1080p].mkv", + url="https://example.com/ep12.torrent", + ) + with ( + patch("module.rss.engine.RequestContent") as MockReq, + patch("module.rss.engine.DownloadClient") as MockClient, + ): + req = AsyncMock() + req.get_torrents = AsyncMock(return_value=[torrent]) + 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) + + resp = await rss_engine.download_bangumi(bangumi) + + assert resp.status is True + stored = await rss_engine.db.torrent.search_all() + assert len(stored) == 1 + assert stored[0].bangumi_id == bangumi.id + async def test_failed_add_returns_failure_and_does_not_persist(self, rss_engine): bangumi = make_bangumi( official_title="Mushoku Tensei", diff --git a/backend/src/test/test_tmdb.py b/backend/src/test/test_tmdb.py index 7cd422cc..0b4d9ebd 100644 --- a/backend/src/test/test_tmdb.py +++ b/backend/src/test/test_tmdb.py @@ -148,6 +148,37 @@ async def test_tmdb_parser_movie_search_no_results_returns_none(mocker): assert tmdb_info is None +def test_search_url_uses_custom_api_key(): + """用户自配的 TMDB API key 优先于内置共享 key (#975)。""" + from unittest.mock import patch + + with patch.object(tmdb_parser_module, "settings") as mock_settings: + mock_settings.network.tmdb_base_url = "https://api.themoviedb.org" + mock_settings.network.tmdb_api_key = "customkey123" + url = tmdb_parser_module.search_url("test") + + assert "api_key=customkey123" in url + + +def test_search_url_falls_back_to_builtin_key(): + from unittest.mock import patch + + from module.conf import TMDB_API + + with patch.object(tmdb_parser_module, "settings") as mock_settings: + mock_settings.network.tmdb_base_url = "https://api.themoviedb.org" + mock_settings.network.tmdb_api_key = "" + url = tmdb_parser_module.search_url("test") + + assert f"api_key={TMDB_API}" in url + + +def test_network_config_tmdb_api_key_defaults_empty(): + from module.models.config import Network + + assert Network().tmdb_api_key == "" + + def test_reset_cache_clears_tmdb_cache(): """reset_cache() must drop all cached lookups (called on config reload so a changed tmdb_base_url stops serving results from the old endpoint).""" diff --git a/webui/src/components/setting/config-network.vue b/webui/src/components/setting/config-network.vue new file mode 100644 index 00000000..8f6c75a5 --- /dev/null +++ b/webui/src/components/setting/config-network.vue @@ -0,0 +1,52 @@ + + + diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 51ffc195..3aa2a8de 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -118,6 +118,12 @@ "type": "Proxy Type", "username": "Username" }, + "network_set": { + "bgm_base_url": "Bangumi API URL", + "title": "Network Setting", + "tmdb_api_key": "TMDB API Key (optional)", + "tmdb_base_url": "TMDB API URL" + }, "security_set": { "title": "Security", "hint": "Login whitelist: empty = allow all IPs. MCP whitelist: empty = deny all access.", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 87cc500e..edf16870 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -118,6 +118,12 @@ "type": "类型", "username": "用户名" }, + "network_set": { + "bgm_base_url": "Bangumi API 地址", + "title": "网络设置", + "tmdb_api_key": "TMDB API Key(可选)", + "tmdb_base_url": "TMDB API 地址" + }, "security_set": { "title": "安全设置", "hint": "登录白名单:为空则允许所有 IP。MCP 白名单:为空则拒绝所有访问。", diff --git a/webui/src/pages/index/config.vue b/webui/src/pages/index/config.vue index 30a9b4d8..d183562d 100644 --- a/webui/src/pages/index/config.vue +++ b/webui/src/pages/index/config.vue @@ -8,6 +8,7 @@ import ConfigDownload from '@/components/setting/config-download.vue'; import ConfigManage from '@/components/setting/config-manage.vue'; import ConfigNotification from '@/components/setting/config-notification.vue'; import ConfigProxy from '@/components/setting/config-proxy.vue'; +import ConfigNetwork from '@/components/setting/config-network.vue'; import ConfigSearchProvider from '@/components/setting/config-search-provider.vue'; import ConfigPlayer from '@/components/setting/config-player.vue'; import ConfigLlm from '@/components/setting/config-llm.vue'; @@ -106,6 +107,13 @@ const sections: ConfigSection[] = [ groups: ['proxy'], keywords: ['proxy', 'http', 'socks5', 'host', 'port'], }, + { + id: 'network', + titleKey: 'config.network_set.title', + component: ConfigNetwork, + groups: ['network'], + keywords: ['network', 'tmdb', 'bangumi', 'bgm', 'mirror', 'api', 'key'], + }, { id: 'search-provider', titleKey: 'config.search_provider_set.title', diff --git a/webui/src/test/mocks/api.ts b/webui/src/test/mocks/api.ts index a3f4763d..7840eb54 100644 --- a/webui/src/test/mocks/api.ts +++ b/webui/src/test/mocks/api.ts @@ -151,6 +151,11 @@ export const mockConfig = { username: '', password: '', }, + network: { + tmdb_base_url: 'https://api.themoviedb.org', + tmdb_api_key: '', + bgm_base_url: 'https://api.bgm.tv', + }, notification: { enable: false, type: 'telegram', diff --git a/webui/types/config.ts b/webui/types/config.ts index e1ff31d9..7f6ebcc6 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -72,6 +72,13 @@ export interface Proxy { username: string; password: string; } +/** 外部数据源地址与凭据(TMDB / bgm.tv):镜像与自配 key */ +export interface Network { + tmdb_base_url: string; + /** 留空回退到内置共享 key */ + tmdb_api_key: string; + bgm_base_url: string; +} /** Notification provider configuration */ export interface NotificationProviderConfig { type: TupleToUnion; @@ -157,6 +164,7 @@ export interface Config { bangumi_manage: BangumiManage; log: Log; proxy: Proxy; + network: Network; notification: Notification; llm: LLM; /** @deprecated 已被 llm 段取代 */ @@ -203,6 +211,12 @@ export const initConfig: Config = { username: '', password: '', }, + network: { + // 与 backend/src/module/models/config.py 的 Network 默认值保持一致 + tmdb_base_url: 'https://api.themoviedb.org', + tmdb_api_key: '', + bgm_base_url: 'https://api.bgm.tv', + }, notification: { enable: false, providers: [], diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts index c814b169..84581e3c 100644 --- a/webui/types/dts/components.d.ts +++ b/webui/types/dts/components.d.ts @@ -67,6 +67,7 @@ declare module '@vue/runtime-core' { ConfigDownload: typeof import('./../../src/components/setting/config-download.vue')['default'] ConfigLlm: typeof import('./../../src/components/setting/config-llm.vue')['default'] ConfigManage: typeof import('./../../src/components/setting/config-manage.vue')['default'] + ConfigNetwork: typeof import('./../../src/components/setting/config-network.vue')['default'] ConfigNormal: typeof import('./../../src/components/setting/config-normal.vue')['default'] ConfigNotification: typeof import('./../../src/components/setting/config-notification.vue')['default'] ConfigParser: typeof import('./../../src/components/setting/config-parser.vue')['default']