mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
1574 lines
57 KiB
Python
1574 lines
57 KiB
Python
"""整理链功能域 mixin。
|
||
|
||
TransferChain 从 5000+ 行的单体拆出这些内聚功能域,每个 mixin 只承载一类
|
||
整理辅助逻辑;主流程(do_transfer / manual_transfer / remote_transfer)仍留在
|
||
TransferChain 中。mixin 方法运行时经 MRO 解析,共享 TransferChain 实例状态。
|
||
|
||
注意:这里的方法均已去掉私有名前缀双下划线(__ -> _),因为 Python 的名字
|
||
改编按定义类生效,方法迁到 mixin 后 __ 前缀会改变改编目标,导致跨类调用失败。
|
||
"""
|
||
import asyncio
|
||
from copy import deepcopy
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||
|
||
from app.schemas.history import DownloadHistory as _SchemaDownloadHistory
|
||
from app.schemas.transfer import EpisodeFormatRule as _SchemaEpisodeFormatRule
|
||
from app.adapters.system.host import SystemUtils
|
||
from app.application.agent import build_manual_redo_prompt, get_running_agent_manager
|
||
from app.application.formatting import EpisodeFormatRuleHelper
|
||
from app.application.history import clear_transfer_failures, resolve_history
|
||
from app.application.transfer import TransferTask, job_lock
|
||
from app.chain.media import MediaChain
|
||
from app.chain.storage import StorageChain
|
||
from app.chain.subscribe import SubscribeChain
|
||
from app.application.chain.data import (
|
||
get_chain_download_history_port,
|
||
get_chain_transfer_history_port,
|
||
)
|
||
from app.application.configuration import (
|
||
get_chain_runtime_config_snapshot,
|
||
get_configured_system_config,
|
||
)
|
||
from app.domain.context import MediaInfo, MusicInfo
|
||
from app.domain.media import normalize_music_type
|
||
from app.domain.meta.metabase import MetaBase
|
||
from app.domain.meta.metamusic import MetaMusic
|
||
from app.foundation import text as text_tools
|
||
from app.runtime.config import global_vars
|
||
from app.runtime.log import logger
|
||
from app.runtime.tasks import get_task_registry
|
||
from app.schemas.workflow import FileItem
|
||
from app.schemas.message import Message
|
||
from app.schemas.tmdb import TmdbEpisode
|
||
from app.schemas.transfer import TransferInfo
|
||
from app.schemas.types import (
|
||
MUSIC_ENTITY_ALBUM,
|
||
EventType,
|
||
MediaSource,
|
||
MediaType,
|
||
NotificationChannel,
|
||
ReplyMode,
|
||
SystemConfigKey,
|
||
)
|
||
|
||
DownloadFiles = Any
|
||
DownloadHistory = Any
|
||
TransferHistory = Any
|
||
|
||
# 字幕文件常见的语言/默认/强制标记,整理同名字幕时只允许剥离这些字幕专属尾缀。
|
||
SUBTITLE_STEM_TAGS = {
|
||
"cc",
|
||
"chi",
|
||
"chs",
|
||
"cht",
|
||
"cn",
|
||
"default",
|
||
"en",
|
||
"eng",
|
||
"english",
|
||
"forced",
|
||
"gb",
|
||
"gb2312",
|
||
"hk",
|
||
"ja",
|
||
"jap",
|
||
"japanese",
|
||
"jp",
|
||
"jpn",
|
||
"sc",
|
||
"sdh",
|
||
"tc",
|
||
"zh",
|
||
"zh-cn",
|
||
"zh-hans",
|
||
"zh-hant",
|
||
"zh-tw",
|
||
"zh_cn",
|
||
"zh_hans",
|
||
"zh_hant",
|
||
"zh_tw",
|
||
"zho",
|
||
"中英",
|
||
"中字",
|
||
"双语",
|
||
"简中",
|
||
"简体",
|
||
"繁中",
|
||
"繁体",
|
||
}
|
||
|
||
|
||
class FileFilterMixin:
|
||
@staticmethod
|
||
def _requires_automatic_category(task: TransferTask) -> bool:
|
||
"""
|
||
判断当前整理任务是否需要根据媒体识别结果自动创建类别目录。
|
||
|
||
:param task: 整理任务
|
||
:return: 是否必须具备自动分类结果
|
||
"""
|
||
target_directory = task.target_directory
|
||
if target_directory and target_directory.media_category:
|
||
return False
|
||
if task.library_category_folder is not None:
|
||
return bool(task.library_category_folder)
|
||
return bool(
|
||
target_directory and target_directory.library_category_folder
|
||
)
|
||
|
||
def _is_subtitle_file(self, fileitem: FileItem) -> bool:
|
||
"""
|
||
判断是否为字幕文件
|
||
"""
|
||
if not fileitem.extension:
|
||
return False
|
||
return (
|
||
True if f".{fileitem.extension.lower()}" in self._subtitle_exts else False
|
||
)
|
||
|
||
def _is_audio_file(self, fileitem: FileItem) -> bool:
|
||
"""
|
||
判断是否为音频文件
|
||
"""
|
||
if not fileitem.extension:
|
||
return False
|
||
return True if f".{fileitem.extension.lower()}" in self._audio_exts else False
|
||
|
||
def _is_media_file(
|
||
self,
|
||
fileitem: FileItem,
|
||
mtype: Optional[MediaType] = None,
|
||
) -> bool:
|
||
"""
|
||
判断是否为主要媒体文件
|
||
"""
|
||
if mtype == MediaType.MUSIC:
|
||
if fileitem.type != "file" or not fileitem.extension:
|
||
return False
|
||
return f".{fileitem.extension.lower()}" in self._audio_exts
|
||
if fileitem.type == "dir":
|
||
# 蓝光原盘判断
|
||
return StorageChain().is_bluray_folder(fileitem)
|
||
if not fileitem.extension:
|
||
return False
|
||
extension = f".{fileitem.extension.lower()}"
|
||
return extension in self._media_exts
|
||
|
||
def _is_primary_media_file(
|
||
self,
|
||
fileitem: FileItem,
|
||
mediainfo: Optional[MediaInfo | MusicInfo],
|
||
) -> bool:
|
||
"""判断文件在当前媒体上下文中是否属于主要媒体文件。"""
|
||
return self._is_media_file(
|
||
fileitem,
|
||
getattr(mediainfo, "type", None),
|
||
)
|
||
|
||
@staticmethod
|
||
def _music_info_from_meta(meta: MetaMusic) -> MusicInfo:
|
||
"""将音频文件标签解析结果转换为可整理的最小音乐信息。"""
|
||
return MusicInfo.from_meta(meta)
|
||
|
||
@classmethod
|
||
def _match_music_album_context(
|
||
cls,
|
||
file_item: FileItem,
|
||
file_path: Path,
|
||
file_meta: MetaMusic,
|
||
) -> tuple[MetaMusic, Optional[MusicInfo]]:
|
||
"""为缺少远端身份的本地音频尝试目录级专辑匹配,命中后回填文件元数据。
|
||
|
||
WAV 等无标签文件只能依靠目录结构和曲目特征识别;匹配结果由 MediaChain
|
||
按目录缓存,同一专辑目录内的后续文件不会重复请求远端。
|
||
"""
|
||
# 目录级匹配需要读取本地音频时长,远端存储文件无法参与
|
||
if file_meta.media_id or getattr(file_item, "storage", "local") != "local":
|
||
return file_meta, None
|
||
try:
|
||
matched = MediaChain().recognize_music_album_directory(file_path.parent)
|
||
except Exception as err:
|
||
logger.debug(f"音乐专辑目录匹配失败:{file_path} - {err}")
|
||
return file_meta, None
|
||
info = matched.get(str(file_path.resolve()))
|
||
if not info or not info.media_id:
|
||
return file_meta, None
|
||
logger.info(f"{file_path.name} 通过专辑目录匹配识别为:{info.artist} - {info.title}")
|
||
merged_meta = deepcopy(file_meta)
|
||
# 保留本地音频的实际技术参数,仅回填身份和名称字段
|
||
if info.title:
|
||
merged_meta.title = info.title
|
||
if info.artists:
|
||
merged_meta.artists = list(info.artists)
|
||
if info.album:
|
||
merged_meta.album = info.album
|
||
if info.album_artist:
|
||
merged_meta.album_artist = info.album_artist
|
||
if info.year:
|
||
merged_meta.year = info.year
|
||
if info.disc_number:
|
||
merged_meta.disc_number = info.disc_number
|
||
if info.track_number:
|
||
merged_meta.track_number = info.track_number
|
||
if info.total_tracks:
|
||
merged_meta.total_tracks = info.total_tracks
|
||
merged_meta.media_source = info.media_source
|
||
merged_meta.media_id = info.media_id
|
||
merged_info = cls._music_info_from_meta(merged_meta)
|
||
# 补齐曲目级远端信息,供后续刮削和展示使用
|
||
merged_info.music_type = info.music_type
|
||
merged_info.artist_ids = list(info.artist_ids)
|
||
merged_info.album_id = info.album_id
|
||
merged_info.album_type = info.album_type
|
||
merged_info.release_date = info.release_date
|
||
merged_info.cover_url = info.cover_url
|
||
merged_info.category = info.category
|
||
merged_info.genres = list(info.genres)
|
||
merged_info.detail_link = info.detail_link
|
||
return merged_meta, merged_info
|
||
|
||
@staticmethod
|
||
def _download_history_music_type(
|
||
download_history: Optional[DownloadHistory],
|
||
) -> Optional[str]:
|
||
"""从下载历史字段或旧版音乐备注中恢复音乐实体类型。"""
|
||
music_type = normalize_music_type(
|
||
getattr(download_history, "music_type", None),
|
||
allow_artist=False,
|
||
)
|
||
if music_type:
|
||
return music_type
|
||
note = getattr(download_history, "note", None)
|
||
music_note = note.get("music") if isinstance(note, dict) else None
|
||
media_payload = music_note.get("media") if isinstance(music_note, dict) else None
|
||
if not isinstance(media_payload, dict):
|
||
return None
|
||
return normalize_music_type(
|
||
media_payload.get("music_type"),
|
||
allow_artist=False,
|
||
)
|
||
|
||
@classmethod
|
||
def _restore_music_download_context(
|
||
cls,
|
||
download_history: Optional[DownloadHistory],
|
||
file_path: Path,
|
||
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
|
||
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。"""
|
||
note = getattr(download_history, "note", None)
|
||
music_note = note.get("music") if isinstance(note, dict) else None
|
||
if not isinstance(music_note, dict) or music_note.get("version") != 1:
|
||
return None, None
|
||
try:
|
||
saved_meta = MetaMusic.from_dict(music_note.get("meta") or {})
|
||
saved_info = MusicInfo.from_dict(music_note.get("media") or {})
|
||
except (TypeError, ValueError):
|
||
return None, None
|
||
|
||
file_tags = MediaChain.read_path_meta(file_path)
|
||
file_meta = deepcopy(saved_meta)
|
||
file_meta.org_string = file_path.name
|
||
# 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名),
|
||
# 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。
|
||
if file_tags.title:
|
||
file_meta.title = file_tags.title
|
||
is_album_context = saved_info.music_type == MUSIC_ENTITY_ALBUM
|
||
for field_name in (
|
||
"artists",
|
||
"disc_number",
|
||
"track_number",
|
||
"total_discs",
|
||
"version",
|
||
"isrc",
|
||
):
|
||
if getattr(file_tags, field_name, None):
|
||
setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name)))
|
||
for field_name in ("album", "album_artist", "year", "total_tracks"):
|
||
file_value = getattr(file_tags, field_name, None)
|
||
# 整专下载以订阅选中的专辑字段为准,避免单个错误标签把曲目拆到其它专辑目录。
|
||
if file_value and (not is_album_context or not getattr(file_meta, field_name, None)):
|
||
setattr(file_meta, field_name, deepcopy(file_value))
|
||
for field_name in (
|
||
"audio_format",
|
||
"bit_depth",
|
||
"sample_rate",
|
||
"bitrate",
|
||
"duration",
|
||
):
|
||
if getattr(file_tags, field_name, None):
|
||
setattr(file_meta, field_name, getattr(file_tags, field_name))
|
||
file_meta.media_source = saved_info.media_source or saved_meta.media_source
|
||
file_meta.media_id = saved_info.media_id or saved_meta.media_id
|
||
|
||
file_info = cls._music_info_from_meta(file_meta)
|
||
file_info.media_source = saved_info.media_source
|
||
file_info.media_id = saved_info.media_id
|
||
file_info.music_type = saved_info.music_type
|
||
file_info.artist_ids = list(saved_info.artist_ids)
|
||
file_info.album_id = saved_info.album_id
|
||
file_info.album_type = saved_info.album_type
|
||
file_info.release_date = saved_info.release_date
|
||
file_info.cover_url = saved_info.cover_url
|
||
file_info.lyrics = saved_info.lyrics
|
||
file_info.category = saved_info.category
|
||
file_info.genres = list(saved_info.genres)
|
||
file_info.detail_link = saved_info.detail_link
|
||
file_info.listen_count = saved_info.listen_count
|
||
return file_meta, file_info
|
||
|
||
@staticmethod
|
||
def _is_music_retry_source(history: TransferHistory, src_path: Path) -> bool:
|
||
"""
|
||
判断重新整理来源是否应走音乐链路:历史类型为音乐,或源路径为音频文件。
|
||
"""
|
||
if history.type == MediaType.MUSIC.value:
|
||
return True
|
||
return src_path.suffix.lower() in get_chain_runtime_config_snapshot().audio_extensions
|
||
|
||
def _recognize_music_retry_media(
|
||
self,
|
||
history: TransferHistory,
|
||
src_path: Path,
|
||
) -> Optional[Union[MusicInfo, MediaInfo]]:
|
||
"""
|
||
重新整理重试时恢复音乐信息。
|
||
|
||
优先按历史记录中的 MusicBrainz 身份恢复;单音频文件回退按音频标签与文件名识别;
|
||
音乐专辑目录返回 None,交由整理链按音频后缀逐文件解析识别。
|
||
"""
|
||
if history.media_source and history.media_id:
|
||
retry_info = MediaChain().recognize_media(
|
||
mtype=MediaType.MUSIC,
|
||
media_source=history.media_source,
|
||
media_id=history.media_id,
|
||
music_type=getattr(history, "music_type", None),
|
||
)
|
||
if retry_info:
|
||
return retry_info
|
||
if src_path.is_file():
|
||
# 音频走统一路径识别入口,自动路由到音乐识别链
|
||
recognize_context = MediaChain().recognize_by_path(str(src_path))
|
||
return recognize_context.media_info if recognize_context else None
|
||
return None
|
||
|
||
def _is_allowed_file(self, fileitem: FileItem) -> bool:
|
||
"""
|
||
判断是否允许的扩展名
|
||
"""
|
||
if not fileitem.extension:
|
||
return False
|
||
return True if f".{fileitem.extension.lower()}" in self._allowed_exts else False
|
||
|
||
@staticmethod
|
||
def _is_allow_filesize(fileitem: FileItem, min_filesize: int) -> bool:
|
||
"""
|
||
判断是否满足最小文件大小
|
||
"""
|
||
return (
|
||
True
|
||
if not min_filesize or (fileitem.size or 0) > min_filesize * 1024 * 1024
|
||
else False
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_hidden_or_recycle_path(file_path: Optional[str]) -> bool:
|
||
"""
|
||
判断是否隐藏或回收站路径
|
||
"""
|
||
if not file_path:
|
||
return False
|
||
normalized_path = file_path.replace("\\", "/")
|
||
return (
|
||
"/@Recycle/" in normalized_path
|
||
or "/#recycle/" in normalized_path
|
||
or "/." in normalized_path
|
||
or "/@eaDir" in normalized_path
|
||
)
|
||
|
||
@staticmethod
|
||
def _should_delete_empty_source_directories(
|
||
task: TransferTask,
|
||
delete_mounted_local_disk_empty_dirs: bool,
|
||
mounted_filesystem_cache: Dict[Path, bool],
|
||
) -> bool:
|
||
"""
|
||
判断移动整理后是否应删除源空目录。
|
||
|
||
仅在关闭挂载盘空目录清理且源存储为本地时检测文件系统,
|
||
避免默认流程产生额外系统调用。
|
||
"""
|
||
if delete_mounted_local_disk_empty_dirs:
|
||
return True
|
||
if task.fileitem.storage != "local":
|
||
return True
|
||
|
||
source_directory = (
|
||
Path(task.target_directory.download_path)
|
||
if task.target_directory and task.target_directory.download_path
|
||
else Path(task.fileitem.path).parent
|
||
)
|
||
if source_directory not in mounted_filesystem_cache:
|
||
mounted_filesystem_cache[source_directory] = (
|
||
SystemUtils.is_network_filesystem(
|
||
source_directory, include_local_fuse=True
|
||
)
|
||
)
|
||
return not mounted_filesystem_cache[source_directory]
|
||
|
||
@staticmethod
|
||
def _is_overwrite_declined(task: TransferTask, transferinfo: TransferInfo,
|
||
transferhis: Any) -> bool:
|
||
"""
|
||
判断本次未入库是否为「同路径已有成功记录 + 覆盖模式裁定不覆盖」。
|
||
|
||
只有同路径此前已成功整理过才需要保护:这类文件是查重闸放行的同路径新版本,
|
||
媒体库中的原有版本仍然在位,不应因一次不覆盖裁决把成功记录改写成失败记录。
|
||
没有成功记录时(如目标同名文件来自其他源路径)保持原有失败语义,
|
||
用户仍能在历史与通知中看到裁决结果。
|
||
:param task: 整理任务
|
||
:param transferinfo: 整理结果
|
||
:param transferhis: 历史操作对象
|
||
:return: True 表示应保留原成功记录
|
||
"""
|
||
if not transferinfo.overwrite_skipped or not task.fileitem:
|
||
return False
|
||
try:
|
||
history = resolve_history(
|
||
task.fileitem.path,
|
||
storage=task.fileitem.storage,
|
||
transfer_history_oper=transferhis,
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"查询整理历史失败: {task.fileitem.path} - {err}")
|
||
return False
|
||
return bool(history and history.status)
|
||
|
||
|
||
class ScrapeBatchMixin:
|
||
|
||
def _send_metadata_scrape_event(
|
||
self, task: TransferTask, transferinfo: TransferInfo
|
||
):
|
||
"""
|
||
发送元数据刮削事件,保持对外事件载荷兼容。
|
||
"""
|
||
if (
|
||
not task
|
||
or not transferinfo
|
||
or not transferinfo.need_scrape
|
||
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
|
||
):
|
||
return
|
||
|
||
target_diritem = transferinfo.target_diritem
|
||
if not target_diritem:
|
||
return
|
||
|
||
self.eventmanager.send_event(
|
||
EventType.MetadataScrape,
|
||
self._build_metadata_scrape_payload(
|
||
task=task,
|
||
fileitem=target_diritem,
|
||
file_list=transferinfo.file_list_new,
|
||
overwrite=False,
|
||
),
|
||
)
|
||
|
||
@staticmethod
|
||
def _build_metadata_scrape_payload(
|
||
task: TransferTask,
|
||
fileitem: FileItem,
|
||
file_list: Optional[list[str]],
|
||
overwrite: bool,
|
||
) -> dict[str, Any]:
|
||
"""构造刮削事件载荷,并为音乐批次保留逐文件身份上下文。"""
|
||
paths = list(dict.fromkeys(file_list or []))
|
||
payload: dict[str, Any] = {
|
||
"meta": task.meta,
|
||
"mediainfo": task.mediainfo,
|
||
"fileitem": fileitem,
|
||
"file_list": paths,
|
||
"overwrite": overwrite,
|
||
}
|
||
if isinstance(task.mediainfo, MusicInfo):
|
||
payload["file_contexts"] = [
|
||
{
|
||
"path": path,
|
||
"meta": task.meta,
|
||
"mediainfo": task.mediainfo,
|
||
}
|
||
for path in paths
|
||
]
|
||
return payload
|
||
|
||
def _register_scrape_batch_task(self, task: TransferTask):
|
||
"""
|
||
登记批次任务。刮削事件只在批次关闭且任务全部完成后统一发送。
|
||
"""
|
||
if not task or not task.transfer_batch_id:
|
||
return
|
||
with job_lock:
|
||
batch = self._scrape_batches.setdefault(
|
||
task.transfer_batch_id,
|
||
{
|
||
"pending": set(),
|
||
"targets": {},
|
||
"closed": False,
|
||
},
|
||
)
|
||
batch["pending"].add(task.fileitem.path)
|
||
|
||
def _close_scrape_batch(self, batch_id: Optional[str]):
|
||
"""
|
||
标记批次不再接收新任务,并尝试发送已聚合的刮削事件。
|
||
"""
|
||
if not batch_id:
|
||
return
|
||
with job_lock:
|
||
batch = self._scrape_batches.setdefault(
|
||
batch_id,
|
||
{
|
||
"pending": set(),
|
||
"targets": {},
|
||
"closed": False,
|
||
},
|
||
)
|
||
batch["closed"] = True
|
||
self._flush_scrape_batch_if_ready(batch_id)
|
||
|
||
def _record_scrape_target(self, task: TransferTask, transferinfo: TransferInfo):
|
||
"""
|
||
记录批次内需要刮削的目标文件,按目标媒体根目录聚合。
|
||
"""
|
||
if (
|
||
not task
|
||
or not task.transfer_batch_id
|
||
or not transferinfo
|
||
or not transferinfo.need_scrape
|
||
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
|
||
):
|
||
return
|
||
|
||
target_diritem = transferinfo.target_diritem
|
||
if not target_diritem:
|
||
return
|
||
|
||
target_files = transferinfo.file_list_new or []
|
||
target_key = (target_diritem.storage, target_diritem.path)
|
||
with job_lock:
|
||
batch = self._scrape_batches.setdefault(
|
||
task.transfer_batch_id,
|
||
{
|
||
"pending": set(),
|
||
"targets": {},
|
||
"closed": False,
|
||
},
|
||
)
|
||
target = batch["targets"].setdefault(
|
||
target_key,
|
||
{
|
||
"fileitem": target_diritem,
|
||
"meta": task.meta,
|
||
"mediainfo": task.mediainfo,
|
||
"files": [],
|
||
"file_contexts": {},
|
||
"overwrite": False,
|
||
},
|
||
)
|
||
if not target.get("meta"):
|
||
target["meta"] = task.meta
|
||
if not target.get("mediainfo"):
|
||
target["mediainfo"] = task.mediainfo
|
||
for target_file in target_files:
|
||
if target_file and target_file not in target["files"]:
|
||
target["files"].append(target_file)
|
||
if target_file and isinstance(task.mediainfo, MusicInfo):
|
||
target["file_contexts"][target_file] = {
|
||
"path": target_file,
|
||
"meta": task.meta,
|
||
"mediainfo": task.mediainfo,
|
||
}
|
||
|
||
def _finish_scrape_batch_task(self, task: TransferTask):
|
||
"""
|
||
标记批次内单个任务已结束。
|
||
"""
|
||
if not task or not task.transfer_batch_id:
|
||
return
|
||
with job_lock:
|
||
batch = self._scrape_batches.get(task.transfer_batch_id)
|
||
if not batch:
|
||
return
|
||
batch["pending"].discard(task.fileitem.path)
|
||
self._flush_scrape_batch_if_ready(task.transfer_batch_id)
|
||
|
||
def _flush_scrape_batch_if_ready(self, batch_id: Optional[str]):
|
||
"""
|
||
批次任务全部结束后发送聚合后的刮削事件。
|
||
"""
|
||
if not batch_id:
|
||
return
|
||
|
||
with job_lock:
|
||
batch = self._scrape_batches.get(batch_id)
|
||
if (
|
||
not batch
|
||
or not batch.get("closed")
|
||
or batch.get("pending")
|
||
):
|
||
return
|
||
targets = list(batch.get("targets", {}).values())
|
||
self._scrape_batches.pop(batch_id, None)
|
||
|
||
for target in targets:
|
||
fileitem = target.get("fileitem")
|
||
if not fileitem:
|
||
continue
|
||
file_list = list(dict.fromkeys(target.get("files") or []))
|
||
file_contexts = target.get("file_contexts") or {}
|
||
payload = {
|
||
"meta": target.get("meta"),
|
||
"mediainfo": target.get("mediainfo"),
|
||
"fileitem": fileitem,
|
||
"file_list": file_list,
|
||
"overwrite": target.get("overwrite", False),
|
||
}
|
||
if file_contexts:
|
||
payload["file_contexts"] = [
|
||
file_contexts[path]
|
||
for path in file_list
|
||
if path in file_contexts
|
||
]
|
||
self.eventmanager.send_event(
|
||
EventType.MetadataScrape,
|
||
payload,
|
||
)
|
||
|
||
|
||
class EpisodeFormatMixin:
|
||
|
||
def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:
|
||
"""
|
||
获取重命名后的名称
|
||
:param meta: 元数据
|
||
:param mediainfo: 媒体信息
|
||
:return: 重命名后的名称(含目录)
|
||
"""
|
||
# 获取集信息,供重命名模块使用
|
||
episodes_info: Optional[List[TmdbEpisode]] = None
|
||
if mediainfo.type == MediaType.TV:
|
||
# 判断注意season为0的情况
|
||
season_num = mediainfo.season
|
||
if season_num is None and meta.season_seq:
|
||
if meta.season_seq.isdigit():
|
||
season_num = int(meta.season_seq)
|
||
# 默认值1
|
||
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 self.run_module(
|
||
"recommend_name",
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
episodes_info=episodes_info,
|
||
)
|
||
# 电影或无集信息时保持原有参数集,避免影响旧签名的模块实现
|
||
return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo)
|
||
|
||
def recommend_episode_format(
|
||
self,
|
||
fileitem: FileItem,
|
||
fileitems: Optional[List[FileItem]] = None,
|
||
) -> Tuple[bool, str, Optional[dict]]:
|
||
"""
|
||
根据目录样本推荐集数定位模板
|
||
"""
|
||
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]:
|
||
"""
|
||
将文件或目录入参归一化为目录对象
|
||
"""
|
||
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
|
||
|
||
|
||
class HistoryMatchMixin:
|
||
@staticmethod
|
||
def _match_download_file(
|
||
download_file: DownloadFiles,
|
||
file_path: Path,
|
||
save_path: Path,
|
||
) -> bool:
|
||
"""
|
||
判断下载文件记录是否明确对应当前文件。
|
||
"""
|
||
if download_file.fullpath == file_path.as_posix():
|
||
return True
|
||
|
||
filepath = download_file.filepath
|
||
if not filepath:
|
||
return False
|
||
|
||
try:
|
||
return (save_path / Path(filepath)).as_posix() == file_path.as_posix()
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
def _resolve_history_from_download_files(
|
||
self,
|
||
downloadhis: Any,
|
||
download_files: List[DownloadFiles],
|
||
file_path: Optional[Path] = None,
|
||
save_path: Optional[Path] = None,
|
||
) -> Optional[DownloadHistory]:
|
||
"""
|
||
从下载文件记录中解析唯一的下载历史。
|
||
"""
|
||
if file_path and save_path:
|
||
download_files = [
|
||
download_file
|
||
for download_file in download_files
|
||
if self._match_download_file(
|
||
download_file=download_file,
|
||
file_path=file_path,
|
||
save_path=save_path,
|
||
)
|
||
]
|
||
|
||
download_hashes = {
|
||
download_file.download_hash
|
||
for download_file in download_files
|
||
if download_file.download_hash
|
||
}
|
||
if len(download_hashes) == 1:
|
||
return downloadhis.get_by_hash(next(iter(download_hashes)))
|
||
return None
|
||
|
||
def _resolve_download_history(
|
||
self,
|
||
downloadhis: Any,
|
||
file_path: Path,
|
||
bluray_dir: bool = False,
|
||
download_hash: Optional[str] = None,
|
||
) -> Optional[DownloadHistory]:
|
||
"""
|
||
根据显式 hash、文件路径或种子根目录回查下载历史。
|
||
"""
|
||
if download_hash:
|
||
return downloadhis.get_by_hash(download_hash)
|
||
|
||
if bluray_dir:
|
||
return downloadhis.get_by_path(file_path.as_posix())
|
||
|
||
download_file = downloadhis.get_file_by_fullpath(file_path.as_posix())
|
||
if download_file:
|
||
return downloadhis.get_by_hash(download_file.download_hash)
|
||
|
||
# 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录,
|
||
# 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。
|
||
shared_download_roots = self._get_shared_download_roots(file_path)
|
||
|
||
for parent_path in file_path.parents:
|
||
parent_posix = parent_path.as_posix()
|
||
download_files = downloadhis.get_files_by_savepath(parent_posix) or []
|
||
|
||
if parent_posix in shared_download_roots:
|
||
# 共享下载根目录只能接受有明确文件记录的匹配,
|
||
# 避免单文件/磁力任务把整个根目录污染成同一媒体。
|
||
history = self._resolve_history_from_download_files(
|
||
downloadhis=downloadhis,
|
||
download_files=download_files,
|
||
file_path=file_path,
|
||
save_path=parent_path,
|
||
)
|
||
if history:
|
||
return history
|
||
break
|
||
|
||
download_history = downloadhis.get_by_path(parent_posix)
|
||
if download_history:
|
||
return download_history
|
||
|
||
history = self._resolve_history_from_download_files(
|
||
downloadhis=downloadhis,
|
||
download_files=download_files,
|
||
)
|
||
if history:
|
||
return history
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def _is_movie_year_conflict(
|
||
file_meta: MetaBase,
|
||
# 两种 DownloadHistory 都会进来:库模型(本文件按 ORM 行查历史)与
|
||
# schemas DTO(TransferTask.download_history)。本函数只按 getattr 取
|
||
# year 与 type,对两者一视同仁
|
||
media: Union[DownloadHistory, _SchemaDownloadHistory, MediaInfo, MusicInfo]
|
||
) -> bool:
|
||
"""
|
||
判断文件名年份是否与已识别电影年份冲突。
|
||
|
||
多电影合集只保存一条下载历史,不能把合集首部电影的媒体 ID 套用到其它年份的文件;
|
||
电视剧季包仍应继续复用同一条下载历史。
|
||
"""
|
||
file_year = getattr(file_meta, "year", None)
|
||
media_year = getattr(media, "year", None)
|
||
if not file_meta or not media or not file_year or not media_year:
|
||
return False
|
||
media_type = getattr(media, "type", None)
|
||
if not isinstance(media_type, MediaType):
|
||
try:
|
||
media_type = MediaType(media_type)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return (
|
||
media_type == MediaType.MOVIE
|
||
and str(file_year) != str(media_year)
|
||
)
|
||
|
||
@staticmethod
|
||
def _optional_attr_equal(
|
||
source: MetaBase,
|
||
target: MetaBase,
|
||
attr: str,
|
||
normalizer: Callable = None,
|
||
) -> bool:
|
||
"""
|
||
比较可选识别字段。
|
||
|
||
字段两边都没有识别到时不参与判断;只要任意一边识别到了,就要求两边值一致,
|
||
避免把同名不同年份或不同季集的附加文件误归到当前主视频。
|
||
"""
|
||
source_value = getattr(source, attr, None)
|
||
target_value = getattr(target, attr, None)
|
||
if source_value is None and target_value is None:
|
||
return True
|
||
if source_value is None or target_value is None:
|
||
return False
|
||
if normalizer:
|
||
source_value = normalizer(source_value)
|
||
target_value = normalizer(target_value)
|
||
return source_value == target_value
|
||
|
||
def _is_same_media_meta(
|
||
self, source_meta: MetaBase, target_meta: MetaBase
|
||
) -> bool:
|
||
"""
|
||
判断两个文件识别出的媒体身份是否一致。
|
||
"""
|
||
if not source_meta or not target_meta:
|
||
return False
|
||
if source_meta.type != target_meta.type:
|
||
return False
|
||
if text_tools.normalize_upper(source_meta.name) != text_tools.normalize_upper(
|
||
target_meta.name
|
||
):
|
||
return False
|
||
if not self._optional_attr_equal(source_meta, target_meta, "year", str):
|
||
return False
|
||
for attr in (
|
||
"begin_season",
|
||
"end_season",
|
||
"begin_episode",
|
||
"end_episode",
|
||
):
|
||
if not self._optional_attr_equal(source_meta, target_meta, attr, int):
|
||
return False
|
||
return True
|
||
|
||
|
||
class FileKeyMixin:
|
||
@staticmethod
|
||
def _get_file_key(fileitem: FileItem) -> Tuple[str, str]:
|
||
"""
|
||
获取文件缓存键。
|
||
"""
|
||
normalized_path = Path(str(fileitem.path).replace("\\", "/")).as_posix()
|
||
return fileitem.storage or "local", normalized_path
|
||
|
||
@staticmethod
|
||
def _get_file_stem(fileitem: FileItem) -> str:
|
||
"""
|
||
获取文件主干名,用于判断同名附加文件。
|
||
"""
|
||
file_name = fileitem.name or Path(fileitem.path).name
|
||
return Path(file_name).stem.lower()
|
||
|
||
@classmethod
|
||
def _get_subtitle_media_stem(cls, subtitle_fileitem: FileItem) -> str:
|
||
"""
|
||
获取字幕对应主视频的候选主干名。
|
||
"""
|
||
current_stem = cls._get_file_stem(subtitle_fileitem)
|
||
while current_stem:
|
||
media_stem, separator, suffix = current_stem.rpartition(".")
|
||
if not separator or suffix not in SUBTITLE_STEM_TAGS:
|
||
return current_stem
|
||
current_stem = media_stem
|
||
return current_stem
|
||
|
||
def _get_extra_media_stem(self, extra_fileitem: FileItem) -> str:
|
||
"""
|
||
获取附加文件对应主视频的候选主干名。
|
||
"""
|
||
if self._is_subtitle_file(extra_fileitem):
|
||
return self._get_subtitle_media_stem(extra_fileitem)
|
||
return self._get_file_stem(extra_fileitem)
|
||
|
||
def _get_related_main_file_key(
|
||
self,
|
||
extra_fileitem: FileItem,
|
||
main_fileitems: List[FileItem],
|
||
) -> Optional[Tuple[str, str]]:
|
||
"""
|
||
获取与附加文件名完全匹配的主视频键。
|
||
"""
|
||
if not (
|
||
self._is_subtitle_file(extra_fileitem)
|
||
or self._is_audio_file(extra_fileitem)
|
||
):
|
||
return None
|
||
|
||
extra_media_stem = self._get_extra_media_stem(extra_fileitem)
|
||
matched_items: List[FileItem] = []
|
||
for main_fileitem in main_fileitems:
|
||
main_stem = self._get_file_stem(main_fileitem)
|
||
if main_stem and main_stem == extra_media_stem:
|
||
matched_items.append(main_fileitem)
|
||
|
||
if len(matched_items) != 1:
|
||
return None
|
||
return self._get_file_key(matched_items[0])
|
||
|
||
@staticmethod
|
||
def _normalize_dir_path(dir_path: Union[str, Path]) -> str:
|
||
"""
|
||
归一化目录路径,用于同一父目录候选缓存。
|
||
"""
|
||
normalized = Path(dir_path).as_posix().rstrip("/")
|
||
return normalized or "/"
|
||
|
||
def _get_dir_key(self, dir_item: FileItem) -> Tuple[str, str]:
|
||
"""
|
||
获取目录缓存键。
|
||
"""
|
||
return dir_item.storage, self._normalize_dir_path(dir_item.path)
|
||
|
||
def _get_file_parent_key(self, current_item: FileItem) -> Tuple[str, str]:
|
||
"""
|
||
获取文件父目录缓存键。
|
||
"""
|
||
return (
|
||
current_item.storage,
|
||
self._normalize_dir_path(Path(current_item.path).parent),
|
||
)
|
||
|
||
|
||
class ManualHistoryMixin:
|
||
@staticmethod
|
||
def _get_subscribe_custom_words(
|
||
history_record: Optional[DownloadHistory],
|
||
) -> Optional[List[str]]:
|
||
"""
|
||
获取整理用自定义识别词:优先使用下载时保存的快照,无快照(历史旧记录)时再按来源实时反查订阅。
|
||
|
||
快照优先可避免整理阶段因订阅季号漂移、来源解析失败或订阅完成被删导致识别词丢失,从而原样入库到偏移前的季集。
|
||
"""
|
||
if not history_record:
|
||
return None
|
||
# 下载时保存的完整订阅识别词快照优先
|
||
if history_record.custom_words:
|
||
return history_record.custom_words.split("\n")
|
||
# 兜底:历史旧记录无快照时,按下载来源实时反查订阅
|
||
if not isinstance(history_record.note, dict):
|
||
return None
|
||
subscribe = SubscribeChain().get_subscribe_by_source(
|
||
history_record.note.get("source")
|
||
)
|
||
return (
|
||
subscribe.custom_words.split("\n")
|
||
if subscribe and subscribe.custom_words
|
||
else None
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_successful_move_history(history: Optional[TransferHistory]) -> bool:
|
||
"""判断历史记录是否为已成功完成的移动类整理。"""
|
||
return bool(
|
||
history
|
||
and history.status
|
||
and history.mode
|
||
and "move" in history.mode
|
||
)
|
||
|
||
def _get_manual_transfer_history(
|
||
self,
|
||
fileitem: FileItem,
|
||
transfer_history_oper: Any,
|
||
include_move_dest: bool = False,
|
||
) -> Optional[TransferHistory]:
|
||
"""查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。"""
|
||
# resolve_history 在命中失败记录时会再确认一次有无成功记录,
|
||
# 避免 get_by_src 无排序导致同源多行时返回哪条不确定
|
||
history = resolve_history(
|
||
fileitem.path,
|
||
storage=fileitem.storage,
|
||
transfer_history_oper=transfer_history_oper,
|
||
)
|
||
if history or not include_move_dest:
|
||
return history
|
||
|
||
history = transfer_history_oper.get_by_dest(
|
||
fileitem.path,
|
||
storage=fileitem.storage,
|
||
)
|
||
return history if self._is_successful_move_history(history) else None
|
||
|
||
def get_manual_transfer_histories(
|
||
self,
|
||
fileitems: List[FileItem],
|
||
) -> List[TransferHistory]:
|
||
"""
|
||
查询文件或目录命中的成功整理记录,供手动整理界面显示重整状态。
|
||
|
||
:param fileitems: 待查询的文件或目录项
|
||
:return: 去重后的成功整理记录
|
||
"""
|
||
transfer_history_oper = get_chain_transfer_history_port()
|
||
histories: Dict[int, TransferHistory] = {}
|
||
for fileitem in fileitems or []:
|
||
if not fileitem or not fileitem.path:
|
||
continue
|
||
storage = fileitem.storage or "local"
|
||
if fileitem.type == "dir":
|
||
matched_histories = transfer_history_oper.list_success_by_src(
|
||
fileitem.path,
|
||
storage=storage,
|
||
recursive=True,
|
||
)
|
||
matched_histories.extend(
|
||
transfer_history_oper.list_success_move_by_dest(
|
||
fileitem.path,
|
||
storage=storage,
|
||
recursive=True,
|
||
)
|
||
)
|
||
else:
|
||
history = self._get_manual_transfer_history(
|
||
fileitem=fileitem,
|
||
transfer_history_oper=transfer_history_oper,
|
||
include_move_dest=True,
|
||
)
|
||
matched_histories = [history] if history and history.status else []
|
||
|
||
for history in matched_histories:
|
||
histories[history.id] = history
|
||
return list(histories.values())
|
||
|
||
@staticmethod
|
||
def _delete_manual_transfer_history(
|
||
history: TransferHistory,
|
||
transfer_history_oper: Any,
|
||
) -> Tuple[bool, str]:
|
||
"""删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。"""
|
||
if (
|
||
history.dest_fileitem
|
||
and not ManualHistoryMixin._is_successful_move_history(history)
|
||
):
|
||
dest_fileitem = FileItem(**history.dest_fileitem)
|
||
storage_chain = StorageChain()
|
||
if (
|
||
storage_chain.exists(dest_fileitem)
|
||
and not storage_chain.delete_media_file(dest_fileitem)
|
||
):
|
||
return False, f"{dest_fileitem.path} 删除失败"
|
||
transfer_history_oper.delete(history.id)
|
||
# 删除记录是用户显式要求重来,失败计数一并清零,否则重整仍会受上一轮次数限制
|
||
clear_transfer_failures(history.src, history.src_storage)
|
||
return True, ""
|
||
|
||
|
||
class FailedRetryMixin:
|
||
@staticmethod
|
||
def build_failed_transfer_buttons(
|
||
history_id: Optional[int],
|
||
) -> Optional[List[List[dict]]]:
|
||
"""
|
||
构建整理失败通知的操作按钮。
|
||
"""
|
||
if not history_id:
|
||
return None
|
||
return [
|
||
[
|
||
{"text": "重试", "callback_data": f"transfer_retry_{history_id}"},
|
||
{
|
||
"text": "智能助手接管",
|
||
"callback_data": f"transfer_ai_retry_{history_id}",
|
||
},
|
||
]
|
||
]
|
||
|
||
def redo_transfer_history(self, history_id: int) -> Tuple[bool, str]:
|
||
"""
|
||
按历史记录直接重新整理,自动重新识别媒体信息。
|
||
"""
|
||
return self._re_transfer(logid=history_id)
|
||
|
||
@staticmethod
|
||
def parse_failed_transfer_callback(
|
||
callback_data: str,
|
||
) -> Optional[tuple[str, int]]:
|
||
"""
|
||
解析整理失败通知按钮回调。
|
||
"""
|
||
for prefix, action in (
|
||
("transfer_retry_", "retry"),
|
||
("transfer_ai_retry_", "ai_retry"),
|
||
):
|
||
if callback_data.startswith(prefix):
|
||
history_id = callback_data.replace(prefix, "", 1)
|
||
if history_id.isdigit():
|
||
return action, int(history_id)
|
||
return None
|
||
|
||
def handle_failed_transfer_callback(
|
||
self,
|
||
*,
|
||
callback_data: str,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
) -> bool:
|
||
"""
|
||
处理整理失败通知中的重试类按钮。
|
||
"""
|
||
callback = self.parse_failed_transfer_callback(callback_data)
|
||
if not callback:
|
||
return False
|
||
|
||
action, history_id = callback
|
||
if action == "retry":
|
||
self._retry_transfer_history(
|
||
history_id=history_id,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
else:
|
||
self._take_over_transfer_history_by_ai(
|
||
history_id=history_id,
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
)
|
||
return True
|
||
|
||
def _retry_transfer_history(
|
||
self,
|
||
history_id: int,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
) -> None:
|
||
"""
|
||
立即重新整理一条失败的整理记录。
|
||
"""
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=f"开始重新整理记录 #{history_id} ...",
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
state, errmsg = self.redo_transfer_history(history_id)
|
||
if state:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=f"整理记录 #{history_id} 已重新整理",
|
||
link=self.runtime_config.history_url,
|
||
save_history=False,
|
||
)
|
||
)
|
||
return
|
||
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="重新整理失败",
|
||
text=errmsg,
|
||
link=self.runtime_config.history_url,
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
def _take_over_transfer_history_by_ai(
|
||
self,
|
||
history_id: int,
|
||
channel: NotificationChannel,
|
||
source: str,
|
||
userid: Union[str, int],
|
||
username: str,
|
||
) -> None:
|
||
"""
|
||
由智能助手接管一条失败的整理记录。
|
||
"""
|
||
|
||
if not self.runtime_config.ai_agent_enable:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="MoviePilot智能助手未启用,请在系统设置中启用",
|
||
save_history=False,
|
||
)
|
||
)
|
||
return
|
||
|
||
history = get_chain_transfer_history_port().get(history_id)
|
||
if not history:
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="重新整理失败",
|
||
text=f"整理记录 #{history_id} 不存在",
|
||
link=self.runtime_config.history_url,
|
||
save_history=False,
|
||
)
|
||
)
|
||
return
|
||
|
||
redo_prompt = build_manual_redo_prompt(history)
|
||
|
||
self.post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title=f"已将整理记录 #{history_id} 交给智能助手处理",
|
||
text="处理完成后会在这里回复结果。",
|
||
link=self.runtime_config.history_url,
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
async def _run_ai_takeover():
|
||
final_output = ""
|
||
|
||
def _capture_output(text_output: str):
|
||
nonlocal final_output
|
||
final_output = text_output or ""
|
||
|
||
try:
|
||
manager = get_running_agent_manager()
|
||
if manager is None:
|
||
raise RuntimeError("智能助手服务未运行")
|
||
await manager.run_background_prompt(
|
||
message=redo_prompt,
|
||
session_prefix=f"__agent_manual_redo_{history_id}",
|
||
output_callback=_capture_output,
|
||
reply_mode=ReplyMode.CAPTURE_ONLY,
|
||
allow_message_tools=False,
|
||
)
|
||
await self.async_post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="智能助手整理完成",
|
||
text=final_output.strip()
|
||
or f"整理记录 #{history_id} 已由智能助手处理完成。",
|
||
link=self.runtime_config.history_url,
|
||
save_history=False,
|
||
)
|
||
)
|
||
except Exception as e:
|
||
await self.async_post_message(
|
||
Message(
|
||
channel=channel,
|
||
source=source,
|
||
userid=userid,
|
||
username=username,
|
||
title="智能助手整理失败",
|
||
text=str(e),
|
||
link=self.runtime_config.history_url,
|
||
save_history=False,
|
||
)
|
||
)
|
||
|
||
get_task_registry().submit_threadsafe(
|
||
_run_ai_takeover(),
|
||
loop=global_vars.loop,
|
||
owner="chain.transfer.ai_takeover",
|
||
)
|
||
|
||
def _re_transfer(
|
||
self,
|
||
logid: int,
|
||
mtype: MediaType = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
根据历史记录,重新识别整理,只支持简单条件
|
||
:param logid: 历史记录ID
|
||
:param mtype: 媒体类型
|
||
:param media_source: 媒体数据源
|
||
:param media_id: 数据源原生 ID,必须与 media_source 成对提供
|
||
"""
|
||
# 查询历史记录
|
||
history: TransferHistory = get_chain_transfer_history_port().get(logid)
|
||
if not history:
|
||
logger.error(f"整理记录不存在,ID:{logid}")
|
||
return False, "整理记录不存在"
|
||
# 按源目录路径重新整理
|
||
src_path = Path(history.src)
|
||
if not src_path.exists():
|
||
return False, f"源目录不存在:{src_path}"
|
||
# 查询媒体信息
|
||
explicit_identity = media_source is not None or media_id is not None
|
||
if explicit_identity and (not media_source or not media_id):
|
||
return False, "媒体重新识别需要同时提供 media_source 和 media_id"
|
||
if mtype and media_source and media_id:
|
||
mediainfo = MediaChain().recognize_media(
|
||
mtype=mtype,
|
||
media_source=media_source,
|
||
media_id=media_id,
|
||
music_type=(
|
||
getattr(history, "music_type", None)
|
||
if mtype == MediaType.MUSIC
|
||
else None
|
||
),
|
||
episode_group=history.episode_group,
|
||
)
|
||
if mediainfo and not isinstance(mediainfo, MusicInfo):
|
||
# 更新媒体图片
|
||
self.obtain_images(mediainfo=mediainfo)
|
||
elif history.media_source and history.media_id:
|
||
try:
|
||
history_type = mtype or MediaType(history.type)
|
||
except ValueError:
|
||
history_type = mtype
|
||
mediainfo = MediaChain().recognize_media(
|
||
mtype=history_type,
|
||
media_source=history.media_source,
|
||
media_id=history.media_id,
|
||
music_type=(
|
||
getattr(history, "music_type", None)
|
||
if history_type == MediaType.MUSIC
|
||
else None
|
||
),
|
||
episode_group=history.episode_group,
|
||
)
|
||
mtype = history_type
|
||
if mediainfo and not isinstance(mediainfo, MusicInfo):
|
||
self.obtain_images(mediainfo=mediainfo)
|
||
elif mtype == MediaType.MUSIC or self._is_music_retry_source(history, src_path):
|
||
# 音乐重新整理走音乐识别链,避免默认影视识别误入 TMDB
|
||
mtype = MediaType.MUSIC
|
||
mediainfo = self._recognize_music_retry_media(history, src_path)
|
||
else:
|
||
recognize_context = MediaChain().recognize_by_path(
|
||
str(src_path),
|
||
episode_group=history.episode_group,
|
||
obtain_images=True,
|
||
)
|
||
mediainfo = recognize_context.media_info if recognize_context else None
|
||
# 音乐专辑目录允许无预识别信息,由整理链按音频后缀逐文件解析识别
|
||
if not mediainfo and not (mtype == MediaType.MUSIC and src_path.is_dir()):
|
||
return False, (
|
||
f"未识别到媒体信息,类型:{mtype.value if mtype else None},"
|
||
f"media_source:{media_source},media_id:{media_id}"
|
||
)
|
||
# 重新执行整理
|
||
if mediainfo:
|
||
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
||
|
||
# 删除旧的已整理文件
|
||
if history.dest_fileitem:
|
||
# 解析目标文件对象
|
||
dest_fileitem = FileItem(**history.dest_fileitem)
|
||
StorageChain().delete_file(dest_fileitem)
|
||
|
||
# 强制整理
|
||
if history.src_fileitem:
|
||
state, errmsg = self.do_transfer(
|
||
fileitem=FileItem(**history.src_fileitem),
|
||
mediainfo=mediainfo,
|
||
mtype=mtype,
|
||
download_hash=history.download_hash,
|
||
force=True,
|
||
background=False,
|
||
manual=True,
|
||
)
|
||
if not state:
|
||
return False, errmsg
|
||
|
||
return True, ""
|