mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-03 22:24:42 +08:00
@@ -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 条目):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 子目录
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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="番剧海报路径")
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
203
backend/src/test/test_collector.py
Normal file
203
backend/src/test/test_collector.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
52
webui/src/components/setting/config-network.vue
Normal file
52
webui/src/components/setting/config-network.vue
Normal 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>
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 白名单:为空则拒绝所有访问。",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
1
webui/types/dts/components.d.ts
vendored
1
webui/types/dts/components.d.ts
vendored
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user