mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
276 lines
10 KiB
Python
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,
|
|
)
|