feat(4.0-P4): 重命名经 rename_strategy 解析,media_files / conflict_policy 改为 core Provider,发布整理事件

- 重命名方式按 rename_method 从注册表解析;未登记时记录一次日志并按 none 处理,
  不再给未改名的种子打 ab:renamed 标签。字幕改用 RenameInput.kind,不再拼 subtitle_*。
- 插件策略经 runner 同步调用(异常与无效返回值计入熔断);策略抛出 RenameSkipped
  或失败时文件保留原名、种子不打标签,每个种子同一原因只发一条 rename_skipped 通知。
- check_files 经 media_files 分类;版本冲突经 conflict_policy 判定,core 实现与
  3.x 的原因文案一致。
- 每轮结束发布 file.renamed;种子整理完成(含 none)发布 torrent.organized。
- pn / advance 暂以 core 登记,下一步移入内置插件 rename。
This commit is contained in:
Estrella Pan
2026-10-08 10:32:15 +02:00
parent cf8135874d
commit e0b8939ec5
13 changed files with 732 additions and 150 deletions

View File

@@ -119,5 +119,6 @@ async def list_plugin_providers():
points.NOTIFIER,
points.SEARCH_SITE,
points.METADATA_PROVIDER,
points.RENAME_STRATEGY,
)
}

View File

@@ -3,9 +3,12 @@ import re
from os import PathLike
from pathlib import PureWindowsPath
from ab_sdk import points
from ab_sdk.rename import CORE_ID, MediaFiles, MediaKind
from module.conf import PLATFORM, settings
from module.models import Bangumi, BangumiUpdate
from module.models.movie import Movie, MovieUpdate
from module.plugin import host as plugin_host
logger = logging.getLogger(__name__)
@@ -38,15 +41,30 @@ def sanitize_path_fragment(name: str) -> str:
return cleaned.rstrip(". ")
class SuffixMediaFiles:
"""宿主自带的 ``media_files`` Provider:按扩展名区分正片与字幕。"""
def classify(self, path: str) -> MediaKind:
suffix = Path(path).suffix.lower()
if suffix in _MEDIA_SUFFIXES:
return "media"
if suffix in _SUBTITLE_SUFFIXES:
return "subtitle"
return "ignore"
def check_files(files: list[dict]):
media_files: MediaFiles = (
plugin_host.get_registry().providers(points.MEDIA_FILES)[CORE_ID].factory()
)
media_list = []
subtitle_list = []
for f in files:
file_name = f["name"]
suffix = Path(file_name).suffix.lower()
if suffix in _MEDIA_SUFFIXES:
kind = media_files.classify(file_name)
if kind == "media":
media_list.append(file_name)
elif suffix in _SUBTITLE_SUFFIXES:
elif kind == "subtitle":
subtitle_list.append(file_name)
return media_list, subtitle_list

View File

@@ -8,57 +8,35 @@
与 gen_save_path 的文件夹命名保持一致 (Title (Year))。
"""
from collections.abc import Callable
from ab_sdk.rename import RenameInput, pad
from module.models import EpisodeFile, SubtitleFile
Strategy = Callable[[EpisodeFile | SubtitleFile, str, str, str], str]
NO_RENAME = "none"
def _subtitle(file_info: EpisodeFile | SubtitleFile) -> SubtitleFile:
assert isinstance(
file_info, SubtitleFile
), "subtitle methods require a SubtitleFile"
return file_info
class NoRename:
"""``none``:保留原路径。"""
def target_name(self, f: RenameInput) -> str:
return f.media_path
def pn(
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, season: str, episode: str
) -> str:
if file_info.episode_type == "movie":
return f"{file_info.title}{file_info.suffix}"
return f"{file_info.title} S{season}E{episode}{file_info.suffix}"
def standard_name(base: str, f: RenameInput) -> str:
"""``{base} SxxEyy[.语言].ext``;电影为 ``{base}[.语言].ext``。"""
language = f".{f.language}" if f.kind == "subtitle" else ""
if f.episode_type == "movie":
return f"{base}{language}{f.suffix}"
return f"{base} S{pad(f.season)}E{pad(f.episode)}{language}{f.suffix}"
def advance(
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, season: str, episode: str
) -> str:
if file_info.episode_type == "movie":
return f"{bangumi_name}{file_info.suffix}"
return f"{bangumi_name} S{season}E{episode}{file_info.suffix}"
class PnRename:
"""``pn``:以文件名解析出的标题命名。"""
def target_name(self, f: RenameInput) -> str:
return standard_name(f.title, f)
def subtitle_pn(
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, season: str, episode: str
) -> str:
sub = _subtitle(file_info)
if sub.episode_type == "movie":
return f"{sub.title}.{sub.language}{sub.suffix}"
return f"{sub.title} S{season}E{episode}.{sub.language}{sub.suffix}"
class AdvanceRename:
"""``advance``:以番剧文件夹名命名。"""
def subtitle_advance(
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, season: str, episode: str
) -> str:
sub = _subtitle(file_info)
if sub.episode_type == "movie":
return f"{bangumi_name}.{sub.language}{sub.suffix}"
return f"{bangumi_name} S{season}E{episode}.{sub.language}{sub.suffix}"
STRATEGIES: dict[str, Strategy] = {
"pn": pn,
"advance": advance,
"subtitle_pn": subtitle_pn,
"subtitle_advance": subtitle_advance,
}
def target_name(self, f: RenameInput) -> str:
return standard_name(f.bangumi_name, f)

View File

@@ -2,7 +2,19 @@ import asyncio
import logging
from datetime import datetime, timedelta, timezone
from pathlib import PurePath
from typing import Any
from ab_sdk import points
from ab_sdk.events import FileRenamed, OrganizedFile, TorrentOrganized
from ab_sdk.rename import (
CORE_ID,
ConflictPolicy,
ConflictRequest,
FileKind,
RenameInput,
RenameSkipped,
RevisionTask,
)
from module.conf import settings
from module.database import Database
from module.database.bangumi import (
@@ -13,11 +25,17 @@ from module.database.bangumi import (
from module.downloader import DownloadClient, RenameOutcome, RenameResult
from module.downloader.path import check_files, is_ep, path_to_bangumi
from module.models import EpisodeFile, Notification, SubtitleFile
from module.notification import RenameConflictEvent
from module.notification import RenameSkippedEvent, SystemEvent
from module.parser import TitleParser
from module.plugin import host as plugin_host
from module.plugin.registry import ProviderEntry
from .rename_strategies import STRATEGIES
from .revision_policy import is_strict_upgrade, parse_revision_identity
from .rename_strategies import NO_RENAME
from .revision_policy import (
is_strict_upgrade,
parse_revision_identity,
revision_snapshot,
)
from .revision_saga import (
_RENAMED_TAG,
MediaRenameReport,
@@ -27,20 +45,89 @@ from .revision_saga import (
logger = logging.getLogger(__name__)
# 下载器 id:多下载器(P2.5)之前只有一个
_DOWNLOADER_ID = "default"
# 已记录过「未登记」日志的重命名方式(每个进程只记一次,重新登记后清除)
_missing_methods: set[str] = set()
# 已发布过的 TorrentOrganized(种子 hash → 最终文件)。未打 ab:renamed 标签的
# 种子(重命名方式为 none)每轮都会重新处理,文件不变时不重复发布。
# ponytail: 进程内字典,重启后重发一次(事件约定为至少一次);条目随种子数增长
_organized_published: dict[str, tuple[OrganizedFile, ...]] = {}
# 已发送过「文件未重命名」通知的 (种子 hash, 原因)
_skip_notified: set[tuple[str, str]] = set()
def _rename_strategy(method: str) -> ProviderEntry:
"""按 rename_method 取重命名策略;未登记(如 rename 插件未启用)时记录一次
日志并按 none 处理。"""
strategies = plugin_host.get_registry().providers(points.RENAME_STRATEGY)
entry = strategies.get(method)
if entry is not None:
_missing_methods.discard(method)
return entry
if method not in _missing_methods:
_missing_methods.add(method)
logger.warning(
"[Renamer] 重命名方式 %s 未登记(提供它的插件未启用?),文件保留原名",
method,
)
return strategies[NO_RENAME]
def _valid_name(result: Any) -> bool:
return isinstance(result, RenameSkipped) or (
isinstance(result, str) and result != ""
)
def _target_name(entry: ProviderEntry, f: RenameInput) -> str:
"""调用重命名策略。策略给不出合法名字时抛出 RenameSkipped。
插件策略经 runner 调用:异常与无效返回值计入熔断;策略主动抛出的
RenameSkipped(输入或用户配置有问题)不计入。
"""
if entry.plugin_id == plugin_host.CORE:
return entry.factory().target_name(f)
def call() -> str | RenameSkipped:
try:
return entry.factory().target_name(f)
except RenameSkipped as e:
return e
runner = plugin_host.get_runner()
if runner is not None:
ok, result = runner.call_provider_sync(
entry.plugin_id, points.RENAME_STRATEGY, call, check=_valid_name
)
else:
try:
result = call()
ok = _valid_name(result)
except Exception as e:
logger.warning("[Plugin:%s] 重命名策略失败:%s", entry.plugin_id, e)
ok = False
if isinstance(result, RenameSkipped):
raise result
if not ok or result is None:
raise RenameSkipped(f"重命名方式 {entry.id} 执行失败,详见日志")
return result
def _downloader_path(save_path: str, name: str) -> str:
"""下载器视角的绝对路径,统一以 "/" 分隔。"""
relative = name.replace("\\", "/")
return f"{normalize_save_path(save_path)}/{relative}"
class Renamer(RevisionSaga):
def __init__(self, client: DownloadClient):
self.client = client
self._parser = TitleParser()
self.events: list[RenameConflictEvent] = []
@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)
self.events: list[SystemEvent] = []
# 本轮各种子的实际重命名 (原路径, 新路径, 类别) 与被跳过的原因
self._moves: dict[str, list[tuple[str, str, FileKind]]] = {}
self._skipped: dict[str, str] = {}
@staticmethod
def gen_path(
@@ -50,24 +137,31 @@ class Renamer(RevisionSaga):
episode_offset: int = 0,
season_offset: int = 0, # Kept for API compatibility, but no longer used
) -> str:
"""按重命名方式生成种子内的目标路径。
策略给不出合法名字时抛出 RenameSkipped,调用方保留原文件名。
"""
# Season comes from the folder name which already includes the offset
# (folder is now "Season {season + season_offset}")
# 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
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
strategy = STRATEGIES.get(method)
if strategy is None:
logger.error(f"Unknown rename method: {method}")
return file_info.media_path
return strategy(file_info, bangumi_name, str(season), episode)
f = RenameInput(
kind="subtitle" if isinstance(file_info, SubtitleFile) else "media",
media_path=file_info.media_path,
title=file_info.title,
bangumi_name=bangumi_name,
season=file_info.season,
episode=Renamer._adjust_episode(file_info.episode, episode_offset),
suffix=file_info.suffix,
episode_type=file_info.episode_type,
language=(
file_info.language if isinstance(file_info, SubtitleFile) else ""
),
group=file_info.group,
)
return _target_name(_rename_strategy(method), f)
async def rename_file(
self,
@@ -94,7 +188,7 @@ class Renamer(RevisionSaga):
season_offset=season_offset,
episode_type=episode_type,
)
if report.result.succeeded and method != "none":
if report.result.succeeded and _rename_strategy(method).id != NO_RENAME:
await self._mark_renamed(_hash, existing_tags)
return report.notification
@@ -143,16 +237,19 @@ class Renamer(RevisionSaga):
season_offset: int = 0,
episode_type: str = "episode",
) -> MediaRenameReport:
prepared = self._prepare_media_rename(
torrent_name=torrent_name,
media_path=media_path,
bangumi_name=bangumi_name,
method=method,
season=season,
episode_offset=episode_offset,
season_offset=season_offset,
episode_type=episode_type,
)
try:
prepared = self._prepare_media_rename(
torrent_name=torrent_name,
media_path=media_path,
bangumi_name=bangumi_name,
method=method,
season=season,
episode_offset=episode_offset,
season_offset=season_offset,
episode_type=episode_type,
)
except RenameSkipped as e:
return self._skip(_hash, media_path, e)
if prepared is None:
logger.warning("%s parse failed", media_path)
if settings.bangumi_manage.remove_bad_torrent:
@@ -183,6 +280,14 @@ class Renamer(RevisionSaga):
stem = stem[len(prefix) :]
return f"{base} - {stem}{suffix}"
def _skip(self, _hash: str, path: str, error: RenameSkipped) -> MediaRenameReport:
"""策略给不出名字:保留原文件名,记录原因(本轮结束时通知、不打标签)。"""
logger.warning("[Renamer] %s 保留原名:%s", path, error)
self._skipped.setdefault(_hash, str(error))
return MediaRenameReport(
result=RenameResult(RenameOutcome.RETRYABLE_FAILURE, detail=str(error))
)
async def rename_collection(
self,
media_list: list[str],
@@ -218,13 +323,18 @@ class Renamer(RevisionSaga):
episode_type=episode_type,
)
if ep:
new_path = self.gen_path(
ep,
bangumi_name,
method=method,
episode_offset=episode_offset,
season_offset=season_offset,
)
try:
new_path = self.gen_path(
ep,
bangumi_name,
method=method,
episode_offset=episode_offset,
season_offset=season_offset,
)
except RenameSkipped as e:
self._skip(_hash, media_path, e)
all_renamed = False
continue
if (
movie_primary is not None
and media_path != movie_primary
@@ -261,13 +371,15 @@ class Renamer(RevisionSaga):
old_path=media_path,
new_path=new_path,
)
if result.outcome is RenameOutcome.RENAMED:
self._record_move(_hash, media_path, new_path, "media")
if not result.succeeded:
all_renamed = False
logger.warning(f"{media_path} rename failed")
else:
# 解析失败的媒体文件不会被重命名——不能算处理完成
all_renamed = False
if all_renamed and mark_complete and method != "none":
if all_renamed and mark_complete and _rename_strategy(method).id != NO_RENAME:
await self._mark_renamed(_hash, existing_tags)
return all_renamed
@@ -284,7 +396,6 @@ class Renamer(RevisionSaga):
episode_type: str = "episode",
**kwargs,
):
method = "subtitle_" + method
for subtitle_path in subtitle_list:
sub = self._parser.torrent_parser(
torrent_path=subtitle_path,
@@ -294,13 +405,17 @@ class Renamer(RevisionSaga):
episode_type=episode_type,
)
if sub:
new_path = self.gen_path(
sub,
bangumi_name,
method=method,
episode_offset=episode_offset,
season_offset=season_offset,
)
try:
new_path = self.gen_path(
sub,
bangumi_name,
method=method,
episode_offset=episode_offset,
season_offset=season_offset,
)
except RenameSkipped as e:
self._skip(_hash, subtitle_path, e)
continue
if subtitle_path != new_path:
# Skip verification for subtitles to reduce latency
renamed = await self.client.rename_torrent_file(
@@ -309,6 +424,8 @@ class Renamer(RevisionSaga):
new_path=new_path,
verify=False,
)
if renamed.outcome is RenameOutcome.RENAMED:
self._record_move(_hash, subtitle_path, new_path, "subtitle")
if not renamed:
logger.warning(f"{subtitle_path} rename failed")
@@ -330,16 +447,19 @@ class Renamer(RevisionSaga):
season_offset: int,
episode_type: str,
) -> MediaRenameReport:
prepared = self._prepare_media_rename(
torrent_name=info["name"],
media_path=media_path,
bangumi_name=bangumi_name,
method=method,
season=season,
episode_offset=episode_offset,
season_offset=season_offset,
episode_type=episode_type,
)
try:
prepared = self._prepare_media_rename(
torrent_name=info["name"],
media_path=media_path,
bangumi_name=bangumi_name,
method=method,
season=season,
episode_offset=episode_offset,
season_offset=season_offset,
episode_type=episode_type,
)
except RenameSkipped as e:
return self._skip(info["hash"], media_path, e)
if prepared is None:
logger.warning("%s parse failed", media_path)
if settings.bangumi_manage.remove_bad_torrent:
@@ -439,21 +559,42 @@ class Renamer(RevisionSaga):
)
if owners:
owner = owners[0] if len(owners) == 1 else None
reason = "canonical path has more than one downloader owner"
can_replace = bool(
owner is not None
decision = self._conflict_policy().decide(
ConflictRequest(
target_path=prepared.target_path,
incoming=RevisionTask(
info["hash"],
info.get("name", ""),
len(files),
revision_snapshot(incoming_identity),
),
owners=tuple(
RevisionTask(
o.info["hash"],
o.info.get("name", ""),
len(o.files),
revision_snapshot(o.identity),
)
for o in owners
),
configured=settings.bangumi_manage.revision_conflict_policy,
strict_upgrade=bool(
owner is not None
and incoming_identity is not None
and owner.identity is not None
and is_strict_upgrade(owner.identity, incoming_identity)
),
)
)
# 替换 saga 只支持「唯一占用者、双方单文件、身份完整」的情形
if (
decision.action == "replace"
and owner is not None
and len(files) == 1
and len(owner.files) == 1
and incoming_identity is not None
and owner.identity is not None
and is_strict_upgrade(owner.identity, incoming_identity)
)
if (
settings.bangumi_manage.revision_conflict_policy == "replace"
and can_replace
):
assert owner is not None
assert incoming_identity is not None
notification = await self._start_replacement(
info=info,
prepared=prepared,
@@ -484,16 +625,7 @@ class Renamer(RevisionSaga):
notification=notification,
)
if len(owners) == 1:
assert owner is not None
if len(files) != 1 or len(owner.files) != 1:
reason = "automatic replacement requires two single-file torrents"
elif incoming_identity is None or owner.identity is None:
reason = "revision identity is incomplete"
elif not is_strict_upgrade(owner.identity, incoming_identity):
reason = "existing and incoming releases are not a strict revision upgrade"
else:
reason = "revision conflict policy is hold"
reason = decision.reason or "automatic replacement is not possible"
await self._persist_conflict(
info=info,
prepared=prepared,
@@ -514,6 +646,75 @@ class Renamer(RevisionSaga):
episode_offset=episode_offset,
)
def _finish_torrent(
self,
info: dict,
media_list: list[str],
subtitle_list: list[str],
method: str,
organized: bool,
) -> None:
"""发布该种子本轮的 file.renamed;整理完成时发布 torrent.organized;
有文件因策略给不出名字而保留原名时改为发一条通知(同一原因只发一次)。"""
torrent_hash = info["hash"]
save_path = info["save_path"]
bangumi_id = self._parse_bangumi_id_from_tags(info.get("tags"))
final: dict[str, str] = {}
for old, new, kind in self._moves.pop(torrent_hash, []):
final[old] = new
plugin_host.publish(
FileRenamed(
bangumi_id=bangumi_id,
old_path=_downloader_path(save_path, old),
new_path=_downloader_path(save_path, new),
file_kind=kind,
downloader_id=_DOWNLOADER_ID,
)
)
reason = self._skipped.pop(torrent_hash, None)
if reason is not None:
if (torrent_hash, reason) not in _skip_notified:
_skip_notified.add((torrent_hash, reason))
self.events.append(
RenameSkippedEvent(
task_id=torrent_hash,
torrent_name=info["name"],
strategy=method,
reason=reason,
)
)
return
if not organized:
return
kinds: tuple[tuple[list[str], FileKind], ...] = (
(media_list, "media"),
(subtitle_list, "subtitle"),
)
files = tuple(
OrganizedFile(_downloader_path(save_path, final.get(name, name)), kind)
for names, kind in kinds
for name in names
)
if _organized_published.get(torrent_hash) == files:
return
_organized_published[torrent_hash] = files
plugin_host.publish(
TorrentOrganized(
torrent_hash=torrent_hash,
bangumi_id=bangumi_id,
files=files,
downloader_id=_DOWNLOADER_ID,
)
)
@staticmethod
def _conflict_policy() -> ConflictPolicy:
return (
plugin_host.get_registry()
.providers(points.CONFLICT_POLICY)[CORE_ID]
.factory()
)
@staticmethod
def _normalize_path(path: str) -> str:
"""Normalize path by removing trailing slashes and standardizing separators."""
@@ -726,7 +927,7 @@ class Renamer(RevisionSaga):
async def rename(self) -> list[Notification]:
logger.debug("Start rename process.")
rename_method = settings.bangumi_manage.rename_method
rename_method = _rename_strategy(settings.bangumi_manage.rename_method).id
pending_infos = await self.client.get_torrent_info()
# Owner counting and Saga recovery must see tasks outside the normal
# Bangumi/completed filter (collections, paused tasks, changed category).
@@ -821,8 +1022,15 @@ class Renamer(RevisionSaga):
await self.rename_subtitles(
subtitle_list=subtitle_list, **kwargs
)
if rename_method != "none":
if rename_method != NO_RENAME and torrent_hash not in self._skipped:
await self._mark_renamed(torrent_hash, info.get("tags"))
self._finish_torrent(
info,
media_list,
subtitle_list,
rename_method,
report.result.succeeded,
)
elif len(media_list) > 1:
logger.info("Start rename collection")
file_sizes = {f["name"]: f.get("size") or 0 for f in files}
@@ -836,9 +1044,12 @@ class Renamer(RevisionSaga):
if collection_complete and subtitle_list:
await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
if collection_complete:
if rename_method != "none":
if rename_method != NO_RENAME and torrent_hash not in self._skipped:
await self._mark_renamed(torrent_hash, info.get("tags"))
await self.client.set_category(torrent_hash, "BangumiCollection")
self._finish_torrent(
info, media_list, subtitle_list, rename_method, collection_complete
)
else:
logger.warning(f"{torrent_name} has no media file")
async with Database() as db:

View File

@@ -7,6 +7,7 @@ import unicodedata
from dataclasses import dataclass
from pathlib import PurePosixPath
from ab_sdk.rename import ConflictDecision, ConflictRequest, Revision
from module.parser.analyser.selector import parse_configured_release_title
from module.parser.analyser.tokenizer import MediaType
from module.parser.release_policy import preference_identity, preference_revision
@@ -91,6 +92,47 @@ def same_release_identity(old: RevisionIdentity, new: RevisionIdentity) -> bool:
)
def revision_snapshot(identity: RevisionIdentity | None) -> Revision | None:
"""交给 conflict_policy 的只读快照。"""
if identity is None:
return None
return Revision(
bangumi_id=identity.bangumi_id,
media_type=identity.media_type.value,
season=identity.season,
episode=identity.episode,
group=identity.group,
resolution=identity.resolution,
revision=identity.revision,
)
class CoreConflictPolicy:
"""宿主自带的 ``conflict_policy``:只在唯一占用者与新种子都是单文件、
且新种子是严格的版本升级时,按用户设置决定是否替换。"""
def decide(self, request: ConflictRequest) -> ConflictDecision:
if len(request.owners) != 1:
return ConflictDecision(
"hold", "canonical path has more than one downloader owner"
)
owner = request.owners[0]
if request.incoming.file_count != 1 or owner.file_count != 1:
return ConflictDecision(
"hold", "automatic replacement requires two single-file torrents"
)
if request.incoming.revision is None or owner.revision is None:
return ConflictDecision("hold", "revision identity is incomplete")
if not request.strict_upgrade:
return ConflictDecision(
"hold",
"existing and incoming releases are not a strict revision upgrade",
)
if request.configured == "replace":
return ConflictDecision("replace")
return ConflictDecision("hold", "revision conflict policy is hold")
def replacement_staged_path(
target_path: str, *, old_task_id: str, old_revision: int
) -> str:

View File

@@ -15,13 +15,14 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy.exc import IntegrityError
from ab_sdk.rename import FileKind
from module.conf import settings
from module.database import Database
from module.database.bangumi import normalize_save_path
from module.downloader import DownloadClient, RenameOutcome, RenameResult
from module.downloader.path import path_to_bangumi
from module.models import EpisodeFile, Notification, RenameOperation, SubtitleFile
from module.notification import RenameConflictEvent
from module.notification import RenameConflictEvent, SystemEvent
from .revision_policy import (
RevisionIdentity,
@@ -62,10 +63,16 @@ class RevisionOwner:
class RevisionSaga:
"""由 Renamer 继承;子类提供下载器门面 ``client`` 与冲突事件列表 ``events``。"""
"""由 Renamer 继承;子类提供下载器门面 ``client``、冲突事件列表 ``events``
与本轮重命名记录 ``_moves``。"""
client: DownloadClient
events: list[RenameConflictEvent]
events: list[SystemEvent]
_moves: dict[str, list[tuple[str, str, FileKind]]]
def _record_move(self, _hash: str, old: str, new: str, kind: FileKind) -> None:
"""记录一次已生效的重命名,供本轮结束时发布 file.renamed / torrent.organized。"""
self._moves.setdefault(_hash, []).append((old, new, kind))
@staticmethod
def _adjust_episode(original: int | float, episode_offset: int) -> int | float:
@@ -116,6 +123,9 @@ class RevisionSaga:
)
notification = None
if result.outcome is RenameOutcome.RENAMED:
self._record_move(
_hash, prepared.source_path, prepared.target_path, "media"
)
notification = Notification(
official_title=bangumi_name,
season=prepared.episode.season,
@@ -549,6 +559,13 @@ class RevisionSaga:
old_path=operation.source_path,
new_path=operation.target_path,
)
if result.outcome is RenameOutcome.RENAMED:
self._record_move(
operation.new_task_id,
operation.source_path,
operation.target_path,
"media",
)
if result.succeeded:
operation = await self._set_operation_state(
operation, "new_promoted"
@@ -565,6 +582,12 @@ class RevisionSaga:
== operation.target_path
for item in new_files
):
self._record_move(
operation.new_task_id,
operation.source_path,
operation.target_path,
"media",
)
operation = await self._set_operation_state(
operation, "new_promoted"
)

View File

@@ -6,6 +6,7 @@ from .events import (
LLMPluginInstallFailedEvent,
OffsetReviewEvent,
RenameConflictEvent,
RenameSkippedEvent,
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
@@ -23,6 +24,7 @@ __all__ = [
"NotificationProvider",
"OffsetReviewEvent",
"RenameConflictEvent",
"RenameSkippedEvent",
"PROVIDER_REGISTRY",
"RssFailureEvent",
"SystemEvent",

View File

@@ -26,6 +26,7 @@ from ab_sdk.events import (
LLMPluginInstallFailedEvent,
OffsetReviewEvent,
RenameConflictEvent,
RenameSkippedEvent,
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
@@ -39,6 +40,7 @@ __all__ = [
"LLMPluginInstallFailedEvent",
"OffsetReviewEvent",
"RenameConflictEvent",
"RenameSkippedEvent",
"RssFailureEvent",
"SystemEvent",
"UpdateAppliedEvent",

View File

@@ -14,6 +14,7 @@ from typing import Any
from ab_sdk import Event, points
from ab_sdk.downloader import DownloaderConnection
from ab_sdk.rename import CORE_ID
from .bus import EventBus
from .registry import ExtensionPoint, ExtensionRegistry, ProviderEntry
@@ -51,6 +52,12 @@ POINTS = (
ExtensionPoint(points.MCP_TOOL, "provider", "MCP 工具", scoped=True),
ExtensionPoint(points.MCP_RESOURCE, "provider", "MCP 资源", scoped=True),
ExtensionPoint(points.MESSAGE_TEMPLATE, "transform", "系统事件通知文案"),
# --- P4 organize ---
ExtensionPoint(
points.RENAME_STRATEGY, "provider", "重命名方式(bangumi_manage.rename_method)"
),
ExtensionPoint(points.MEDIA_FILES, "provider", "种子内文件分类(正片 / 字幕)"),
ExtensionPoint(points.CONFLICT_POLICY, "provider", "目标路径被占用时保留或替换"),
)
_registry: ExtensionRegistry | None = None
@@ -174,6 +181,22 @@ def _register_core(registry: ExtensionRegistry) -> None:
ProviderEntry(CORE, provider_id, metadata(provider_id)),
)
# organize:重命名方式 none、按扩展名分类、版本冲突策略
from module.downloader.path import SuffixMediaFiles
from module.manager.rename_strategies import (
NO_RENAME,
AdvanceRename,
NoRename,
PnRename,
)
from module.manager.revision_policy import CoreConflictPolicy
_core(registry, points.RENAME_STRATEGY, NO_RENAME, NoRename())
_core(registry, points.RENAME_STRATEGY, "pn", PnRename())
_core(registry, points.RENAME_STRATEGY, "advance", AdvanceRename())
_core(registry, points.MEDIA_FILES, CORE_ID, SuffixMediaFiles())
_core(registry, points.CONFLICT_POLICY, CORE_ID, CoreConflictPolicy())
# --- P5 events/api ---------------------------------------------------------

View File

@@ -139,6 +139,25 @@ class HookRunner:
entry = HookEntry(plugin_id, func, 0, timeout)
return await self._call(entry, point, args, {}, check)
def call_provider_sync(
self,
plugin_id: str,
point: str,
func: Callable[..., Any],
*args: Any,
check: Check | None = None,
) -> tuple[bool, Any]:
"""同步 Provider 方法(如重命名策略)的调用:没有超时,失败与返回值
校验的规则同 :meth:`call_provider`。"""
try:
result = func(*args)
_check(result, check)
except Exception as e:
self._fail(plugin_id, f"{point}: {type(e).__name__}: {e}")
return False, None
self._breaker.record_success(plugin_id)
return True, result
async def _call(
self,
entry: HookEntry,
@@ -152,21 +171,30 @@ class HookRunner:
result = entry.func(*args, **kwargs)
if inspect.isawaitable(result):
result = await asyncio.wait_for(result, timeout)
# 返回值校验在记成功之前:无效结果必须计入熔断,而不是先清零再计一次
if result is not None and check is not None and not check(result):
raise TypeError(f"返回了无效结果 {type(result).__name__},已忽略")
_check(result, check)
except Exception as e:
reason = (
f"{point} 超时({timeout}s)"
if isinstance(e, TimeoutError)
else f"{point}: {type(e).__name__}: {e}"
self._fail(
entry.plugin_id,
(
f"{point} 超时({timeout}s)"
if isinstance(e, TimeoutError)
else f"{point}: {type(e).__name__}: {e}"
),
)
logger.warning("[Plugin:%s] 钩子失败:%s", entry.plugin_id, reason)
self._breaker.record_failure(entry.plugin_id, reason)
return False, None
self._breaker.record_success(entry.plugin_id)
return True, result
def _fail(self, plugin_id: str, reason: str) -> None:
logger.warning("[Plugin:%s] 钩子失败:%s", plugin_id, reason)
self._breaker.record_failure(plugin_id, reason)
def _check(result: Any, check: Check | None) -> None:
# 返回值校验在记成功之前:无效结果必须计入熔断,而不是先清零再计一次
if result is not None and check is not None and not check(result):
raise TypeError(f"返回了无效结果 {type(result).__name__},已忽略")
def _as_verdict(result: Any) -> Verdict | None:
if isinstance(result, Verdict):

View File

@@ -0,0 +1,255 @@
"""P4 organize 扩展点:rename_strategy 的解析、回退与跳过通知,conflict_policy,
以及 file.renamed / torrent.organized 事件。"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from ab_sdk import points
from ab_sdk.events import (
FileRenamed,
OrganizedFile,
RenameSkippedEvent,
TorrentOrganized,
)
from ab_sdk.rename import (
ConflictRequest,
RenameInput,
RenameSkipped,
Revision,
RevisionTask,
)
from ab_sdk.testing import RecordingBus
from module.downloader import DownloadClient
from module.manager import renamer as renamer_module
from module.manager.renamer import Renamer
from module.manager.revision_policy import CoreConflictPolicy
from module.models import EpisodeFile, SubtitleFile
from module.plugin import host
from module.plugin.registry import ProviderEntry
from module.plugin.runner import CircuitBreaker, HookRunner
SAVE_PATH = "/downloads/Bangumi/Anime (2024)/Season 1"
NAME = "[Sub] Anime - 01.mkv"
INFO = {"hash": "h1", "name": NAME, "save_path": SAVE_PATH, "tags": "ab:7"}
SUB = "[Sub] Anime - 01.zh.ass"
def parse(torrent_path: str, **kwargs) -> EpisodeFile | SubtitleFile:
if torrent_path == SUB:
return SubtitleFile(
media_path=SUB,
title="Anime",
season=1,
episode=1,
language="zh",
suffix=".ass",
)
return EpisodeFile(
media_path=torrent_path, title="Anime", season=1, episode=1, suffix=".mkv"
)
@pytest.fixture
def plugins(monkeypatch):
"""全新的进程级注册表、runner 与记录事件的总线。"""
monkeypatch.setattr(host, "_registry", None)
registry = host.get_registry()
tripped: list[str] = []
breaker = CircuitBreaker(2, on_trip=lambda plugin_id, _: tripped.append(plugin_id))
monkeypatch.setattr(host, "_runner", HookRunner(registry, breaker))
bus = RecordingBus()
monkeypatch.setattr(host, "_bus", bus)
monkeypatch.setattr(renamer_module, "_organized_published", {})
monkeypatch.setattr(renamer_module, "_skip_notified", set())
return SimpleNamespace(registry=registry, tripped=tripped, bus=bus)
def add_strategy(registry, provider_id: str, target_name, plugin_id: str = "ext"):
impl = SimpleNamespace(target_name=target_name)
registry.add_provider(
points.RENAME_STRATEGY, ProviderEntry(plugin_id, provider_id, lambda: impl)
)
@pytest.fixture
def renamer(mock_qb_client):
with patch("module.downloader.download_client.settings") as mock_settings:
mock_settings.downloader.type = "qbittorrent"
mock_settings.downloader.host = "localhost:8080"
mock_settings.downloader.path = "/downloads/Bangumi"
with patch(
"module.downloader.download_client.DownloadClient._DownloadClient__getClient",
return_value=mock_qb_client,
):
client = DownloadClient()
client.client = mock_qb_client
mock_qb_client.torrents_info.return_value = [INFO]
mock_qb_client.torrents_files.return_value = [
{"name": NAME},
{"name": SUB},
]
return Renamer(client)
async def run(renamer: Renamer, method: str) -> None:
with (
patch.object(renamer._parser, "torrent_parser", side_effect=parse),
patch("module.manager.renamer.settings") as mock_settings,
patch("module.downloader.path.settings") as mock_path_settings,
):
mock_settings.bangumi_manage.rename_method = method
mock_settings.bangumi_manage.remove_bad_torrent = False
mock_path_settings.downloader.path = "/downloads/Bangumi"
await renamer.rename()
def published(bus: RecordingBus, cls: type) -> list:
return [e for e in bus.published if isinstance(e, cls)]
async def test_rename_unregistered_method_keeps_names_without_tag(plugins, renamer):
await run(renamer, "jellyfin-style")
renamer.client.client.torrents_rename_file.assert_not_called()
renamer.client.client.add_tag.assert_not_called()
assert published(plugins.bus, TorrentOrganized)
async def test_rename_plugin_strategy_renames_and_publishes_events(plugins, renamer):
seen: list[RenameInput] = []
def target_name(f: RenameInput) -> str:
seen.append(f)
language = f".{f.language}" if f.kind == "subtitle" else ""
return f"{f.title} E{f.episode}{language}{f.suffix}"
add_strategy(plugins.registry, "custom", target_name)
await run(renamer, "custom")
assert [(f.kind, f.bangumi_name) for f in seen] == [
("media", "Anime (2024)"),
("subtitle", "Anime (2024)"),
]
renamer.client.client.add_tag.assert_awaited_once_with("h1", "ab:renamed")
assert published(plugins.bus, FileRenamed) == [
FileRenamed(
bangumi_id=7,
old_path=f"{SAVE_PATH}/{NAME}",
new_path=f"{SAVE_PATH}/Anime E1.mkv",
file_kind="media",
),
FileRenamed(
bangumi_id=7,
old_path=f"{SAVE_PATH}/{SUB}",
new_path=f"{SAVE_PATH}/Anime E1.zh.ass",
file_kind="subtitle",
),
]
assert published(plugins.bus, TorrentOrganized) == [
TorrentOrganized(
torrent_hash="h1",
bangumi_id=7,
files=(
OrganizedFile(f"{SAVE_PATH}/Anime E1.mkv", "media"),
OrganizedFile(f"{SAVE_PATH}/Anime E1.zh.ass", "subtitle"),
),
)
]
async def test_rename_none_publishes_organized_once_per_process(plugins, renamer):
await run(renamer, "none")
await run(renamer, "none")
renamer.client.client.torrents_rename_file.assert_not_called()
assert published(plugins.bus, FileRenamed) == []
organized = published(plugins.bus, TorrentOrganized)
assert len(organized) == 1
assert organized[0].files[0] == OrganizedFile(f"{SAVE_PATH}/{NAME}", "media")
def _skip(f: RenameInput) -> str:
raise RenameSkipped("模板渲染失败:'titel' is undefined")
def _crash(f: RenameInput) -> str:
raise ValueError("boom")
@pytest.mark.parametrize(
("target_name", "counted"),
[(_skip, False), (_crash, True), (lambda f: "", True), (lambda f: 3, True)],
)
async def test_rename_strategy_failure_keeps_name_and_notifies_once(
plugins, renamer, target_name, counted
):
add_strategy(plugins.registry, "custom", target_name)
await run(renamer, "custom")
await run(renamer, "custom")
renamer.client.client.torrents_rename_file.assert_not_called()
renamer.client.client.add_tag.assert_not_called()
assert published(plugins.bus, TorrentOrganized) == []
events = [e for e in renamer.events if isinstance(e, RenameSkippedEvent)]
assert len(events) == 1
assert events[0].task_id == "h1" and events[0].strategy == "custom"
assert plugins.tripped == (["ext"] if counted else [])
def _task(file_count: int = 1, revision: int | None = 1) -> RevisionTask:
identity = (
None
if revision is None
else Revision(7, "episode", 1, 1, "ani", "1080p", revision)
)
return RevisionTask("h", "name", file_count, identity)
@pytest.mark.parametrize(
("owners", "incoming", "configured", "strict", "action", "reason"),
[
(
(_task(), _task()),
_task(),
"replace",
True,
"hold",
"canonical path has more than one downloader owner",
),
(
(_task(file_count=2),),
_task(),
"replace",
True,
"hold",
"automatic replacement requires two single-file torrents",
),
(
(_task(revision=None),),
_task(),
"replace",
True,
"hold",
"revision identity is incomplete",
),
(
(_task(),),
_task(),
"replace",
False,
"hold",
"existing and incoming releases are not a strict revision upgrade",
),
((_task(),), _task(), "hold", True, "hold", "revision conflict policy is hold"),
((_task(),), _task(), "replace", True, "replace", ""),
],
)
def test_core_conflict_policy_decide_matches_3x_reasons(
owners, incoming, configured, strict, action, reason
):
decision = CoreConflictPolicy().decide(
ConflictRequest("t.mkv", incoming, owners, configured, strict)
)
assert (decision.action, decision.reason) == (action, reason)

View File

@@ -525,6 +525,7 @@ class TestPluginsApi:
"notifier": [],
"search_site": [],
"metadata_provider": [],
"rename_strategy": [],
}
def test_config_get_masks_plugin_secrets(self, authed_client, plugin_ctx):

View File

@@ -79,7 +79,7 @@ class TestGenPath:
language="zh",
suffix=".ass",
)
result = Renamer.gen_path(sub, "Bangumi", method="subtitle_pn")
result = Renamer.gen_path(sub, "Bangumi", method="pn")
assert result == "My Anime S01E03.zh.ass"
def test_subtitle_advance_method(self):
@@ -92,7 +92,7 @@ class TestGenPath:
language="zh-tw",
suffix=".srt",
)
result = Renamer.gen_path(sub, "Bangumi Name", method="subtitle_advance")
result = Renamer.gen_path(sub, "Bangumi Name", method="advance")
assert result == "Bangumi Name S02E07.zh-tw.srt"
def test_zero_padding_single_digit(self):
@@ -215,7 +215,7 @@ class TestGenPathMovie:
suffix=".ass",
episode_type="movie",
)
result = Renamer.gen_path(sub, "天气之子 (2019)", method="subtitle_advance")
result = Renamer.gen_path(sub, "天气之子 (2019)", method="advance")
assert result == "天气之子 (2019).zh.ass"
def test_gen_path_movie_group_tag_enabled_keeps_clean_name(self):
@@ -296,7 +296,7 @@ class TestGenPathGroupTagStability:
suffix=".ass",
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(sub, "Bangumi Name", method="subtitle_pn")
result = Renamer.gen_path(sub, "Bangumi Name", method="pn")
assert result == "My Anime S01E05.zh.ass"
def test_gen_path_group_tag_disabled_no_prefix(self):
@@ -1686,9 +1686,7 @@ class TestGenPathWithOffsets:
language="zh",
suffix=".ass",
)
result = Renamer.gen_path(
sub, "Bangumi", method="subtitle_pn", episode_offset=-12
)
result = Renamer.gen_path(sub, "Bangumi", method="pn", episode_offset=-12)
assert "E13" in result # 25 - 12 = 13
def test_offset_none_method_unchanged(self):