Merge pull request #1059 from EstrellaXD/3.3-dev

3.3.1
This commit is contained in:
Estrella Pan
2026-07-07 19:02:17 +02:00
committed by GitHub
26 changed files with 935 additions and 62 deletions

View File

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

View File

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

View File

@@ -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:<id> link tag。必须精确匹配数字 id:
# ab:renamed(处理完成标记)等同前缀标签不代表已关联番剧
if re.search(r"ab:\d+", tags):
continue
# First try by torrent name, then fall back to save_path

View File

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

View File

@@ -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 子目录

View File

@@ -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:<id>
标签与种子行的 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:<id> 标签,
# 种子行也要用它关联 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:<id> 标签和种子行的 bangumi_id 关联都依赖它
await _ensure_bangumi_id(db, data)
return await engine.download_bangumi(data)
async def eps_complete():

View File

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

View File

@@ -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="番剧海报路径")

View File

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

View File

@@ -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 形如 sctp<uid>t<key>,推送端点与 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."""

View File

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

View File

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

View File

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

View File

@@ -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:<id> 标签依赖调用时 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

View File

@@ -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 以 sctp<uid>t 开头,走 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"

View File

@@ -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('A<B>C: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/<folder>/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)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import type { Network } from '#/config';
import type { SettingItem } from '#/components';
const { t } = useMyI18n();
const { getSettingGroup } = useConfigStore();
const network = getSettingGroup('network');
const items: SettingItem<Network>[] = [
{
configKey: 'tmdb_base_url',
label: () => t('config.network_set.tmdb_base_url'),
type: 'input',
prop: {
type: 'text',
placeholder: 'https://api.themoviedb.org',
},
},
{
configKey: 'tmdb_api_key',
label: () => t('config.network_set.tmdb_api_key'),
type: 'input',
prop: {
type: 'password',
autocomplete: 'off',
},
},
{
configKey: 'bgm_base_url',
label: () => t('config.network_set.bgm_base_url'),
type: 'input',
prop: {
type: 'text',
placeholder: 'https://api.bgm.tv',
},
},
];
</script>
<template>
<ab-fold-panel :title="$t('config.network_set.title')">
<div space-y-8>
<ab-setting
v-for="i in items"
:key="i.configKey"
v-bind="i"
v-model:data="network[i.configKey]"
></ab-setting>
</div>
</ab-fold-panel>
</template>

View File

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

View File

@@ -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 白名单:为空则拒绝所有访问。",

View File

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

View File

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

View File

@@ -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<NotificationType>;
@@ -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: [],

View File

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