Files
MoviePilot/app/chain/transfer/format.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

276 lines
10 KiB
Python

"""
整理剧集命名与格式规则推荐。
"""
from pathlib import Path
from typing import List, Optional, Tuple, Union, cast
from app.application.configuration import (
get_configured_system_config,
)
from app.application.formatting import EpisodeFormatRecommendation, EpisodeFormatRuleHelper
from app.application.history.retry import max_failed_retries
from app.application.transfer.feedback import TransferFailureNotification, classify_transfer_failure
from app.application.transfer.workflow import TransferTask
from app.chain._contracts import TransferMixinHost
from app.chain.storage import StorageChain
from app.chain.transfer.contract import _TransferOwnerBase
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.runtime.log import logger
from app.schemas.file import FileItem
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import EpisodeFormatRule as _SchemaEpisodeFormatRule
from app.schemas.transfer import TransferInfo
from app.schemas.types import (
MediaType,
SystemConfigKey,
)
class EpisodeFormatMixin(_TransferOwnerBase):
"""
提供剧集命名建议、格式规则选择和样例发现。
"""
__mixin_host_protocol__ = TransferMixinHost
def recommend_name(
self,
meta: Optional[MetaBase],
mediainfo: Union[MediaInfo, MusicInfo],
) -> Optional[str]:
"""
获取重命名后的名称
:param meta: 元数据
:param mediainfo: 媒体信息
:return: 重命名后的名称(含目录)
"""
if meta is None:
return None
# 获取集信息,供重命名模块使用
episodes_info: Optional[List[TmdbEpisode]] = None
if mediainfo.type == MediaType.TV:
# 第零季表示特别篇,不能按空值回退为第一季。
season_num = mediainfo.season
if season_num is None and meta.season_seq:
if meta.season_seq.isdigit():
season_num = int(meta.season_seq)
if season_num is None:
season_num = 1
episodes_info = self.run_module(
"tmdb_episodes",
tmdbid=mediainfo.tmdb_id,
season=season_num,
episode_group=mediainfo.episode_group,
)
if episodes_info:
return cast(
Optional[str],
self.run_module(
"recommend_name",
meta=meta,
mediainfo=mediainfo,
episodes_info=episodes_info,
),
)
# 电影或无集信息时保持原有参数集,避免影响旧签名的模块实现
return cast(
Optional[str],
self.run_module("recommend_name", meta=meta, mediainfo=mediainfo),
)
def recommend_episode_format(
self,
fileitem: Optional[FileItem],
fileitems: Optional[List[FileItem]] = None,
) -> Tuple[bool, str, Optional[EpisodeFormatRecommendation]]:
"""
根据目录样本推荐集数定位模板
"""
if not fileitem and not fileitems:
logger.warn("推荐集数定位模板失败:缺少目录参数")
return False, "缺少目录参数", None
rules = self._get_episode_format_rules()
if fileitems:
state, errmsg, sample_files = self._get_selected_episode_format_sample_files(
fileitems
)
if not state:
logger.warn(f"推荐集数定位模板失败:{errmsg}")
return False, errmsg, None
target_path = sample_files[0].path if sample_files else None
else:
if not fileitem or not fileitem.path:
logger.warn("推荐集数定位模板失败:缺少目录参数")
return False, "缺少目录参数", None
directory = self._resolve_episode_format_directory(fileitem)
if not directory or directory.type != "dir":
logger.warn(f"推荐集数定位模板失败:目录不存在 - {fileitem.path}")
return False, "目录不存在", None
sample_files = self._get_episode_format_sample_files(directory)
target_path = directory.path
logger.info(
f"开始匹配集数定位规则:{target_path},规则数 {len(rules)},样本数 {len(sample_files)}"
)
state, errmsg, data = EpisodeFormatRuleHelper().recommend(
rules=rules,
sample_files=sample_files,
)
if not state:
logger.warn(f"集数定位模板推荐失败:{target_path} - {errmsg}")
return state, errmsg, data
logger.info(
f"集数定位模板推荐成功:{target_path} - 规则 {data.get('rule_name') if data else None}"
)
return state, errmsg, data
@staticmethod
def _get_episode_format_rules() -> List[_SchemaEpisodeFormatRule]:
"""
获取启用的集数定位规则
"""
rule_items = get_configured_system_config().get(SystemConfigKey.EpisodeFormatRuleTable) or []
rules: List[_SchemaEpisodeFormatRule] = []
for item in rule_items:
if not isinstance(item, dict):
continue
try:
rule = _SchemaEpisodeFormatRule(**item)
except Exception as err:
logger.warn(f"忽略无效的集数定位规则:{err}")
continue
if rule.enabled:
rules.append(rule)
return sorted(rules, key=lambda item: item.order)
def _resolve_episode_format_directory(
self, fileitem: FileItem
) -> Optional[FileItem]:
"""
将文件或目录入参归一化为目录对象
"""
if not fileitem.path:
return None
storage_chain = StorageChain()
if fileitem.type == "dir":
return storage_chain.get_item(fileitem)
source_path = Path(fileitem.path)
parent_item = FileItem(
storage=fileitem.storage,
path=source_path.parent.as_posix(),
type="dir",
name=source_path.parent.name,
)
return storage_chain.get_item(parent_item)
def _get_selected_episode_format_sample_files(
self, fileitems: List[FileItem]
) -> Tuple[bool, str, List[FileItem]]:
"""
获取当前选择文件中可参与模板推荐的样本文件。
"""
if not fileitems:
return False, "没有可用于识别的样本文件", []
expected_dir_key: Optional[Tuple[str, str]] = None
selected_files: List[FileItem] = []
seen_files = set()
for item in fileitems:
if not item or not item.path or item.type != "file":
return False, "当前选择不满足智能识别条件", []
dir_key = (
item.storage or "local",
Path(item.path).parent.as_posix(),
)
if expected_dir_key is None:
expected_dir_key = dir_key
elif dir_key != expected_dir_key:
return False, "当前选择不满足智能识别条件", []
file_key = (item.storage or "local", item.path)
if file_key in seen_files:
continue
seen_files.add(file_key)
if not (
self._is_media_file(item)
or self._is_subtitle_file(item)
or self._is_audio_file(item)
):
continue
if self._is_hidden_or_recycle_path(item.path):
continue
selected_files.append(item)
if not selected_files:
return False, "没有可用于识别的样本文件", []
return True, "", selected_files
def _get_episode_format_sample_files(
self, directory: FileItem
) -> List[FileItem]:
"""
获取目录下可参与模板推荐的样本文件。
推荐结果最终会在手动整理链路中作为 `episode_format`
交由 `FormatParser` 过滤主视频、字幕和外挂音频,因此这里需要把
同目录下的主视频、字幕和外挂音频一起纳入推荐流程。
"""
file_items = StorageChain().list_files(directory, recursion=False) or []
sample_files: List[FileItem] = []
for item in file_items:
if not item or item.type != "file":
continue
if not (
self._is_media_file(item)
or self._is_subtitle_file(item)
or self._is_audio_file(item)
):
continue
if self._is_hidden_or_recycle_path(item.path):
continue
sample_files.append(item)
return sample_files
def build_failure_notification(
task: TransferTask, transferinfo: TransferInfo, history_id: Optional[int],
*, manual_identity: bool = False, retry_count: Optional[int] = None,
auto_paused: bool = False,
) -> TransferFailureNotification:
"""
从整理工作项构造失败通知快照,不触发聚合或消息发送。
"""
from app.runtime.errors import public_error_message
feedback = classify_transfer_failure(transferinfo.message)
return TransferFailureNotification(
media_title=(
task.mediainfo.title_year
if task.mediainfo else task.fileitem.name if task.fileitem else None
) or "未知媒体",
season_episode=getattr(task.meta, "season_episode", "") or "",
reason=public_error_message(transferinfo.message, context="transfer") or "整理失败",
history_id=history_id,
image=(
task.mediainfo.get_message_image()
if task.mediainfo and hasattr(task.mediainfo, "get_message_image")
else None
),
username=task.username,
manual_identity=manual_identity,
task_id=task.admission_task_id,
source_path=task.fileitem.path if task.fileitem else None,
target_path=transferinfo.target_item.path if transferinfo.target_item else None,
failure_stage=transferinfo.failure_stage or feedback.stage.value,
recovery_action=transferinfo.recovery_action or feedback.action,
retry_count=retry_count,
max_retries=max_failed_retries(),
auto_paused=auto_paused,
cleanup_status=transferinfo.cleanup_status,
cleanup_error=transferinfo.cleanup_error,
)