mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-03 14:16:03 +08:00
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:
@@ -119,5 +119,6 @@ async def list_plugin_providers():
|
||||
points.NOTIFIER,
|
||||
points.SEARCH_SITE,
|
||||
points.METADATA_PROVIDER,
|
||||
points.RENAME_STRATEGY,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
255
backend/src/test/test_organize.py
Normal file
255
backend/src/test/test_organize.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user