mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-16 03:23:54 +08:00
3390 lines
132 KiB
Python
3390 lines
132 KiB
Python
import os
|
||
import re
|
||
from copy import deepcopy
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||
from threading import Lock
|
||
from typing import Any, Iterable, List, Optional, Tuple, Union
|
||
|
||
from fastapi.concurrency import run_in_threadpool
|
||
|
||
from app import schemas
|
||
from app.chain import ChainBase
|
||
from app.chain.music import MusicChain
|
||
from app.chain.storage import StorageChain
|
||
from app.core.cache import async_fresh, cached, fresh
|
||
from app.core.config import settings
|
||
from app.core.context import (
|
||
Context,
|
||
MediaInfo,
|
||
MusicAlbumInfo,
|
||
MusicInfo,
|
||
MusicLyrics,
|
||
)
|
||
from app.core.event import eventmanager, Event
|
||
from app.core.meta import MetaBase, MetaMusic
|
||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||
from app.db.systemconfig_oper import SystemConfigOper
|
||
from app.helper.audio import AudioMetadataHelper
|
||
from app.log import logger
|
||
from app.schemas import FileItem
|
||
from app.schemas.types import (
|
||
MUSIC_ENTITY_ALBUM,
|
||
MUSIC_ENTITY_RECORDING,
|
||
ChainEventType,
|
||
EventType,
|
||
MediaType,
|
||
ScrapingTarget,
|
||
ScrapingMetadata,
|
||
ScrapingPolicy,
|
||
SystemConfigKey,
|
||
)
|
||
from app.utils.http import RequestUtils
|
||
from app.utils.media import (
|
||
is_music_media_source,
|
||
normalize_media_source,
|
||
)
|
||
from app.utils.mixins import ConfigReloadMixin
|
||
from app.utils.singleton import Singleton
|
||
from app.utils.string import StringUtils
|
||
|
||
recognize_lock = Lock()
|
||
scraping_lock = Lock()
|
||
|
||
current_umask = os.umask(0)
|
||
os.umask(current_umask)
|
||
|
||
|
||
@dataclass
|
||
class _MusicScrapeFileResult:
|
||
"""记录单个音轨的标签刮削结果和歌词处理状态。"""
|
||
|
||
metadata_success: bool = True
|
||
lyrics_status: str = "disabled"
|
||
|
||
|
||
class ScrapingOption:
|
||
"""刮削选项"""
|
||
|
||
type: ScrapingTarget = ScrapingTarget.TV
|
||
metadata: ScrapingMetadata = ScrapingMetadata.NFO
|
||
policy: ScrapingPolicy = ScrapingPolicy.MISSINGONLY
|
||
|
||
def __init__(
|
||
self,
|
||
type: Union[str, ScrapingTarget],
|
||
metadata: Union[str, ScrapingMetadata],
|
||
value: Union[ScrapingPolicy, bool, str],
|
||
):
|
||
if isinstance(type, ScrapingTarget):
|
||
self.type = type
|
||
elif isinstance(type, str):
|
||
self.type = ScrapingTarget(type)
|
||
if isinstance(metadata, ScrapingMetadata):
|
||
self.metadata = metadata
|
||
elif isinstance(metadata, str):
|
||
self.metadata = ScrapingMetadata(metadata)
|
||
if isinstance(value, bool):
|
||
# 兼容旧的布尔值格式
|
||
self.policy = ScrapingPolicy.MISSINGONLY if value else ScrapingPolicy.SKIP
|
||
elif isinstance(value, ScrapingPolicy):
|
||
self.policy = value
|
||
elif isinstance(value, str):
|
||
self.policy = ScrapingPolicy(value)
|
||
else:
|
||
logger.error(
|
||
f"无效的刮削选项:type={type}, metadata={metadata}, value={value}"
|
||
)
|
||
|
||
@property
|
||
def is_skip(self) -> bool:
|
||
"""是否跳过"""
|
||
return self.policy == ScrapingPolicy.SKIP
|
||
|
||
@property
|
||
def is_overwrite(self) -> bool:
|
||
"""是否覆盖模式"""
|
||
return self.policy == ScrapingPolicy.OVERWRITE
|
||
|
||
|
||
class ScrapingConfig:
|
||
"""媒体刮削配置"""
|
||
|
||
def __init__(self, config_dict: dict[str, str] = None):
|
||
"""
|
||
初始化配置对象
|
||
:param config_dict: 用户配置字典(扁平化格式),为 None 时使用默认配置
|
||
"""
|
||
self._policies: dict[tuple[str, str], ScrapingOption] = {}
|
||
# 合并用户配置和默认配置
|
||
if config_dict is None:
|
||
config_dict = {}
|
||
|
||
# 以默认配置为基础,用用户配置覆盖
|
||
_config = self.get_default_config()
|
||
for key, value in config_dict.items():
|
||
_config[key] = value
|
||
|
||
for key, value in _config.items():
|
||
if "_" in key:
|
||
items = key.split("_", 1)
|
||
self._policies[tuple(items)] = ScrapingOption(*items, value)
|
||
|
||
def option(
|
||
self, item: Union[str, ScrapingTarget], metadata: Union[str, ScrapingMetadata]
|
||
) -> ScrapingOption:
|
||
|
||
if isinstance(item, ScrapingTarget):
|
||
item = item.name.lower()
|
||
if isinstance(metadata, ScrapingMetadata):
|
||
metadata = metadata.name.lower()
|
||
|
||
return self._policies.get(
|
||
(item, metadata), ScrapingOption(item, metadata, ScrapingPolicy.SKIP)
|
||
)
|
||
|
||
@classmethod
|
||
def from_system_config(cls) -> "ScrapingConfig":
|
||
"""
|
||
从系统配置加载
|
||
|
||
:return: MediaScrapingConfig 实例
|
||
"""
|
||
user_config = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {}
|
||
return cls(user_config)
|
||
|
||
@staticmethod
|
||
def get_default_config() -> dict[str, str]:
|
||
"""获取默认配置字典"""
|
||
config_items = [
|
||
f"{mt}_{md}"
|
||
for mt, mds in [
|
||
(
|
||
"movie",
|
||
["nfo", "poster", "backdrop", "logo", "disc", "banner", "thumb", "clearart", "landscape"],
|
||
),
|
||
("tv", ["nfo", "poster", "backdrop", "logo", "banner", "thumb", "clearart", "landscape"]),
|
||
("season", ["nfo", "poster", "backdrop", "banner", "thumb", "landscape"]),
|
||
("episode", ["nfo", "thumb"]),
|
||
("music", ["nfo", "poster", "lyrics"]),
|
||
]
|
||
for md in mds
|
||
]
|
||
return {item: ScrapingPolicy.MISSINGONLY for item in config_items}
|
||
|
||
|
||
class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||
"""
|
||
媒体信息处理链,单例运行
|
||
"""
|
||
|
||
CONFIG_WATCH = {SystemConfigKey.ScrapingSwitchs.value}
|
||
|
||
IMAGE_METADATA_MAP = {
|
||
"poster": ScrapingMetadata.POSTER,
|
||
"backdrop": ScrapingMetadata.BACKDROP,
|
||
"fanart": ScrapingMetadata.BACKDROP,
|
||
"background": ScrapingMetadata.BACKDROP,
|
||
"logo": ScrapingMetadata.LOGO,
|
||
"disc": ScrapingMetadata.DISC,
|
||
"cdart": ScrapingMetadata.DISC,
|
||
"banner": ScrapingMetadata.BANNER,
|
||
"thumb": ScrapingMetadata.THUMB,
|
||
"landscape": ScrapingMetadata.LANDSCAPE,
|
||
"clearart": ScrapingMetadata.CLEARART,
|
||
}
|
||
|
||
IMAGE_ALIASES = {
|
||
"backdrop": ["fanart"],
|
||
"fanart": ["backdrop"],
|
||
"thumb": ["landscape"],
|
||
"landscape": ["thumb"],
|
||
}
|
||
|
||
MUSIC_LYRICS_EXTENSIONS = (".lrc", ".txt")
|
||
_music_track_prefix_pattern = re.compile(
|
||
r"^\s*(?:(?:cd|disc)\s*\d+\s*[-_. ]+)?(?:\d+\s*[-_. ]+)+",
|
||
flags=re.IGNORECASE,
|
||
)
|
||
_video_primary_source = "themoviedb"
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.storagechain = StorageChain()
|
||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||
|
||
def _run_native_media_recognize(
|
||
self,
|
||
module_kwargs: dict,
|
||
cache: bool,
|
||
) -> Optional[MediaInfo]:
|
||
"""统一同步媒体识别路由,未指定来源时影视和音乐只使用各自主数据源。"""
|
||
meta = module_kwargs.get("meta")
|
||
mtype = module_kwargs.get("mtype")
|
||
source = module_kwargs.get("source")
|
||
if (
|
||
isinstance(meta, MetaMusic)
|
||
or mtype == MediaType.MUSIC
|
||
or is_music_media_source(source)
|
||
):
|
||
music_chain = MusicChain()
|
||
if source:
|
||
recognize_kwargs = {
|
||
"source": source,
|
||
"meta": meta if isinstance(meta, MetaMusic) else None,
|
||
"mediaid": module_kwargs.get("mediaid"),
|
||
"cache": cache,
|
||
}
|
||
if "music_type" in module_kwargs:
|
||
recognize_kwargs["music_type"] = module_kwargs["music_type"]
|
||
with fresh(not cache):
|
||
return music_chain.recognize_from_source(**recognize_kwargs)
|
||
if isinstance(meta, MetaMusic):
|
||
return music_chain.recognize_best(meta=meta, cache=cache)
|
||
return None
|
||
if not source and isinstance(meta, MetaBase):
|
||
module_kwargs = {**module_kwargs, "source": self._video_primary_source}
|
||
return super()._run_native_media_recognize(module_kwargs, cache)
|
||
|
||
async def _async_run_native_media_recognize(
|
||
self,
|
||
module_kwargs: dict,
|
||
cache: bool,
|
||
) -> Optional[MediaInfo]:
|
||
"""统一异步媒体识别路由,未指定来源时影视和音乐只使用各自主数据源。"""
|
||
meta = module_kwargs.get("meta")
|
||
mtype = module_kwargs.get("mtype")
|
||
source = module_kwargs.get("source")
|
||
if (
|
||
isinstance(meta, MetaMusic)
|
||
or mtype == MediaType.MUSIC
|
||
or is_music_media_source(source)
|
||
):
|
||
music_chain = MusicChain()
|
||
if source:
|
||
recognize_kwargs = {
|
||
"source": source,
|
||
"meta": meta if isinstance(meta, MetaMusic) else None,
|
||
"mediaid": module_kwargs.get("mediaid"),
|
||
"cache": cache,
|
||
}
|
||
if "music_type" in module_kwargs:
|
||
recognize_kwargs["music_type"] = module_kwargs["music_type"]
|
||
async with async_fresh(not cache):
|
||
return await music_chain.async_recognize_from_source(
|
||
**recognize_kwargs
|
||
)
|
||
if isinstance(meta, MetaMusic):
|
||
return await music_chain.async_recognize_best(meta=meta, cache=cache)
|
||
return None
|
||
if not source and isinstance(meta, MetaBase):
|
||
module_kwargs = {**module_kwargs, "source": self._video_primary_source}
|
||
return await super()._async_run_native_media_recognize(module_kwargs, cache)
|
||
|
||
def on_config_changed(self):
|
||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||
|
||
@staticmethod
|
||
def _cleanup_temp_file(path: Optional[Path]):
|
||
"""
|
||
清理临时刮削文件
|
||
|
||
:param path: 临时文件路径
|
||
"""
|
||
if not path or not path.exists():
|
||
return
|
||
try:
|
||
path.unlink()
|
||
except OSError as err:
|
||
logger.warn(f"临时文件清理失败:{path} - {err}")
|
||
|
||
@staticmethod
|
||
def _should_scrape(
|
||
scraping_option: ScrapingOption,
|
||
file_exists: bool,
|
||
global_overwrite: bool = False,
|
||
) -> bool:
|
||
"""
|
||
判断是否应该执行刮削操作
|
||
|
||
:param scraping_option: 刮削选项对象
|
||
:param file_exists: 文件是否已存在
|
||
:param global_overwrite: 全局覆盖标志
|
||
:return bool: 是否应该刮削
|
||
"""
|
||
if scraping_option.is_skip:
|
||
logger.info(
|
||
f"{scraping_option.type.value} {scraping_option.metadata.value} 刮削策略 {scraping_option.policy.value}"
|
||
)
|
||
return False
|
||
|
||
if not file_exists:
|
||
# 文件不存在
|
||
return True
|
||
|
||
# 文件存在的情况
|
||
if scraping_option.is_overwrite or global_overwrite:
|
||
logger.info(
|
||
f"{scraping_option.type.value} {scraping_option.metadata.value} 文件存在,"
|
||
f"{'配置为覆盖' if scraping_option.is_overwrite else '配置为全局覆盖'}"
|
||
)
|
||
return True
|
||
else:
|
||
logger.info(
|
||
f"{scraping_option.type.value} {scraping_option.metadata.value} 文件已存在,跳过"
|
||
)
|
||
return False
|
||
|
||
def _save_file(
|
||
self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str]
|
||
):
|
||
"""
|
||
保存或上传文件
|
||
|
||
:param fileitem: 关联的媒体文件项
|
||
:param path: 元数据文件路径
|
||
:param content: 文件内容
|
||
"""
|
||
if not fileitem or not content or not path:
|
||
return
|
||
tmp_file_path = None
|
||
try:
|
||
# delete_on_close 是 Python 3.12 才支持的参数,使用 delete=False 后手动清理以兼容低版本。
|
||
with NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp_file:
|
||
tmp_file_path = Path(tmp_file.name)
|
||
# 写入内容
|
||
if isinstance(content, bytes):
|
||
tmp_file.write(content)
|
||
else:
|
||
tmp_file.write(content.encode("utf-8"))
|
||
tmp_file.flush()
|
||
|
||
# 刮削文件只需要读写权限
|
||
tmp_file_path.chmod(0o666 & ~current_umask)
|
||
|
||
# 上传文件
|
||
item = self.storagechain.upload_file(
|
||
fileitem=fileitem, path=tmp_file_path, new_name=path.name
|
||
)
|
||
if item:
|
||
logger.info(f"已保存文件:{item.path}")
|
||
else:
|
||
logger.warn(f"文件保存失败:{path}")
|
||
finally:
|
||
self._cleanup_temp_file(tmp_file_path)
|
||
|
||
def _download_and_save_image(
|
||
self, fileitem: schemas.FileItem, path: Path, url: str
|
||
):
|
||
"""
|
||
流式下载图片并保存到文件
|
||
|
||
:param fileitem: 关联的媒体文件项
|
||
:param path: 图片文件路径
|
||
:param url: 图片下载URL
|
||
"""
|
||
if not fileitem or not url or not path:
|
||
return
|
||
try:
|
||
logger.info(f"正在下载图片:{url} ...")
|
||
request_utils = RequestUtils(
|
||
proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT
|
||
)
|
||
with request_utils.get_stream(url=url) as r:
|
||
if r and r.status_code == 200:
|
||
tmp_file_path = None
|
||
try:
|
||
# delete_on_close 是 Python 3.12 才支持的参数,使用 delete=False 后手动清理以兼容低版本。
|
||
with NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp_file:
|
||
tmp_file_path = Path(tmp_file.name)
|
||
# 流式写入文件
|
||
for chunk in r.iter_content(chunk_size=8192):
|
||
if chunk:
|
||
tmp_file.write(chunk)
|
||
tmp_file.flush()
|
||
|
||
# 刮削的图片只需要读写权限
|
||
tmp_file_path.chmod(0o666 & ~current_umask)
|
||
|
||
# 上传文件
|
||
item = self.storagechain.upload_file(
|
||
fileitem=fileitem, path=tmp_file_path, new_name=path.name
|
||
)
|
||
if item:
|
||
logger.info(f"已保存图片:{item.path}")
|
||
else:
|
||
logger.warn(f"图片保存失败:{path}")
|
||
finally:
|
||
self._cleanup_temp_file(tmp_file_path)
|
||
else:
|
||
logger.info(f"{url} 图片下载失败")
|
||
except Exception as err:
|
||
logger.error(f"{url} 图片下载失败:{str(err)}!")
|
||
|
||
def _get_target_fileitem_and_path(
|
||
self,
|
||
current_fileitem: schemas.FileItem,
|
||
item_type: ScrapingTarget,
|
||
metadata_type: ScrapingMetadata,
|
||
filename_hint: Optional[str] = None,
|
||
parent_fileitem: Optional[schemas.FileItem] = None,
|
||
) -> Tuple[schemas.FileItem, Optional[Path]]:
|
||
"""
|
||
根据当前上下文、刮削项类型和元数据类型生成目标 FileItem 和 Path
|
||
处理 NFO 和图片文件的命名约定及存储位置
|
||
"""
|
||
# 默认保存的目录是当前文件项的目录
|
||
target_dir_item = current_fileitem
|
||
target_dir_path = Path(current_fileitem.path)
|
||
final_filename = filename_hint # 如果提供了 filename_hint,优先使用
|
||
|
||
# 针对 NFO 文件的特殊命名和存储逻辑
|
||
if metadata_type == ScrapingMetadata.NFO:
|
||
if item_type == ScrapingTarget.MOVIE:
|
||
if current_fileitem.type == "file":
|
||
# 电影文件NFO: 放在电影文件同级目录,名称与电影文件主体一致,后缀.nfo
|
||
final_filename = f"{target_dir_path.stem}.nfo"
|
||
target_dir_item = (
|
||
parent_fileitem
|
||
or self.storagechain.get_parent_item(current_fileitem)
|
||
)
|
||
if not target_dir_item:
|
||
logger.error(
|
||
f"无法获取文件 {current_fileitem.path} 的父目录项。"
|
||
)
|
||
return (
|
||
current_fileitem,
|
||
None,
|
||
) # 返回一个表示失败的FileItem和None
|
||
target_dir_path = Path(target_dir_item.path)
|
||
else: # current_fileitem.type == "dir"
|
||
# 电影目录NFO (例如蓝光原盘): 放在电影目录内,名称与目录名主体一致,后缀.nfo
|
||
final_filename = f"{target_dir_path.name}.nfo"
|
||
# target_dir_item 保持为 current_fileitem
|
||
# target_dir_path 保持为 Path(current_fileitem.path)
|
||
elif item_type == ScrapingTarget.TV:
|
||
# 电视剧根目录NFO: 放在剧集根目录内,命名为 tvshow.nfo
|
||
final_filename = "tvshow.nfo"
|
||
elif item_type == ScrapingTarget.SEASON:
|
||
# 电视剧季目录NFO: 放在季目录内,命名为 season.nfo
|
||
final_filename = "season.nfo"
|
||
elif item_type == ScrapingTarget.EPISODE:
|
||
# 电视剧集文件NFO: 放在集文件同级目录,名称与集文件主体一致,后缀.nfo
|
||
final_filename = f"{target_dir_path.stem}.nfo"
|
||
target_dir_item = parent_fileitem or self.storagechain.get_parent_item(
|
||
current_fileitem
|
||
)
|
||
if not target_dir_item:
|
||
logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。")
|
||
return current_fileitem, None # 返回一个表示失败的FileItem和None
|
||
target_dir_path = Path(target_dir_item.path)
|
||
# 图片通常是放在当前目录 (current_fileitem) 下
|
||
# Jellyfin/Kodi 等在季目录内使用通用图片名,而不是 season01-poster.jpg
|
||
elif item_type == ScrapingTarget.SEASON:
|
||
season_image_name_map = {
|
||
ScrapingMetadata.POSTER: "poster",
|
||
ScrapingMetadata.BANNER: "banner",
|
||
ScrapingMetadata.THUMB: "thumb",
|
||
ScrapingMetadata.BACKDROP: "backdrop",
|
||
ScrapingMetadata.LANDSCAPE: "landscape",
|
||
}
|
||
if season_image_name := season_image_name_map.get(metadata_type):
|
||
hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg"
|
||
final_filename = f"{season_image_name}{hint_ext}"
|
||
elif item_type == ScrapingTarget.MOVIE and current_fileitem.type == "file":
|
||
# 电影文件的图片应与视频文件同级保存,避免把图片路径拼到文件名下面。
|
||
target_dir_item = parent_fileitem or self.storagechain.get_parent_item(
|
||
current_fileitem
|
||
)
|
||
if not target_dir_item:
|
||
logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。")
|
||
return current_fileitem, None
|
||
target_dir_path = Path(target_dir_item.path)
|
||
# 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,文件名与视频文件一致
|
||
elif (
|
||
metadata_type in [ScrapingMetadata.THUMB]
|
||
and item_type == ScrapingTarget.EPISODE
|
||
):
|
||
hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg"
|
||
final_filename = f"{target_dir_path.stem}{hint_ext}"
|
||
target_dir_item = parent_fileitem or self.storagechain.get_parent_item(
|
||
current_fileitem
|
||
)
|
||
if not target_dir_item:
|
||
logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。")
|
||
return current_fileitem, None # 返回一个表示失败的FileItem和None
|
||
target_dir_path = Path(target_dir_item.path)
|
||
# TODO: 考虑其他图片类型是否也需要保存到父目录
|
||
|
||
# 确保最终有文件名
|
||
if not final_filename:
|
||
logger.error(
|
||
f"无法为 {item_type.value} - {metadata_type.value} 确定文件名。filename_hint: {filename_hint}"
|
||
)
|
||
# 返回一个表示失败的FileItem和None
|
||
return current_fileitem, None
|
||
|
||
target_full_path = target_dir_path / final_filename
|
||
return target_dir_item, target_full_path
|
||
|
||
def _get_target_fileitems_and_paths(
|
||
self,
|
||
current_fileitem: schemas.FileItem,
|
||
item_type: ScrapingTarget,
|
||
metadata_type: ScrapingMetadata,
|
||
filename_hint: Optional[str] = None,
|
||
parent_fileitem: Optional[schemas.FileItem] = None,
|
||
) -> List[Tuple[schemas.FileItem, Path]]:
|
||
"""
|
||
根据刮削上下文生成一个或多个保存目标。
|
||
季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名。
|
||
"""
|
||
target_item, target_path = self._get_target_fileitem_and_path(
|
||
current_fileitem=current_fileitem,
|
||
item_type=item_type,
|
||
metadata_type=metadata_type,
|
||
filename_hint=filename_hint,
|
||
parent_fileitem=parent_fileitem,
|
||
)
|
||
targets = [(target_item, target_path)] if target_path else []
|
||
|
||
if (
|
||
item_type != ScrapingTarget.SEASON
|
||
or not filename_hint
|
||
or not filename_hint.lower().startswith("season")
|
||
or metadata_type not in {
|
||
ScrapingMetadata.POSTER,
|
||
ScrapingMetadata.BANNER,
|
||
ScrapingMetadata.THUMB,
|
||
ScrapingMetadata.BACKDROP,
|
||
ScrapingMetadata.LANDSCAPE,
|
||
}
|
||
):
|
||
return targets
|
||
|
||
season_parent_item = parent_fileitem or self.storagechain.get_parent_item(
|
||
current_fileitem
|
||
)
|
||
if not season_parent_item:
|
||
logger.warn(f"无法获取季目录 {current_fileitem.path} 的父目录项,跳过根目录季图片")
|
||
return targets
|
||
|
||
season_root_path = Path(current_fileitem.path).with_name(filename_hint)
|
||
root_target = (season_parent_item, season_root_path)
|
||
if root_target not in targets:
|
||
targets.insert(0, root_target)
|
||
return targets
|
||
|
||
def _expand_with_aliases(
|
||
self,
|
||
targets: List[Tuple[schemas.FileItem, Path]],
|
||
item_type: ScrapingTarget,
|
||
) -> List[Tuple[schemas.FileItem, Path]]:
|
||
"""
|
||
为兼容多媒体服务器,扩展图片保存目标列表,添加别名文件。
|
||
例如 backdrop.jpg 同时保存为 fanart.jpg,thumb.jpg 同时保存为 landscape.jpg。
|
||
"""
|
||
expanded = list(targets)
|
||
for base_item, image_path in list(targets):
|
||
if not image_path:
|
||
continue
|
||
stem = image_path.stem.lower()
|
||
ext = image_path.suffix
|
||
# 跳过 season 前缀文件(如 season01-poster.jpg)
|
||
if stem.startswith("season"):
|
||
continue
|
||
aliases = self.IMAGE_ALIASES.get(stem)
|
||
if not aliases:
|
||
continue
|
||
for alias in aliases:
|
||
alias_meta_type = self.IMAGE_METADATA_MAP.get(alias)
|
||
if alias_meta_type:
|
||
alias_option = self.scraping_policies.option(item_type, alias_meta_type)
|
||
if alias_option.is_skip:
|
||
continue
|
||
alias_path = image_path.with_name(f"{alias}{ext}")
|
||
alias_target = (base_item, alias_path)
|
||
if alias_target not in expanded:
|
||
expanded.append(alias_target)
|
||
return expanded
|
||
|
||
def metadata_nfo(
|
||
self,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
season: Optional[int] = None,
|
||
episode: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""
|
||
获取NFO文件内容文本
|
||
|
||
:param meta: 元数据
|
||
:param mediainfo: 媒体信息
|
||
:param season: 季号
|
||
:param episode: 集号
|
||
"""
|
||
return self.run_module(
|
||
"metadata_nfo",
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
season=season,
|
||
episode=episode,
|
||
)
|
||
|
||
def metadata_img(
|
||
self,
|
||
mediainfo: MediaInfo,
|
||
season: Optional[int] = None,
|
||
episode: Optional[int] = None,
|
||
) -> Optional[dict]:
|
||
"""
|
||
获取图片名称和url,合并所有模块的结果。
|
||
优先使用高优先级模块的图片,低优先级模块补充缺失的图片类型。
|
||
"""
|
||
merged = {}
|
||
for module in sorted(
|
||
self.modulemanager.get_running_modules("metadata_img"),
|
||
key=lambda x: x.get_priority(),
|
||
):
|
||
try:
|
||
result = module.metadata_img(
|
||
mediainfo=mediainfo, season=season, episode=episode
|
||
)
|
||
if result and isinstance(result, dict):
|
||
for name, url in result.items():
|
||
merged.setdefault(name, url)
|
||
except Exception as err:
|
||
logger.error(f"获取 {module.get_name()} 图片失败:{str(err)}")
|
||
return merged or None
|
||
|
||
@staticmethod
|
||
def select_recognize_source(
|
||
log_name: str, log_context: str, native_fn, plugin_fn,
|
||
is_recognized=None,
|
||
plugin_event: ChainEventType = ChainEventType.NameRecognize,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
选择识别模式,插件优先或原生优先
|
||
|
||
:param log_name: 用于日志“标题:...”处的名称(如 file_path.name 或 title)
|
||
:param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title)
|
||
:param native_fn: 原生识别函数
|
||
:param plugin_fn: 插件识别函数
|
||
:param is_recognized: 判定识别结果是否有效的谓词;音乐原生兜底结果无远端身份,
|
||
需视为未识别才会请求辅助识别,影视默认按非空判定
|
||
:param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件
|
||
"""
|
||
if is_recognized is None:
|
||
is_recognized = lambda result: bool(result)
|
||
mediainfo = None
|
||
plugin_available = eventmanager.check(plugin_event)
|
||
if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available:
|
||
# 插件优先
|
||
logger.info(f"插件识别优先模式已开启。请求辅助识别,标题:{log_name} ...")
|
||
helped = plugin_fn()
|
||
if is_recognized(helped):
|
||
mediainfo = helped
|
||
else:
|
||
logger.info(
|
||
f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别 ..."
|
||
)
|
||
mediainfo = native_fn()
|
||
# 辅助结果不采信时保留原生兜底,避免丢失已有识别结果(音乐原生兜底恒非空)
|
||
if helped and not mediainfo:
|
||
mediainfo = helped
|
||
else:
|
||
# 原生优先
|
||
logger.info(f"开始识别标题:{log_name} ...")
|
||
mediainfo = native_fn()
|
||
if not is_recognized(mediainfo) and plugin_available:
|
||
logger.info(
|
||
f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别 ..."
|
||
)
|
||
helped = plugin_fn()
|
||
if is_recognized(helped):
|
||
mediainfo = helped
|
||
return mediainfo
|
||
|
||
def recognize_by_meta(
|
||
self,
|
||
metainfo: MetaBase,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
obtain_images: bool = False,
|
||
mtype: Optional[MediaType] = None,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
根据主副标题识别媒体信息
|
||
|
||
:param metainfo: 标题解析元数据
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param obtain_images: 是否补充图片
|
||
:param mtype: 上游已确定的媒体类型
|
||
:param music_type: 音乐实体类型,用于约束显式音乐身份及插件结果
|
||
"""
|
||
mediainfo = self._recognize_with_fallback_by_meta(
|
||
metainfo=metainfo,
|
||
mtype=mtype,
|
||
source=source,
|
||
episode_group=episode_group,
|
||
obtain_images=obtain_images,
|
||
music_type=music_type,
|
||
)
|
||
if not mediainfo:
|
||
logger.warn(f"{metainfo.title} 未识别到媒体信息")
|
||
return mediainfo
|
||
|
||
@staticmethod
|
||
def _build_tmdb_supplement_meta(
|
||
mediainfo: MediaInfo,
|
||
metainfo: Optional[MetaBase] = None,
|
||
) -> MetaBase:
|
||
"""
|
||
根据主识别结果构造 TMDB 辅助识别参数。
|
||
|
||
:param mediainfo: 主识别源返回的媒体信息
|
||
:param metainfo: 原始标题解析信息
|
||
:return: 不携带主识别源身份的 TMDB 查询参数
|
||
"""
|
||
title = mediainfo.title or getattr(metainfo, "name", None) or ""
|
||
tmdb_meta = MetaInfo(title)
|
||
if not tmdb_meta.cn_name and getattr(metainfo, "cn_name", None):
|
||
tmdb_meta.cn_name = metainfo.cn_name
|
||
if not tmdb_meta.en_name:
|
||
tmdb_meta.en_name = mediainfo.en_title or (
|
||
getattr(metainfo, "en_name", None)
|
||
)
|
||
tmdb_meta.type = mediainfo.type or (
|
||
getattr(metainfo, "type", None) or MediaType.UNKNOWN
|
||
)
|
||
season = (
|
||
mediainfo.season
|
||
if mediainfo.season is not None
|
||
else getattr(metainfo, "begin_season", None)
|
||
)
|
||
tmdb_meta.begin_season = season
|
||
season_year = None
|
||
if season is not None and mediainfo.season_years:
|
||
season_year = (
|
||
mediainfo.season_years.get(season)
|
||
or mediainfo.season_years.get(str(season))
|
||
)
|
||
tmdb_meta.year = (
|
||
season_year
|
||
or mediainfo.year
|
||
or getattr(metainfo, "year", None)
|
||
)
|
||
return tmdb_meta
|
||
|
||
@staticmethod
|
||
def _merge_tmdb_auxiliary(
|
||
mediainfo: MediaInfo,
|
||
tmdb_media: MediaInfo,
|
||
) -> MediaInfo:
|
||
"""
|
||
将 TMDB 兼容字段合并到主识别结果,不改变主数据源身份和展示信息。
|
||
|
||
:param mediainfo: 主识别源返回的媒体信息
|
||
:param tmdb_media: TMDB 辅助识别结果
|
||
:return: 已补充 TMDB 兼容字段的主媒体信息
|
||
"""
|
||
if not tmdb_media or tmdb_media.source != "themoviedb" or not tmdb_media.tmdb_id:
|
||
return mediainfo
|
||
|
||
mediainfo.tmdb_id = tmdb_media.tmdb_id
|
||
mediainfo.tmdb_info = tmdb_media.tmdb_info or mediainfo.tmdb_info
|
||
if not mediainfo.category:
|
||
mediainfo.category = tmdb_media.category
|
||
if not mediainfo.genre_ids:
|
||
mediainfo.genre_ids = list(tmdb_media.genre_ids or [])
|
||
for field in ("imdb_id", "tvdb_id", "tvdb_slug", "collection_id"):
|
||
if not getattr(mediainfo, field, None):
|
||
setattr(mediainfo, field, getattr(tmdb_media, field, None))
|
||
return mediainfo
|
||
|
||
def supplement_tmdb_info(
|
||
self,
|
||
mediainfo: Optional[MediaInfo],
|
||
metainfo: Optional[MetaBase] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
为任意主识别源补充 TMDB 辅助信息,同时保留原始媒体身份。
|
||
|
||
:param mediainfo: 主识别源返回的媒体信息
|
||
:param metainfo: 原始标题解析信息
|
||
:return: 已补充 TMDB 辅助字段的原媒体对象
|
||
"""
|
||
if not mediainfo:
|
||
return None
|
||
if mediainfo.type == MediaType.MUSIC:
|
||
return mediainfo
|
||
if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids:
|
||
return mediainfo
|
||
tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo)
|
||
tmdb_module = self.modulemanager.get_running_module("TheMovieDbModule")
|
||
if not tmdb_module:
|
||
logger.warn("TMDB 模块未启用,无法补充 TMDB 辅助信息")
|
||
return mediainfo
|
||
try:
|
||
tmdb_media = tmdb_module.recognize_media(
|
||
meta=tmdb_meta,
|
||
mtype=mediainfo.type,
|
||
source="themoviedb",
|
||
mediaid=str(mediainfo.tmdb_id) if mediainfo.tmdb_id else None,
|
||
tmdbid=mediainfo.tmdb_id,
|
||
episode_group=mediainfo.episode_group,
|
||
cache=True,
|
||
)
|
||
except Exception as err:
|
||
logger.warn(f"{mediainfo.title_year} 补充 TMDB 辅助信息失败:{err}")
|
||
return mediainfo
|
||
if not tmdb_media:
|
||
logger.warn(f"{mediainfo.title_year} 未匹配到 TMDB 辅助信息")
|
||
return mediainfo
|
||
return self._merge_tmdb_auxiliary(mediainfo, tmdb_media)
|
||
|
||
def _recognize_with_fallback_by_meta(
|
||
self,
|
||
metainfo: MetaBase,
|
||
mtype: Optional[MediaType] = None,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
obtain_images: bool = False,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
根据标题识别媒体信息,必要时回退到辅助识别。
|
||
|
||
:param metainfo: 标题解析元数据
|
||
:param mtype: 上游已确定的媒体类型
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param obtain_images: 是否补充图片
|
||
:param music_type: 音乐实体类型,用于约束显式音乐身份及插件结果
|
||
:return: 统一媒体信息
|
||
"""
|
||
if not metainfo:
|
||
return None
|
||
title = metainfo.title
|
||
share_meta = deepcopy(metainfo)
|
||
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
|
||
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
|
||
is_recognized = (
|
||
(lambda result: bool(result and result.source)) if is_music else None
|
||
)
|
||
|
||
def native_recognize() -> Optional[MediaInfo]:
|
||
"""使用请求级数据源执行原生识别。"""
|
||
return self.recognize_media(
|
||
meta=metainfo,
|
||
mtype=mtype,
|
||
source=source,
|
||
share_meta=share_meta,
|
||
episode_group=episode_group,
|
||
music_type=music_type,
|
||
)
|
||
|
||
def plugin_recognize() -> Optional[MediaInfo]:
|
||
"""执行辅助识别并保持请求级数据源约束。"""
|
||
if is_music and not isinstance(metainfo, MetaMusic):
|
||
return None
|
||
return self.recognize_help(
|
||
title=title,
|
||
org_meta=metainfo,
|
||
share_meta=share_meta,
|
||
source=source,
|
||
episode_group=episode_group,
|
||
music_type=music_type,
|
||
)
|
||
|
||
# 按 config 中设置的识别顺序识别,影视与音乐共用同一选择流程
|
||
mediainfo = self.select_recognize_source(
|
||
log_name=title,
|
||
log_context=title,
|
||
native_fn=native_recognize,
|
||
plugin_fn=plugin_recognize,
|
||
is_recognized=is_recognized,
|
||
plugin_event=(
|
||
ChainEventType.MusicNameRecognize if is_music
|
||
else ChainEventType.NameRecognize
|
||
),
|
||
)
|
||
if not mediainfo:
|
||
return None
|
||
# 识别成功
|
||
logger.info(
|
||
f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}"
|
||
)
|
||
if obtain_images:
|
||
self.obtain_images(mediainfo=mediainfo)
|
||
return mediainfo
|
||
|
||
@staticmethod
|
||
def _parse_recognize_event_number(value) -> Optional[int]:
|
||
"""
|
||
解析辅助识别返回的季集号,兼容整数和数字字符串并保留数值 0。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
return int(text) if text.isdigit() else None
|
||
|
||
def recognize_help(
|
||
self,
|
||
title: str,
|
||
org_meta: MetaBase,
|
||
share_meta: MetaBase = None,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
请求辅助识别,返回媒体信息;影视与音乐共用同一流程,仅要素事件与重组方式不同
|
||
|
||
:param title: 标题
|
||
:param org_meta: 原始元数据
|
||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param music_type: 音乐实体类型,仅音乐辅助识别使用
|
||
"""
|
||
# 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件
|
||
if isinstance(org_meta, MetaMusic):
|
||
return self._recognize_music_help(
|
||
title=title,
|
||
org_meta=org_meta,
|
||
share_meta=share_meta,
|
||
source=source,
|
||
music_type=music_type,
|
||
)
|
||
# 发送请求事件,等待结果
|
||
result: Event = eventmanager.send_event(
|
||
ChainEventType.NameRecognize,
|
||
{
|
||
"title": title,
|
||
},
|
||
)
|
||
if not result:
|
||
return None
|
||
# 获取返回事件数据
|
||
event_data = result.event_data or {}
|
||
logger.info(f"获取到辅助识别结果:{event_data}")
|
||
# 处理数据格式
|
||
title, year, season_number, episode_number = None, None, None, None
|
||
if event_data.get("name"):
|
||
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||
if event_data.get("year"):
|
||
year = str(event_data["year"]).split("/")[0].strip()
|
||
season_number = self._parse_recognize_event_number(event_data.get("season"))
|
||
episode_number = self._parse_recognize_event_number(event_data.get("episode"))
|
||
if not title:
|
||
return None
|
||
if title == "Unknown":
|
||
return None
|
||
if not str(year).isdigit():
|
||
year = None
|
||
# 结果赋值
|
||
if title == org_meta.name and year == org_meta.year:
|
||
logger.info(f"辅助识别与原始识别结果一致,无需重新识别媒体信息")
|
||
return None
|
||
logger.info(f"辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...")
|
||
org_meta.name = title
|
||
org_meta.year = year
|
||
org_meta.begin_season = season_number
|
||
org_meta.begin_episode = episode_number
|
||
if org_meta.begin_season is not None or org_meta.begin_episode is not None:
|
||
org_meta.type = MediaType.TV
|
||
# 重新识别
|
||
return self.recognize_media(
|
||
meta=org_meta,
|
||
source=source,
|
||
share_meta=share_meta,
|
||
episode_group=episode_group,
|
||
)
|
||
|
||
def _recognize_music_help(
|
||
self,
|
||
title: str,
|
||
org_meta: MetaMusic,
|
||
share_meta: MetaBase = None,
|
||
source: Optional[str] = None,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息
|
||
|
||
:param title: 原始音乐标题
|
||
:param org_meta: 原始音乐元数据
|
||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||
:param source: 请求级识别数据源
|
||
:param music_type: 音乐实体类型
|
||
"""
|
||
# 发送音乐名称识别事件,等待插件返回标题要素
|
||
result: Event = eventmanager.send_event(
|
||
ChainEventType.MusicNameRecognize,
|
||
{
|
||
"title": title,
|
||
"artist": org_meta.artist,
|
||
"album": org_meta.album,
|
||
"year": org_meta.year,
|
||
"music_type": music_type,
|
||
},
|
||
)
|
||
if not result:
|
||
return None
|
||
event_data = result.event_data or {}
|
||
logger.info(f"获取到音乐辅助识别结果:{event_data}")
|
||
name, artist, album, year = self._parse_music_recognize_event(event_data)
|
||
if not name:
|
||
return None
|
||
# 辅助识别要素与原始一致时无需重新匹配
|
||
if (
|
||
name == org_meta.title
|
||
and (not artist or artist in org_meta.artists)
|
||
and (not album or album == org_meta.album)
|
||
and (not year or year == org_meta.year)
|
||
):
|
||
logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息")
|
||
return None
|
||
logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...")
|
||
new_meta = self._build_music_help_meta(
|
||
org_meta=org_meta,
|
||
name=name,
|
||
artist=artist,
|
||
album=album,
|
||
year=year,
|
||
)
|
||
# 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底
|
||
mediainfo = self.recognize_media(
|
||
meta=new_meta,
|
||
source=source,
|
||
share_meta=share_meta,
|
||
music_type=music_type,
|
||
)
|
||
return mediainfo if mediainfo and mediainfo.source else None
|
||
|
||
@staticmethod
|
||
def _parse_music_recognize_event(
|
||
event_data: dict,
|
||
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[int]]:
|
||
"""
|
||
解析音乐辅助识别返回的标题要素,曲名为空或未知时返回 None
|
||
"""
|
||
name = None
|
||
if event_data.get("name"):
|
||
name = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||
artist = None
|
||
if event_data.get("artist"):
|
||
artist = str(event_data["artist"]).split("/")[0].strip()
|
||
album = None
|
||
if event_data.get("album"):
|
||
album = str(event_data["album"]).split("/")[0].strip()
|
||
year = None
|
||
year_text = str(event_data.get("year") or "").split("/")[0].strip()
|
||
if year_text.isdigit():
|
||
year = int(year_text)
|
||
if not name or name == "Unknown":
|
||
name = None
|
||
return name, artist, album, year
|
||
|
||
@staticmethod
|
||
def _build_music_help_meta(
|
||
org_meta: MetaMusic,
|
||
name: str,
|
||
artist: Optional[str],
|
||
album: Optional[str],
|
||
year: Optional[int],
|
||
) -> MetaMusic:
|
||
"""按插件修正标题要素,并保留本地轨道与音频判定证据。"""
|
||
return MetaMusic(
|
||
org_string=org_meta.org_string,
|
||
title=name,
|
||
artists=[artist] if artist else list(org_meta.artists or []),
|
||
album=album or org_meta.album,
|
||
album_artist=artist or org_meta.album_artist,
|
||
year=year or org_meta.year,
|
||
disc_number=org_meta.disc_number,
|
||
track_number=org_meta.track_number,
|
||
total_discs=org_meta.total_discs,
|
||
total_tracks=org_meta.total_tracks,
|
||
version=org_meta.version,
|
||
audio_format=org_meta.audio_format,
|
||
audio_lossless=org_meta.audio_lossless,
|
||
bit_depth=org_meta.bit_depth,
|
||
sample_rate=org_meta.sample_rate,
|
||
bitrate=org_meta.bitrate,
|
||
duration=org_meta.duration,
|
||
isrc=org_meta.isrc,
|
||
)
|
||
|
||
@classmethod
|
||
def is_audio_path(cls, path: Union[str, Path]) -> bool:
|
||
"""判断路径是否指向系统支持的音频文件。"""
|
||
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
|
||
|
||
@classmethod
|
||
def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic:
|
||
"""委托音乐领域链读取路径元数据,保持跨域入口与目录匹配语义一致。"""
|
||
return MusicChain.read_path_meta(path)
|
||
|
||
@classmethod
|
||
def _music_info_from_path_meta(cls, meta: MetaMusic) -> MusicInfo:
|
||
"""把音频标签转换为文件管理可展示的最小音乐信息。"""
|
||
return MusicInfo.from_meta(meta)
|
||
|
||
@staticmethod
|
||
def _merge_music_audio_quality(info: MusicInfo, meta: MetaMusic) -> MusicInfo:
|
||
"""将本地文件的实际音频参数合并到远端音乐身份识别结果。"""
|
||
for key in ("audio_format", "audio_lossless", "bit_depth", "sample_rate", "bitrate"):
|
||
value = getattr(meta, key, None)
|
||
if value is not None:
|
||
setattr(info, key, value)
|
||
return info
|
||
|
||
@staticmethod
|
||
def _clear_music_identity(meta: MetaMusic) -> MetaMusic:
|
||
"""复制音乐元数据并清除远程身份,供直查失败后按要素重新匹配。"""
|
||
clean_meta = MetaMusic.from_dict(meta.to_dict())
|
||
clean_meta.media_source = None
|
||
clean_meta.media_id = None
|
||
return clean_meta
|
||
|
||
@staticmethod
|
||
def _is_remote_music_info(info: Optional[MusicInfo]) -> bool:
|
||
"""判断音乐识别结果是否携带可复用的远程身份。"""
|
||
return bool(info and info.source and info.media_id)
|
||
|
||
@staticmethod
|
||
def _recognize_musicbrainz_recording(
|
||
meta: MetaMusic,
|
||
recording_id: str,
|
||
) -> Optional[MusicInfo]:
|
||
"""按已知 MusicBrainz Recording ID 直接读取单曲详情。"""
|
||
identity_meta = MetaMusic.from_dict(meta.to_dict())
|
||
identity_meta.media_source = "musicbrainz"
|
||
identity_meta.media_id = recording_id
|
||
return MusicChain().recognize_from_source(
|
||
source="musicbrainz",
|
||
meta=identity_meta,
|
||
mediaid=recording_id,
|
||
music_type=MUSIC_ENTITY_RECORDING,
|
||
)
|
||
|
||
@staticmethod
|
||
async def _async_recognize_musicbrainz_recording(
|
||
meta: MetaMusic,
|
||
recording_id: str,
|
||
) -> Optional[MusicInfo]:
|
||
"""异步按已知 MusicBrainz Recording ID 直接读取单曲详情。"""
|
||
identity_meta = MetaMusic.from_dict(meta.to_dict())
|
||
identity_meta.media_source = "musicbrainz"
|
||
identity_meta.media_id = recording_id
|
||
return await MusicChain().async_recognize_from_source(
|
||
source="musicbrainz",
|
||
meta=identity_meta,
|
||
mediaid=recording_id,
|
||
music_type=MUSIC_ENTITY_RECORDING,
|
||
)
|
||
|
||
def _recognize_music_meta_tier(
|
||
self,
|
||
meta: Optional[MetaMusic],
|
||
source: Optional[str],
|
||
tier_name: str,
|
||
) -> Optional[MusicInfo]:
|
||
"""识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
|
||
if not meta:
|
||
return None
|
||
normalized_source = normalize_media_source(source)
|
||
search_meta = meta
|
||
if meta.media_source == "musicbrainz" and meta.media_id:
|
||
if normalized_source in (None, "musicbrainz"):
|
||
direct = self._recognize_musicbrainz_recording(
|
||
meta=meta,
|
||
recording_id=str(meta.media_id),
|
||
)
|
||
if self._is_remote_music_info(direct):
|
||
logger.info(f"音乐识别命中{tier_name}层 MusicBrainz ID 直查")
|
||
return direct
|
||
search_meta = self._clear_music_identity(meta)
|
||
if not search_meta.title:
|
||
return None
|
||
result = self.recognize_media(
|
||
meta=search_meta,
|
||
source=source,
|
||
music_type=MUSIC_ENTITY_RECORDING,
|
||
)
|
||
if self._is_remote_music_info(result):
|
||
logger.info(f"音乐识别命中{tier_name}层:{result.title}")
|
||
return result
|
||
return None
|
||
|
||
async def _async_recognize_music_meta_tier(
|
||
self,
|
||
meta: Optional[MetaMusic],
|
||
source: Optional[str],
|
||
tier_name: str,
|
||
) -> Optional[MusicInfo]:
|
||
"""异步识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
|
||
if not meta:
|
||
return None
|
||
normalized_source = normalize_media_source(source)
|
||
search_meta = meta
|
||
if meta.media_source == "musicbrainz" and meta.media_id:
|
||
if normalized_source in (None, "musicbrainz"):
|
||
direct = await self._async_recognize_musicbrainz_recording(
|
||
meta=meta,
|
||
recording_id=str(meta.media_id),
|
||
)
|
||
if self._is_remote_music_info(direct):
|
||
logger.info(f"音乐识别命中{tier_name}层 MusicBrainz ID 直查")
|
||
return direct
|
||
search_meta = self._clear_music_identity(meta)
|
||
if not search_meta.title:
|
||
return None
|
||
result = await self.async_recognize_media(
|
||
meta=search_meta,
|
||
source=source,
|
||
music_type=MUSIC_ENTITY_RECORDING,
|
||
)
|
||
if self._is_remote_music_info(result):
|
||
logger.info(f"音乐识别命中{tier_name}层:{result.title}")
|
||
return result
|
||
return None
|
||
|
||
@staticmethod
|
||
def _music_album_dir_fallback(path: Union[str, Path]) -> Optional[MusicInfo]:
|
||
"""单曲识别无远端身份时,查找所在目录专辑匹配中属于当前文件的结果。"""
|
||
file_path = Path(path)
|
||
if not file_path.exists() or not file_path.is_file():
|
||
return None
|
||
try:
|
||
matched = MusicChain().recognize_album_directory(file_path.parent)
|
||
except Exception as err:
|
||
logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}")
|
||
return None
|
||
return matched.get(str(file_path.resolve()))
|
||
|
||
@staticmethod
|
||
async def _async_music_album_dir_fallback(
|
||
path: Union[str, Path],
|
||
) -> Optional[MusicInfo]:
|
||
"""异步查找所在目录专辑匹配中属于当前文件的结果。"""
|
||
file_path = Path(path)
|
||
if not file_path.exists() or not file_path.is_file():
|
||
return None
|
||
try:
|
||
matched = await MusicChain().async_recognize_album_directory(
|
||
file_path.parent
|
||
)
|
||
except Exception as err:
|
||
logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}")
|
||
return None
|
||
return matched.get(str(file_path.resolve()))
|
||
|
||
def recognize_music_by_path(
|
||
self,
|
||
path: Union[str, Path],
|
||
source: Optional[str] = None,
|
||
) -> Tuple[MetaMusic, MusicInfo]:
|
||
"""按指纹、文件标签、文件名三级顺序识别本地音乐。"""
|
||
meta, tag_meta, filename_meta = MusicChain.read_path_evidence(path)
|
||
info = None
|
||
normalized_source = normalize_media_source(source)
|
||
if normalized_source in (None, "musicbrainz"):
|
||
recording_id = MusicChain().identify_by_fingerprint(path)
|
||
if recording_id:
|
||
info = self._recognize_musicbrainz_recording(meta, recording_id)
|
||
if self._is_remote_music_info(info):
|
||
logger.info("音乐识别命中 AcoustID 指纹层,已跳过标签和文件名识别")
|
||
if not self._is_remote_music_info(info):
|
||
info = self._recognize_music_meta_tier(
|
||
meta=tag_meta,
|
||
source=source,
|
||
tier_name="文件标签",
|
||
)
|
||
if not self._is_remote_music_info(info):
|
||
info = self._recognize_music_meta_tier(
|
||
meta=filename_meta,
|
||
source=source,
|
||
tier_name="文件名",
|
||
)
|
||
result = self._merge_music_audio_quality(
|
||
info or self._music_info_from_path_meta(meta), meta
|
||
)
|
||
if not result.source and source in (None, "musicbrainz"):
|
||
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
|
||
matched = self._music_album_dir_fallback(path)
|
||
if matched:
|
||
result = self._merge_music_audio_quality(matched, meta)
|
||
return meta, result
|
||
|
||
async def async_recognize_music_by_path(
|
||
self,
|
||
path: Union[str, Path],
|
||
source: Optional[str] = None,
|
||
) -> Tuple[MetaMusic, MusicInfo]:
|
||
"""异步按指纹、文件标签、文件名三级顺序识别本地音乐。"""
|
||
meta, tag_meta, filename_meta = await run_in_threadpool(
|
||
MusicChain.read_path_evidence,
|
||
path,
|
||
)
|
||
info = None
|
||
normalized_source = normalize_media_source(source)
|
||
if normalized_source in (None, "musicbrainz"):
|
||
recording_id = await MusicChain().async_identify_by_fingerprint(path)
|
||
if recording_id:
|
||
info = await self._async_recognize_musicbrainz_recording(
|
||
meta,
|
||
recording_id,
|
||
)
|
||
if self._is_remote_music_info(info):
|
||
logger.info("音乐识别命中 AcoustID 指纹层,已跳过标签和文件名识别")
|
||
if not self._is_remote_music_info(info):
|
||
info = await self._async_recognize_music_meta_tier(
|
||
meta=tag_meta,
|
||
source=source,
|
||
tier_name="文件标签",
|
||
)
|
||
if not self._is_remote_music_info(info):
|
||
info = await self._async_recognize_music_meta_tier(
|
||
meta=filename_meta,
|
||
source=source,
|
||
tier_name="文件名",
|
||
)
|
||
result = self._merge_music_audio_quality(
|
||
info or self._music_info_from_path_meta(meta), meta
|
||
)
|
||
if not result.source and source in (None, "musicbrainz"):
|
||
# 单曲搜索未命中时,按所在目录做专辑级匹配兑底
|
||
matched = await self._async_music_album_dir_fallback(path)
|
||
if matched:
|
||
result = self._merge_music_audio_quality(matched, meta)
|
||
return meta, result
|
||
|
||
def _is_music_path_request(self, path: str, source: Optional[str]) -> bool:
|
||
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
|
||
return self.is_audio_path(path) or is_music_media_source(source)
|
||
|
||
def recognize_by_path(
|
||
self,
|
||
path: str,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
obtain_images: bool = False,
|
||
) -> Optional[Context]:
|
||
"""
|
||
根据文件路径识别媒体信息,影视与音乐统一入口
|
||
|
||
:param path: 文件路径
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param obtain_images: 是否补充图片
|
||
:return: 识别上下文
|
||
"""
|
||
logger.info(f"开始识别媒体信息,文件:{path} ...")
|
||
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
|
||
if self._is_music_path_request(path, source):
|
||
music_meta, music_info = self.recognize_music_by_path(
|
||
path, source=source
|
||
)
|
||
return Context(meta_info=music_meta, media_info=music_info)
|
||
file_path = Path(path)
|
||
# 元数据
|
||
file_meta = MetaInfoPath(file_path)
|
||
mediainfo = self._recognize_with_fallback_by_meta(
|
||
metainfo=file_meta,
|
||
source=source,
|
||
episode_group=episode_group,
|
||
obtain_images=obtain_images,
|
||
)
|
||
if not mediainfo:
|
||
logger.warn(f"{path} 未识别到媒体信息")
|
||
return Context(meta_info=file_meta)
|
||
# 返回上下文
|
||
return Context(meta_info=file_meta, media_info=mediainfo)
|
||
|
||
def search(
|
||
self, title: str, source: Optional[str] = None
|
||
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
||
"""
|
||
搜索媒体/人物信息
|
||
|
||
:param title: 搜索内容
|
||
:param source: 请求级搜索数据源
|
||
:return: 识别元数据,媒体信息列表
|
||
"""
|
||
# 提取要素
|
||
mtype, key_word, season_num, episode_num, year, content = (
|
||
StringUtils.get_keyword(title)
|
||
)
|
||
# 识别
|
||
meta = MetaInfo(content)
|
||
if not meta.name:
|
||
meta.cn_name = content
|
||
# 合并信息
|
||
if mtype:
|
||
meta.type = mtype
|
||
if season_num:
|
||
meta.begin_season = season_num
|
||
if episode_num:
|
||
meta.begin_episode = episode_num
|
||
if year:
|
||
meta.year = year
|
||
# 开始搜索
|
||
logger.info(f"开始搜索媒体信息:{meta.name}")
|
||
medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta, source=source)
|
||
if not medias:
|
||
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
|
||
return meta, []
|
||
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
|
||
# 识别的元数据,媒体信息列表
|
||
return meta, medias
|
||
|
||
def get_tmdbinfo_by_doubanid(
|
||
self, doubanid: str, mtype: MediaType = None
|
||
) -> Optional[dict]:
|
||
"""
|
||
根据豆瓣ID获取TMDB信息
|
||
"""
|
||
tmdbinfo = None
|
||
doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)
|
||
if doubaninfo:
|
||
# 优先使用原标题匹配
|
||
if doubaninfo.get("original_title"):
|
||
meta = MetaInfo(title=doubaninfo.get("title"))
|
||
meta_org = MetaInfo(title=doubaninfo.get("original_title"))
|
||
else:
|
||
meta_org = meta = MetaInfo(title=doubaninfo.get("title"))
|
||
# 年份
|
||
if doubaninfo.get("year"):
|
||
meta.year = doubaninfo.get("year")
|
||
# 处理类型
|
||
if isinstance(doubaninfo.get("media_type"), MediaType):
|
||
meta.type = doubaninfo.get("media_type")
|
||
else:
|
||
meta.type = (
|
||
MediaType.MOVIE
|
||
if doubaninfo.get("type") == "movie"
|
||
else MediaType.TV
|
||
)
|
||
# 匹配TMDB信息
|
||
meta_names = list(
|
||
dict.fromkeys(
|
||
[k for k in [meta_org.name, meta.cn_name, meta.en_name] if k]
|
||
)
|
||
)
|
||
tmdbinfo = self._match_tmdb_with_names(
|
||
meta_names=meta_names,
|
||
year=meta.year,
|
||
mtype=mtype or meta.type,
|
||
season=meta.begin_season,
|
||
)
|
||
if tmdbinfo:
|
||
# 合季季后返回
|
||
tmdbinfo["season"] = meta.begin_season
|
||
return tmdbinfo
|
||
|
||
def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||
"""
|
||
根据BangumiID获取TMDB信息
|
||
"""
|
||
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
|
||
if bangumiinfo:
|
||
# 优先使用原标题匹配
|
||
if bangumiinfo.get("name_cn"):
|
||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||
meta_cn = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||
else:
|
||
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
|
||
# 年份
|
||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||
# 识别TMDB媒体信息
|
||
meta_names = list(
|
||
dict.fromkeys([k for k in [meta_cn.name, meta.name] if k])
|
||
)
|
||
tmdbinfo = self._match_tmdb_with_names(
|
||
meta_names=meta_names,
|
||
year=year,
|
||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||
season=meta.begin_season,
|
||
)
|
||
return tmdbinfo
|
||
return None
|
||
|
||
def get_doubaninfo_by_tmdbid(
|
||
self, tmdbid: int, mtype: MediaType = None, season: Optional[int] = None
|
||
) -> Optional[dict]:
|
||
"""
|
||
根据TMDBID获取豆瓣信息
|
||
"""
|
||
tmdbinfo = self.tmdb_info(tmdbid=tmdbid, mtype=mtype)
|
||
if tmdbinfo:
|
||
# 名称
|
||
name = tmdbinfo.get("title") or tmdbinfo.get("name")
|
||
# 年份
|
||
year = self._extract_year_from_tmdb(tmdbinfo, season)
|
||
# IMDBID
|
||
imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id")
|
||
return self.match_doubaninfo(
|
||
name=name, year=year, mtype=mtype, imdbid=imdbid
|
||
)
|
||
return None
|
||
|
||
def get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||
"""
|
||
根据BangumiID获取豆瓣信息
|
||
"""
|
||
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
|
||
if bangumiinfo:
|
||
# 优先使用中文标题匹配
|
||
if bangumiinfo.get("name_cn"):
|
||
meta = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||
else:
|
||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||
# 年份
|
||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||
# 使用名称识别豆瓣媒体信息
|
||
return self.match_doubaninfo(
|
||
name=meta.name,
|
||
year=year,
|
||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||
season=meta.begin_season,
|
||
)
|
||
return None
|
||
|
||
@eventmanager.register(EventType.MetadataScrape)
|
||
def scrape_metadata_event(self, event: Event):
|
||
"""
|
||
监控手动刮削事件
|
||
"""
|
||
if not event:
|
||
return
|
||
event_data = event.event_data or {}
|
||
# 媒体根目录
|
||
fileitem: FileItem = event_data.get("fileitem")
|
||
# 媒体文件列表
|
||
file_list: List[str] = list(dict.fromkeys(event_data.get("file_list") or []))
|
||
# 媒体元数据
|
||
meta: MetaBase = event_data.get("meta")
|
||
# 媒体信息
|
||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||
# 是否覆盖
|
||
overwrite = event_data.get("overwrite", False)
|
||
# 检查媒体根目录
|
||
if not fileitem:
|
||
return
|
||
|
||
# 刮削锁
|
||
with scraping_lock:
|
||
# 音乐刮削与影视共用 MediaChain 入口,按 ScrapingConfig 的音乐项写入标签与封面
|
||
if getattr(mediainfo, "type", None) == MediaType.MUSIC:
|
||
scrape_kwargs: dict[str, Any] = {}
|
||
if file_list:
|
||
scrape_kwargs["audio_files"] = self._music_event_audio_fileitems(
|
||
root=fileitem,
|
||
file_list=file_list,
|
||
)
|
||
scrape_kwargs["media_by_path"] = {
|
||
Path(context.get("path")).as_posix(): context.get("mediainfo")
|
||
for context in event_data.get("file_contexts") or []
|
||
if (
|
||
isinstance(context, dict)
|
||
and context.get("path")
|
||
and isinstance(context.get("mediainfo"), MusicInfo)
|
||
)
|
||
}
|
||
_, message = self.scrape_metadata(
|
||
fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
overwrite=overwrite,
|
||
**scrape_kwargs,
|
||
)
|
||
if message:
|
||
logger.info(f"音乐刮削:{message}")
|
||
return
|
||
# 检查文件项是否存在
|
||
if not self.storagechain.get_item(fileitem):
|
||
logger.warn(f"文件项不存在:{fileitem.path}")
|
||
return
|
||
# 检查是否为目录
|
||
if fileitem.type == "file":
|
||
# 单个文件刮削
|
||
self.scrape_metadata(
|
||
fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
init_folder=True,
|
||
parent=self.storagechain.get_parent_item(fileitem),
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
if file_list:
|
||
# 如果是BDMV原盘目录,只对根目录进行刮削,不处理子目录
|
||
if self.storagechain.is_bluray_folder(fileitem):
|
||
logger.info(
|
||
f"检测到BDMV原盘目录,只对根目录进行刮削:{fileitem.path}"
|
||
)
|
||
self.scrape_metadata(
|
||
fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
init_folder=True,
|
||
recursive=False,
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
# 1. 收集fileitem和file_list中每个文件之间所有子目录
|
||
all_dirs: set[Path] = set()
|
||
root_path = Path(fileitem.path)
|
||
|
||
logger.debug(f"开始收集目录,根目录:{root_path}")
|
||
# 收集根目录
|
||
all_dirs.add(root_path)
|
||
|
||
# 收集所有目录(包括所有层级)
|
||
for sub_file in file_list:
|
||
sub_path = Path(sub_file)
|
||
# 收集从根目录到文件的所有父目录
|
||
current_path = sub_path.parent
|
||
while (
|
||
current_path != root_path
|
||
and current_path.is_relative_to(root_path)
|
||
):
|
||
all_dirs.add(current_path)
|
||
current_path = current_path.parent
|
||
|
||
logger.debug(f"共收集到 {len(all_dirs)} 个目录")
|
||
|
||
# 2. 初始化一遍子目录,但不处理文件
|
||
for sub_dir in sorted(
|
||
all_dirs,
|
||
key=lambda item: (len(item.parts), item.as_posix()),
|
||
):
|
||
sub_dir_item = self.storagechain.get_file_item(
|
||
storage=fileitem.storage, path=sub_dir
|
||
)
|
||
if sub_dir_item:
|
||
logger.info(f"为目录生成海报和nfo:{sub_dir}")
|
||
# 初始化目录元数据,但不处理文件
|
||
self.scrape_metadata(
|
||
fileitem=sub_dir_item,
|
||
mediainfo=mediainfo,
|
||
init_folder=True,
|
||
recursive=False,
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
logger.warn(f"无法获取目录项:{sub_dir}")
|
||
|
||
# 3. 刮削每个文件
|
||
logger.info(f"开始刮削 {len(file_list)} 个文件")
|
||
for sub_file_path in sorted(file_list):
|
||
sub_file_item = self.storagechain.get_file_item(
|
||
storage=fileitem.storage, path=Path(sub_file_path)
|
||
)
|
||
if sub_file_item:
|
||
self.scrape_metadata(
|
||
fileitem=sub_file_item,
|
||
mediainfo=mediainfo,
|
||
init_folder=False,
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
logger.warn(f"无法获取文件项:{sub_file_path}")
|
||
else:
|
||
# 执行全量刮削
|
||
logger.info(f"开始刮削目录 {fileitem.path} ...")
|
||
self.scrape_metadata(
|
||
fileitem=fileitem,
|
||
meta=meta,
|
||
init_folder=True,
|
||
mediainfo=mediainfo,
|
||
overwrite=overwrite,
|
||
)
|
||
|
||
def _scrape_nfo_generic(
|
||
self,
|
||
current_fileitem: schemas.FileItem,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
item_type: ScrapingTarget,
|
||
parent_fileitem: Optional[schemas.FileItem] = None,
|
||
overwrite: bool = False,
|
||
season_number: Optional[int] = None,
|
||
episode_number: Optional[int] = None,
|
||
):
|
||
"""
|
||
NFO 刮削
|
||
"""
|
||
# 获取刮削选项
|
||
nfo_option = self.scraping_policies.option(item_type, ScrapingMetadata.NFO)
|
||
|
||
# 检查刮削开关
|
||
if nfo_option.is_skip:
|
||
logger.info(
|
||
f"{item_type.value} {ScrapingMetadata.NFO.value} 刮削策略 {nfo_option.policy.value}"
|
||
)
|
||
return
|
||
|
||
# 获取目标 FileItem (`base_item`) 和 Path (`nfo_path`)
|
||
base_item, nfo_path = self._get_target_fileitem_and_path(
|
||
current_fileitem=current_fileitem,
|
||
item_type=item_type,
|
||
metadata_type=ScrapingMetadata.NFO,
|
||
parent_fileitem=parent_fileitem,
|
||
)
|
||
|
||
if not nfo_path: # _get_target_fileitem_and_path 内部错误处理返回None
|
||
return
|
||
|
||
# 文件存在检查
|
||
file_exists = self.storagechain.get_file_item(
|
||
storage=base_item.storage, path=nfo_path
|
||
)
|
||
|
||
# 刮削决策
|
||
if self._should_scrape(nfo_option, bool(file_exists), overwrite):
|
||
# 生成 NFO 内容
|
||
nfo_content = self.metadata_nfo(
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
season=season_number,
|
||
episode=episode_number,
|
||
)
|
||
if nfo_content:
|
||
self._save_file(fileitem=base_item, path=nfo_path, content=nfo_content)
|
||
else:
|
||
logger.warn(f"{nfo_path.name} NFO 文件生成失败!")
|
||
|
||
def _scrape_images_generic(
|
||
self,
|
||
current_fileitem: schemas.FileItem,
|
||
mediainfo: MediaInfo,
|
||
item_type: ScrapingTarget,
|
||
parent_fileitem: Optional[schemas.FileItem] = None,
|
||
overwrite: bool = False,
|
||
season_number: Optional[int] = None,
|
||
episode_number: Optional[int] = None,
|
||
):
|
||
"""
|
||
图片刮削
|
||
"""
|
||
# 获取图片 URL
|
||
if item_type == ScrapingTarget.EPISODE:
|
||
image_dict = self.metadata_img(
|
||
mediainfo=mediainfo, season=season_number, episode=episode_number
|
||
)
|
||
elif item_type == ScrapingTarget.SEASON:
|
||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_number)
|
||
else:
|
||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||
|
||
if not image_dict:
|
||
logger.info(f"未获取到 {item_type.value} 的图片信息,跳过图片刮削。")
|
||
return
|
||
|
||
# 遍历图片 image_name 和 image_url
|
||
for image_name, image_url in image_dict.items():
|
||
metadata_type = None
|
||
# 对每个 image_name 查找匹配的 ScrapingMetadata
|
||
for keyword, meta_type in self.IMAGE_METADATA_MAP.items():
|
||
if keyword in image_name.lower():
|
||
metadata_type = meta_type
|
||
break
|
||
|
||
if metadata_type:
|
||
# 获取对应的 ScrapingOption
|
||
option = self.scraping_policies.option(item_type, metadata_type)
|
||
|
||
if option.is_skip:
|
||
logger.info(
|
||
f"{item_type.value} {option.metadata.value} 刮削策略 {option.policy.value}"
|
||
)
|
||
continue
|
||
|
||
# 判断是否匹配当前刮削的季号
|
||
if item_type == ScrapingTarget.TV and image_name.lower().startswith(
|
||
"season"
|
||
):
|
||
logger.info(f"当前为电视剧根目录刮削,跳过季图片:{image_name}")
|
||
continue
|
||
if (
|
||
item_type == ScrapingTarget.SEASON
|
||
and season_number is not None
|
||
and image_name.lower().startswith("season")
|
||
):
|
||
# 检查是否只下载当前刮削季的图片
|
||
image_season_str = (
|
||
"00" if "specials" in image_name.lower() else image_name[6:8]
|
||
)
|
||
|
||
if image_season_str is not None and image_season_str != str(
|
||
season_number
|
||
).rjust(2, "0"):
|
||
logger.info(
|
||
f"当前刮削季为:{season_number},跳过非本季图片:{image_name}"
|
||
)
|
||
continue
|
||
|
||
# 获取目标 FileItem 和 Path,季图片会同时写根目录和季目录。
|
||
image_targets = self._get_target_fileitems_and_paths(
|
||
current_fileitem=current_fileitem,
|
||
item_type=item_type,
|
||
metadata_type=metadata_type,
|
||
filename_hint=image_name,
|
||
parent_fileitem=parent_fileitem,
|
||
)
|
||
|
||
# 扩展别名目标(如 backdrop→fanart, thumb→landscape)
|
||
image_targets = self._expand_with_aliases(image_targets, item_type)
|
||
|
||
for base_item, image_path in image_targets:
|
||
if not image_path:
|
||
continue
|
||
|
||
# 文件存在检查
|
||
file_exists = self.storagechain.get_file_item(
|
||
storage=base_item.storage, path=image_path
|
||
)
|
||
|
||
# 刮削决策
|
||
if self._should_scrape(option, bool(file_exists), overwrite):
|
||
self._download_and_save_image(
|
||
fileitem=base_item, path=image_path, url=image_url
|
||
)
|
||
else:
|
||
logger.debug(
|
||
f"未找到图片类型 {image_name} 对应的 ScrapingMetadata,跳过。"
|
||
)
|
||
|
||
def scrape_metadata(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
meta: MetaBase = None,
|
||
mediainfo: Union[MediaInfo, MusicInfo] = None,
|
||
init_folder: bool = True,
|
||
parent: schemas.FileItem = None,
|
||
overwrite: bool = False,
|
||
recursive: bool = True,
|
||
audio_files: Optional[list[schemas.FileItem]] = None,
|
||
media_by_path: Optional[dict[str, MusicInfo]] = None,
|
||
) -> tuple[bool, str]:
|
||
"""
|
||
手动刮削媒体信息
|
||
|
||
:param fileitem: 刮削目录或文件
|
||
:param meta: 元数据
|
||
:param mediainfo: 媒体信息
|
||
:param init_folder: 是否刮削根目录
|
||
:param parent: 上级目录
|
||
:param overwrite: 是否覆盖已有文件
|
||
:param recursive: 是否递归处理目录内文件
|
||
:param audio_files: 音乐批次已确认成功的音频文件清单
|
||
:param media_by_path: 音乐批次每个音频文件对应的标准身份
|
||
"""
|
||
if not fileitem:
|
||
return False, "未提供刮削文件"
|
||
|
||
# 当前文件路径
|
||
filepath = Path(fileitem.path)
|
||
is_music = (
|
||
getattr(mediainfo, "type", None) == MediaType.MUSIC
|
||
or isinstance(meta, MetaMusic)
|
||
or (
|
||
fileitem.type == "file"
|
||
and filepath.suffix.lower() in settings.RMT_AUDIOEXT
|
||
)
|
||
)
|
||
if is_music:
|
||
music_info = (
|
||
mediainfo
|
||
if getattr(mediainfo, "type", None) == MediaType.MUSIC
|
||
else None
|
||
)
|
||
music_kwargs: dict[str, Any] = {}
|
||
if audio_files is not None:
|
||
music_kwargs["audio_files"] = audio_files
|
||
if media_by_path is not None:
|
||
music_kwargs["media_by_path"] = media_by_path
|
||
return self.scrape_music_metadata(
|
||
fileitem=fileitem,
|
||
mediainfo=music_info,
|
||
overwrite=overwrite,
|
||
**music_kwargs,
|
||
)
|
||
if fileitem.type == "file" and (
|
||
not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT
|
||
):
|
||
return False, "刮削路径不是支持的媒体文件"
|
||
|
||
# 准备元数据和媒体信息
|
||
if not meta:
|
||
meta = MetaInfoPath(filepath)
|
||
if not mediainfo:
|
||
mediainfo = self.recognize_by_meta(meta)
|
||
if not mediainfo:
|
||
logger.warn(f"{filepath} 无法识别文件媒体信息!")
|
||
return False, "未识别到媒体信息"
|
||
|
||
logger.info(f"开始刮削:{filepath} ...")
|
||
|
||
# 根据媒体类型分发处理逻辑
|
||
if mediainfo.type == MediaType.MOVIE:
|
||
self._handle_movie_scraping(
|
||
fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
init_folder=init_folder,
|
||
parent=parent,
|
||
overwrite=overwrite,
|
||
recursive=recursive,
|
||
)
|
||
elif mediainfo.type == MediaType.TV:
|
||
self._handle_tv_scraping(
|
||
fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
init_folder=init_folder,
|
||
parent=parent,
|
||
overwrite=overwrite,
|
||
recursive=recursive,
|
||
)
|
||
else:
|
||
logger.warn(f"{filepath} 媒体类型不支持刮削:{mediainfo.type}")
|
||
return False, "媒体类型不支持刮削"
|
||
|
||
logger.info(f"{filepath.name} 刮削完成")
|
||
return True, f"{filepath.name} 刮削完成"
|
||
|
||
def scrape_music_metadata(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
mediainfo: Optional[MusicInfo] = None,
|
||
overwrite: bool = True,
|
||
source: Optional[str] = None,
|
||
audio_files: Optional[list[schemas.FileItem]] = None,
|
||
media_by_path: Optional[dict[str, MusicInfo]] = None,
|
||
) -> tuple[bool, str]:
|
||
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
|
||
|
||
音乐刮削被收拢到 MediaChain 统一分发,避免音乐链反向依赖媒体链造成嵌套。
|
||
|
||
:param audio_files: 已确认属于本批次的音频文件;为空时按 fileitem 展开
|
||
:param media_by_path: 每个音频文件对应的音乐身份,避免批次内不同单曲互相覆盖
|
||
"""
|
||
files = self._normalize_music_audio_fileitems(
|
||
audio_files if audio_files is not None else self._music_audio_fileitems(fileitem)
|
||
)
|
||
if not files:
|
||
return False, "刮削路径中没有支持的音频文件"
|
||
normalized_media_by_path = {
|
||
Path(path).as_posix(): info
|
||
for path, info in (media_by_path or {}).items()
|
||
if isinstance(info, MusicInfo)
|
||
}
|
||
file_media = [
|
||
normalized_media_by_path.get(Path(item.path).as_posix(), mediainfo)
|
||
for item in files
|
||
]
|
||
distinct_recordings = {
|
||
self._music_scrape_identity(info)
|
||
for info in file_media
|
||
if info and info.music_type != MUSIC_ENTITY_ALBUM
|
||
}
|
||
if len(files) > 1 and len(distinct_recordings) == 1 and all(file_media):
|
||
return False, "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||
|
||
# 三类音乐产物使用独立策略,允许只下载歌词而不改写音频标签。
|
||
nfo_option = self.scraping_policies.option("music", "nfo")
|
||
poster_option = self.scraping_policies.option("music", "poster")
|
||
lyrics_option = self.scraping_policies.option("music", "lyrics")
|
||
if nfo_option.is_skip and poster_option.is_skip and lyrics_option.is_skip:
|
||
return False, "音乐标签、封面和歌词刮削策略均为跳过"
|
||
|
||
with_cover = not poster_option.is_skip
|
||
music_chain = None
|
||
if not lyrics_option.is_skip:
|
||
music_chain = MusicChain()
|
||
cover_cache: dict[str, tuple[Optional[bytes], str]] = {}
|
||
album_cache: dict[tuple[str, str], Optional[MusicAlbumInfo]] = {}
|
||
|
||
failures: list[str] = []
|
||
lyrics_counts = {
|
||
"saved": 0,
|
||
"existing": 0,
|
||
"missing": 0,
|
||
"failed": 0,
|
||
}
|
||
metadata_failure_label = (
|
||
"音乐标签和封面"
|
||
if not nfo_option.is_skip and with_cover
|
||
else "音乐标签" if not nfo_option.is_skip else "封面"
|
||
)
|
||
for audio_item, item_media in zip(files, file_media):
|
||
item_cover = None
|
||
cover_url = item_media.cover_url if item_media else None
|
||
if with_cover and cover_url:
|
||
if cover_url not in cover_cache:
|
||
cover_cache[cover_url] = self._download_music_cover(cover_url)
|
||
item_cover = cover_cache[cover_url]
|
||
album_info = None
|
||
if (
|
||
music_chain
|
||
and item_media
|
||
and item_media.music_type == MUSIC_ENTITY_ALBUM
|
||
and item_media.source
|
||
and item_media.media_id
|
||
):
|
||
album_key = (item_media.source, item_media.media_id)
|
||
if album_key not in album_cache:
|
||
album_cache[album_key] = music_chain.album(
|
||
source=item_media.source,
|
||
media_id=item_media.media_id,
|
||
)
|
||
album_info = album_cache[album_key]
|
||
result = self._scrape_music_file(
|
||
audio_item,
|
||
item_media,
|
||
write_tags=not nfo_option.is_skip,
|
||
tag_overwrite=overwrite or nfo_option.is_overwrite,
|
||
with_cover=with_cover,
|
||
cover_overwrite=overwrite or poster_option.is_overwrite,
|
||
cover=item_cover,
|
||
lyrics_option=lyrics_option,
|
||
lyrics_overwrite=overwrite or lyrics_option.is_overwrite,
|
||
music_chain=music_chain,
|
||
album_info=album_info,
|
||
source=source,
|
||
)
|
||
if not result.metadata_success:
|
||
failures.append(
|
||
f"{audio_item.name or audio_item.path} {metadata_failure_label}写入失败"
|
||
)
|
||
if result.lyrics_status in lyrics_counts:
|
||
lyrics_counts[result.lyrics_status] += 1
|
||
if result.lyrics_status == "failed":
|
||
failures.append(f"{audio_item.name or audio_item.path} 歌词保存失败")
|
||
|
||
message = f"已刮削 {len(files)} 个音频文件"
|
||
if not lyrics_option.is_skip:
|
||
message += (
|
||
f",歌词新增 {lyrics_counts['saved']} 首"
|
||
f"、已存在 {lyrics_counts['existing']} 首"
|
||
f"、未匹配 {lyrics_counts['missing']} 首"
|
||
)
|
||
if lyrics_counts["failed"]:
|
||
message += f"、失败 {lyrics_counts['failed']} 首"
|
||
if failures:
|
||
return False, f"{message};{';'.join(failures[:3])}"
|
||
return True, message
|
||
|
||
@staticmethod
|
||
def _music_scrape_identity(info: MusicInfo) -> tuple:
|
||
"""构造音乐刮削身份键,用于识别同一单曲被错误套用到多个文件。"""
|
||
return (
|
||
info.source,
|
||
info.media_id,
|
||
info.music_type,
|
||
info.title,
|
||
info.disc_number,
|
||
info.track_number,
|
||
)
|
||
|
||
@staticmethod
|
||
@cached(maxsize=64, ttl=settings.CONF.meta, skip_none=True)
|
||
def _request_music_cover(url: str) -> Optional[tuple[Optional[bytes], str]]:
|
||
"""下载并缓存音乐封面;仅稳定 404 与成功响应进入有界缓存。"""
|
||
response = RequestUtils(
|
||
proxies=settings.PROXY,
|
||
ua=settings.NORMAL_USER_AGENT,
|
||
timeout=20,
|
||
).get_res(url)
|
||
if response is None:
|
||
return None
|
||
try:
|
||
if response.status_code == 404:
|
||
return None, "image/jpeg"
|
||
if response.status_code != 200:
|
||
logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
|
||
return None
|
||
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
|
||
return response.content, mime
|
||
finally:
|
||
response.close()
|
||
|
||
@staticmethod
|
||
def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
|
||
"""通过有界缓存下载音乐封面,并统一返回图片内容与 MIME 类型。"""
|
||
if not url:
|
||
return None, "image/jpeg"
|
||
return MediaChain._request_music_cover(url) or (None, "image/jpeg")
|
||
|
||
@staticmethod
|
||
def _is_music_audio_file(path: str) -> bool:
|
||
"""判断路径是否指向系统支持的音频文件。"""
|
||
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
|
||
|
||
def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
|
||
"""展开待刮削目录并过滤系统支持的音频文件。"""
|
||
if fileitem.type != "dir":
|
||
return [fileitem] if self._is_music_audio_file(fileitem.path or "") else []
|
||
return [
|
||
item
|
||
for item in self.storagechain.list_files(fileitem, recursion=True) or []
|
||
if item.type == "file" and self._is_music_audio_file(item.path or "")
|
||
]
|
||
|
||
@classmethod
|
||
def _normalize_music_audio_fileitems(
|
||
cls,
|
||
fileitems: Iterable[schemas.FileItem],
|
||
) -> list[schemas.FileItem]:
|
||
"""过滤并按存储路径去重已选音频文件,保持调用方给出的顺序。"""
|
||
normalized: list[schemas.FileItem] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for item in fileitems or []:
|
||
if (
|
||
not item
|
||
or item.type != "file"
|
||
or not cls._is_music_audio_file(item.path or "")
|
||
):
|
||
continue
|
||
key = (item.storage or "local", Path(item.path).as_posix())
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
normalized.append(item)
|
||
return normalized
|
||
|
||
def _music_event_audio_fileitems(
|
||
self,
|
||
root: schemas.FileItem,
|
||
file_list: Iterable[str],
|
||
) -> list[schemas.FileItem]:
|
||
"""把刮削事件中的成功路径恢复为文件项,并限制在事件媒体根目录内。"""
|
||
root_path = Path(root.path)
|
||
selected: list[schemas.FileItem] = []
|
||
for raw_path in file_list or []:
|
||
audio_path = Path(raw_path)
|
||
if not self._is_music_audio_file(audio_path.as_posix()):
|
||
continue
|
||
if root.type == "dir" and not audio_path.is_relative_to(root_path):
|
||
logger.warning(f"忽略媒体根目录外的音乐刮削路径:{audio_path}")
|
||
continue
|
||
item = self.storagechain.get_file_item(
|
||
storage=root.storage,
|
||
path=audio_path,
|
||
)
|
||
selected.append(item or schemas.FileItem(
|
||
storage=root.storage,
|
||
path=audio_path.as_posix(),
|
||
type="file",
|
||
name=audio_path.name,
|
||
basename=audio_path.stem,
|
||
extension=audio_path.suffix.lstrip("."),
|
||
))
|
||
return self._normalize_music_audio_fileitems(selected)
|
||
|
||
def _scrape_music_file(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
mediainfo: Optional[MusicInfo],
|
||
write_tags: bool,
|
||
tag_overwrite: bool,
|
||
with_cover: bool = True,
|
||
cover_overwrite: bool = True,
|
||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||
lyrics_option: Optional[ScrapingOption] = None,
|
||
lyrics_overwrite: bool = False,
|
||
music_chain: Optional[MusicChain] = None,
|
||
album_info: Optional[MusicAlbumInfo] = None,
|
||
source: Optional[str] = None,
|
||
) -> _MusicScrapeFileResult:
|
||
"""下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。"""
|
||
storage = self.storagechain
|
||
download_failure = _MusicScrapeFileResult(
|
||
metadata_success=not (write_tags or with_cover),
|
||
lyrics_status=(
|
||
"failed"
|
||
if lyrics_option and not lyrics_option.is_skip and music_chain
|
||
else "disabled"
|
||
),
|
||
)
|
||
if fileitem.storage == "local":
|
||
local_path = storage.download_file(fileitem)
|
||
if not local_path:
|
||
return download_failure
|
||
return self._apply_music_file_scrape(
|
||
fileitem=fileitem,
|
||
local_path=local_path,
|
||
mediainfo=mediainfo,
|
||
write_tags=write_tags,
|
||
tag_overwrite=tag_overwrite,
|
||
with_cover=with_cover,
|
||
cover_overwrite=cover_overwrite,
|
||
cover=cover,
|
||
lyrics_option=lyrics_option,
|
||
lyrics_overwrite=lyrics_overwrite,
|
||
music_chain=music_chain,
|
||
album_info=album_info,
|
||
source=source,
|
||
)
|
||
|
||
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
|
||
local_path = storage.download_file(fileitem, path=Path(temp_dir))
|
||
if not local_path:
|
||
return download_failure
|
||
return self._apply_music_file_scrape(
|
||
fileitem=fileitem,
|
||
local_path=local_path,
|
||
mediainfo=mediainfo,
|
||
write_tags=write_tags,
|
||
tag_overwrite=tag_overwrite,
|
||
with_cover=with_cover,
|
||
cover_overwrite=cover_overwrite,
|
||
cover=cover,
|
||
lyrics_option=lyrics_option,
|
||
lyrics_overwrite=lyrics_overwrite,
|
||
music_chain=music_chain,
|
||
album_info=album_info,
|
||
source=source,
|
||
)
|
||
|
||
def _apply_music_file_scrape(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
local_path: Path,
|
||
mediainfo: Optional[MusicInfo],
|
||
write_tags: bool,
|
||
tag_overwrite: bool,
|
||
with_cover: bool,
|
||
cover_overwrite: bool,
|
||
cover: Optional[tuple[Optional[bytes], str]],
|
||
lyrics_option: Optional[ScrapingOption],
|
||
lyrics_overwrite: bool,
|
||
music_chain: Optional[MusicChain],
|
||
album_info: Optional[MusicAlbumInfo],
|
||
source: Optional[str],
|
||
) -> _MusicScrapeFileResult:
|
||
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
|
||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, source=source)
|
||
metadata_requested = write_tags or with_cover
|
||
metadata_success = True
|
||
if metadata_requested:
|
||
metadata_success = self._write_music_metadata(
|
||
local_path=local_path,
|
||
mediainfo=mediainfo,
|
||
tag_overwrite=tag_overwrite,
|
||
write_tags=write_tags,
|
||
with_cover=with_cover,
|
||
cover_overwrite=cover_overwrite,
|
||
cover=cover,
|
||
scrape_info=scrape_info,
|
||
source=source,
|
||
)
|
||
|
||
lyrics_status = self._scrape_music_lyrics(
|
||
fileitem=fileitem,
|
||
local_path=local_path,
|
||
scrape_info=scrape_info,
|
||
lyrics_option=lyrics_option,
|
||
overwrite=lyrics_overwrite,
|
||
music_chain=music_chain,
|
||
album_info=album_info,
|
||
)
|
||
|
||
if fileitem.storage != "local" and metadata_requested and metadata_success:
|
||
parent = self.storagechain.get_parent_item(fileitem)
|
||
if not parent:
|
||
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
|
||
metadata_success = False
|
||
elif not self.storagechain.upload_file(
|
||
parent,
|
||
local_path,
|
||
new_name=fileitem.name or local_path.name,
|
||
):
|
||
metadata_success = False
|
||
return _MusicScrapeFileResult(
|
||
metadata_success=metadata_success,
|
||
lyrics_status=lyrics_status,
|
||
)
|
||
|
||
@staticmethod
|
||
def _merge_music_album_metadata(local_meta: MetaMusic, album: MusicInfo) -> MetaMusic:
|
||
"""把专辑级字段合并到单个音轨标签,同时保留该文件自己的标题、艺术家和曲序。"""
|
||
merged = deepcopy(local_meta)
|
||
merged.artists = list(local_meta.artists or album.artists)
|
||
merged.album = album.album or album.title or local_meta.album
|
||
merged.album_artist = album.album_artist or album.artist or local_meta.album_artist
|
||
merged.year = album.year or local_meta.year
|
||
merged.total_tracks = album.total_tracks or local_meta.total_tracks
|
||
merged.media_source = album.source or local_meta.media_source
|
||
merged.media_id = album.media_id or local_meta.media_id
|
||
return merged
|
||
|
||
@classmethod
|
||
def _match_music_album_track(
|
||
cls,
|
||
local_meta: MetaMusic,
|
||
album_info: Optional[MusicAlbumInfo],
|
||
) -> Optional[MusicInfo]:
|
||
"""按碟号、曲序、标题、艺术家和时长为本地文件匹配专辑中的单个音轨。"""
|
||
if not album_info or not album_info.tracks:
|
||
return None
|
||
local_title = cls._normalize_music_track_title(local_meta.title)
|
||
local_artists = {
|
||
cls._normalize_music_track_title(artist)
|
||
for artist in local_meta.artists
|
||
if artist
|
||
}
|
||
ranked: list[tuple[int, MusicInfo]] = []
|
||
for track in album_info.tracks:
|
||
score = 0
|
||
if local_meta.track_number and track.track_number:
|
||
if local_meta.track_number == track.track_number:
|
||
score += 6
|
||
else:
|
||
continue
|
||
if local_meta.disc_number and track.disc_number:
|
||
if local_meta.disc_number == track.disc_number:
|
||
score += 3
|
||
else:
|
||
continue
|
||
if local_title and local_title == cls._normalize_music_track_title(track.title):
|
||
score += 8
|
||
track_artists = {
|
||
cls._normalize_music_track_title(artist)
|
||
for artist in track.artists
|
||
if artist
|
||
}
|
||
if local_artists and track_artists and local_artists.intersection(track_artists):
|
||
score += 3
|
||
if local_meta.duration and track.duration:
|
||
duration_delta = abs(local_meta.duration - track.duration)
|
||
if duration_delta <= 2:
|
||
score += 4
|
||
elif duration_delta > 5:
|
||
score -= 3
|
||
if score >= 8:
|
||
ranked.append((score, track))
|
||
if not ranked:
|
||
return None
|
||
ranked.sort(key=lambda pair: pair[0], reverse=True)
|
||
if len(ranked) > 1 and ranked[0][0] == ranked[1][0]:
|
||
return None
|
||
matched = deepcopy(ranked[0][1])
|
||
matched.duration = matched.duration or local_meta.duration
|
||
return matched
|
||
|
||
@classmethod
|
||
def _normalize_music_track_title(cls, value: Optional[str]) -> str:
|
||
"""规范化音轨标题并移除常见文件名前置碟号和曲序。"""
|
||
title = cls._music_track_prefix_pattern.sub("", str(value or "").strip())
|
||
return re.sub(r"[^\w]+", "", title.casefold(), flags=re.UNICODE)
|
||
|
||
@classmethod
|
||
def _resolve_music_scrape_info(
|
||
cls,
|
||
local_path: Path,
|
||
mediainfo: Optional[MusicInfo],
|
||
source: Optional[str] = None,
|
||
) -> Optional[MetaMusic | MusicInfo]:
|
||
"""在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。"""
|
||
if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM:
|
||
return cls._merge_music_album_metadata(
|
||
AudioMetadataHelper.read(local_path),
|
||
mediainfo,
|
||
)
|
||
if mediainfo and mediainfo.music_type in (MUSIC_ENTITY_RECORDING, None, ""):
|
||
return mediainfo
|
||
if mediainfo:
|
||
return None
|
||
|
||
_, recognized = cls.recognize_music_by_path(local_path, source=source)
|
||
return recognized
|
||
|
||
def _write_music_metadata(
|
||
self,
|
||
local_path: Path,
|
||
mediainfo: Optional[MusicInfo],
|
||
tag_overwrite: bool,
|
||
write_tags: bool,
|
||
with_cover: bool,
|
||
cover_overwrite: bool,
|
||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||
scrape_info: Optional[MetaMusic | MusicInfo] = None,
|
||
source: Optional[str] = None,
|
||
) -> bool:
|
||
"""解析单个本地音轨并按独立策略写入标签和封面。"""
|
||
scrape_info = scrape_info or self._resolve_music_scrape_info(
|
||
local_path,
|
||
mediainfo,
|
||
source=source,
|
||
)
|
||
if not scrape_info or not scrape_info.title:
|
||
logger.warning(f"无法识别音乐信息:{local_path}")
|
||
return False
|
||
if not with_cover:
|
||
cover_data, cover_mime = None, "image/jpeg"
|
||
elif cover is not None:
|
||
cover_data, cover_mime = cover
|
||
else:
|
||
cover_url = getattr(mediainfo, "cover_url", None) or getattr(scrape_info, "cover_url", None)
|
||
cover_data, cover_mime = self._download_music_cover(cover_url)
|
||
return AudioMetadataHelper.write(
|
||
local_path,
|
||
scrape_info,
|
||
cover_data=cover_data,
|
||
cover_mime=cover_mime,
|
||
overwrite=tag_overwrite,
|
||
write_tags=write_tags,
|
||
cover_overwrite=cover_overwrite,
|
||
)
|
||
|
||
def _scrape_music_lyrics(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
local_path: Path,
|
||
scrape_info: Optional[MetaMusic | MusicInfo],
|
||
lyrics_option: Optional[ScrapingOption],
|
||
overwrite: bool,
|
||
music_chain: Optional[MusicChain],
|
||
album_info: Optional[MusicAlbumInfo],
|
||
) -> str:
|
||
"""按歌词策略查询单个音轨并保存同名旁挂歌词文件。"""
|
||
if not lyrics_option or lyrics_option.is_skip or not music_chain:
|
||
return "disabled"
|
||
existing = self._find_music_lyrics_sidecar(fileitem)
|
||
if existing and not overwrite:
|
||
return "existing"
|
||
if not scrape_info:
|
||
return "missing"
|
||
|
||
lookup_info: MetaMusic | MusicInfo = scrape_info
|
||
if album_info:
|
||
local_meta = AudioMetadataHelper.read(local_path)
|
||
lookup_info = self._match_music_album_track(local_meta, album_info) or scrape_info
|
||
lyrics = music_chain.lyrics(lookup_info)
|
||
if not lyrics or lyrics.instrumental or not lyrics.content or not lyrics.extension:
|
||
return "missing"
|
||
return (
|
||
"saved"
|
||
if self._write_music_lyrics_sidecar(
|
||
fileitem=fileitem,
|
||
local_path=local_path,
|
||
lyrics=lyrics,
|
||
overwrite=overwrite,
|
||
)
|
||
else "failed"
|
||
)
|
||
|
||
def _find_music_lyrics_sidecar(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
) -> Optional[schemas.FileItem]:
|
||
"""查找音轨旁已存在的同步或纯文本歌词文件。"""
|
||
audio_path = Path(fileitem.path)
|
||
for extension in self.MUSIC_LYRICS_EXTENSIONS:
|
||
item = self.storagechain.get_file_item(
|
||
storage=fileitem.storage,
|
||
path=audio_path.with_suffix(extension),
|
||
)
|
||
if item:
|
||
return item
|
||
return None
|
||
|
||
def _write_music_lyrics_sidecar(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
local_path: Path,
|
||
lyrics: MusicLyrics,
|
||
overwrite: bool,
|
||
) -> bool:
|
||
"""原子写入本地歌词或上传远端歌词,并在覆盖时清理旧格式旁挂文件。"""
|
||
extension = lyrics.extension
|
||
content = lyrics.content
|
||
if not extension or not content:
|
||
return False
|
||
target_path = Path(fileitem.path).with_suffix(extension)
|
||
target_name = target_path.name
|
||
temp_path: Optional[Path] = None
|
||
try:
|
||
if fileitem.storage == "local":
|
||
with NamedTemporaryFile(
|
||
mode="w",
|
||
encoding="utf-8",
|
||
dir=target_path.parent,
|
||
prefix=f".{target_name}.",
|
||
delete=False,
|
||
) as temp_file:
|
||
temp_file.write(f"{content.rstrip()}\n")
|
||
temp_path = Path(temp_file.name)
|
||
temp_path.replace(target_path)
|
||
else:
|
||
parent = self.storagechain.get_parent_item(fileitem)
|
||
if not parent:
|
||
logger.warning(f"无法获取远端歌词父目录:{fileitem.path}")
|
||
return False
|
||
temp_path = local_path.with_suffix(extension)
|
||
temp_path.write_text(f"{content.rstrip()}\n", encoding="utf-8")
|
||
if not self.storagechain.upload_file(
|
||
parent,
|
||
temp_path,
|
||
new_name=target_name,
|
||
):
|
||
return False
|
||
|
||
if overwrite:
|
||
self._remove_alternate_music_lyrics(fileitem, keep_extension=extension)
|
||
return True
|
||
except OSError as err:
|
||
logger.warning(f"保存音乐歌词失败:{target_path} - {err}")
|
||
return False
|
||
finally:
|
||
if temp_path and temp_path.exists() and temp_path != target_path:
|
||
self._cleanup_temp_file(temp_path)
|
||
|
||
def _remove_alternate_music_lyrics(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
keep_extension: str,
|
||
) -> None:
|
||
"""覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。"""
|
||
audio_path = Path(fileitem.path)
|
||
for extension in self.MUSIC_LYRICS_EXTENSIONS:
|
||
if extension == keep_extension:
|
||
continue
|
||
item = self.storagechain.get_file_item(
|
||
storage=fileitem.storage,
|
||
path=audio_path.with_suffix(extension),
|
||
)
|
||
if item and not self.storagechain.delete_file(item):
|
||
logger.warning(f"删除旧歌词文件失败:{item.path}")
|
||
|
||
def _handle_movie_scraping(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
init_folder: bool,
|
||
parent: schemas.FileItem,
|
||
overwrite: bool,
|
||
recursive: bool,
|
||
):
|
||
"""
|
||
处理电影刮削
|
||
"""
|
||
if fileitem.type == "file":
|
||
# 电影文件始终处理 NFO,直接初始化文件时再补同级目录图片。
|
||
self._scrape_nfo_generic(
|
||
current_fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.MOVIE,
|
||
parent_fileitem=parent,
|
||
overwrite=overwrite,
|
||
)
|
||
if init_folder:
|
||
self._scrape_images_generic(
|
||
current_fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.MOVIE,
|
||
parent_fileitem=parent,
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
# 电影目录:递归处理文件并初始化目录
|
||
self._handle_movie_directory(
|
||
fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
init_folder=init_folder,
|
||
overwrite=overwrite,
|
||
recursive=recursive,
|
||
)
|
||
|
||
def _handle_movie_directory(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
init_folder: bool,
|
||
overwrite: bool,
|
||
recursive: bool,
|
||
):
|
||
"""
|
||
处理电影目录刮削
|
||
"""
|
||
files = self.storagechain.list_files(fileitem=fileitem) or []
|
||
is_bluray_folder = self.storagechain.contains_bluray_subdirectories(files)
|
||
|
||
# 递归处理文件(非蓝光原盘)
|
||
if recursive and not is_bluray_folder:
|
||
for file in files:
|
||
if file.type == "dir":
|
||
continue
|
||
self.scrape_metadata(
|
||
fileitem=file,
|
||
mediainfo=mediainfo,
|
||
init_folder=False,
|
||
parent=fileitem,
|
||
overwrite=overwrite,
|
||
)
|
||
|
||
# 初始化目录元数据
|
||
if init_folder:
|
||
if is_bluray_folder:
|
||
# 蓝光原盘目录:仅处理 NFO
|
||
self._scrape_nfo_generic(
|
||
current_fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.MOVIE,
|
||
overwrite=overwrite,
|
||
)
|
||
# 电影目录:处理图片
|
||
self._scrape_images_generic(
|
||
current_fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.MOVIE,
|
||
overwrite=overwrite,
|
||
)
|
||
|
||
def _handle_tv_scraping(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
init_folder: bool,
|
||
parent: schemas.FileItem,
|
||
overwrite: bool,
|
||
recursive: bool,
|
||
):
|
||
"""
|
||
处理电视剧刮削
|
||
"""
|
||
filepath = Path(fileitem.path)
|
||
|
||
if fileitem.type == "file":
|
||
# 电视剧集文件:重新识别季集信息并刮削
|
||
self._handle_tv_episode_file(
|
||
fileitem=fileitem,
|
||
filepath=filepath,
|
||
mediainfo=mediainfo,
|
||
parent=parent,
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
# 电视剧目录:递归处理并初始化目录
|
||
self._handle_tv_directory(
|
||
fileitem=fileitem,
|
||
filepath=filepath,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
init_folder=init_folder,
|
||
parent=parent,
|
||
overwrite=overwrite,
|
||
recursive=recursive,
|
||
)
|
||
|
||
def _handle_tv_episode_file(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
filepath: Path,
|
||
mediainfo: MediaInfo,
|
||
parent: schemas.FileItem,
|
||
overwrite: bool,
|
||
):
|
||
"""
|
||
处理电视剧集文件刮削
|
||
"""
|
||
# 重新识别季集信息
|
||
file_meta = MetaInfoPath(filepath)
|
||
if not file_meta.begin_episode:
|
||
logger.warn(f"{filepath.name} 无法识别文件集数!")
|
||
return
|
||
|
||
file_mediainfo = self.recognize_media(
|
||
meta=file_meta,
|
||
tmdbid=mediainfo.tmdb_id,
|
||
episode_group=mediainfo.episode_group,
|
||
)
|
||
if not file_mediainfo:
|
||
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
||
return
|
||
|
||
# 处理 NFO
|
||
self._scrape_nfo_generic(
|
||
current_fileitem=fileitem,
|
||
meta=file_meta,
|
||
mediainfo=file_mediainfo,
|
||
item_type=ScrapingTarget.EPISODE,
|
||
parent_fileitem=parent,
|
||
overwrite=overwrite,
|
||
season_number=file_meta.begin_season,
|
||
episode_number=file_meta.begin_episode,
|
||
)
|
||
|
||
# 处理图片
|
||
self._scrape_images_generic(
|
||
current_fileitem=fileitem,
|
||
mediainfo=file_mediainfo,
|
||
item_type=ScrapingTarget.EPISODE,
|
||
parent_fileitem=parent,
|
||
overwrite=overwrite,
|
||
season_number=file_meta.begin_season,
|
||
episode_number=file_meta.begin_episode,
|
||
)
|
||
|
||
def _handle_tv_directory(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
filepath: Path,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
init_folder: bool,
|
||
parent: schemas.FileItem,
|
||
overwrite: bool,
|
||
recursive: bool,
|
||
):
|
||
"""
|
||
处理电视剧目录刮削
|
||
"""
|
||
# 递归处理子目录和文件
|
||
if recursive:
|
||
files = self.storagechain.list_files(fileitem=fileitem) or []
|
||
for file in files:
|
||
if (
|
||
file.type == "dir"
|
||
and file.name not in settings.RENAME_FORMAT_S0_NAMES
|
||
and MetaInfo(file.name).begin_season is None
|
||
):
|
||
# 电视剧不处理非季子目录
|
||
continue
|
||
self.scrape_metadata(
|
||
fileitem=file,
|
||
mediainfo=mediainfo,
|
||
parent=fileitem if file.type == "file" else None,
|
||
init_folder=True if file.type == "dir" else False,
|
||
overwrite=overwrite,
|
||
)
|
||
|
||
# 初始化目录元数据
|
||
if init_folder:
|
||
self._initialize_tv_directory_metadata(
|
||
fileitem=fileitem,
|
||
filepath=filepath,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
parent=parent,
|
||
overwrite=overwrite,
|
||
)
|
||
|
||
def _initialize_tv_directory_metadata(
|
||
self,
|
||
fileitem: schemas.FileItem,
|
||
filepath: Path,
|
||
meta: MetaBase,
|
||
mediainfo: MediaInfo,
|
||
parent: schemas.FileItem,
|
||
overwrite: bool,
|
||
):
|
||
"""
|
||
初始化电视剧目录元数据(识别季号并刮削)
|
||
"""
|
||
# 识别文件夹名称
|
||
season_meta = MetaInfo(filepath.name)
|
||
|
||
# 特殊季目录处理(Specials/SPs)
|
||
if filepath.name in settings.RENAME_FORMAT_S0_NAMES:
|
||
season_meta.begin_season = 0
|
||
elif season_meta.name and season_meta.begin_season is not None:
|
||
# 排除辅助词重新识别,避免误判根目录 (issue https://github.com/jxxghp/MoviePilot/issues/5501)
|
||
season_meta_no_custom = MetaInfo(filepath.name, custom_words=["#"])
|
||
if season_meta_no_custom.begin_season is None:
|
||
# 季号由辅助词指定,按剧集根目录处理 (issue https://github.com/jxxghp/MoviePilot/issues/5373)
|
||
season_meta.begin_season = None
|
||
|
||
# 根据季号判断目录类型并刮削
|
||
if season_meta.begin_season is not None:
|
||
# 季目录:处理季 NFO 和图片
|
||
self._scrape_nfo_generic(
|
||
current_fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.SEASON,
|
||
overwrite=overwrite,
|
||
season_number=season_meta.begin_season,
|
||
)
|
||
self._scrape_images_generic(
|
||
current_fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.SEASON,
|
||
parent_fileitem=parent,
|
||
overwrite=overwrite,
|
||
season_number=season_meta.begin_season,
|
||
)
|
||
elif season_meta.name:
|
||
# 剧集根目录:处理电视剧 NFO 和图片
|
||
self._scrape_nfo_generic(
|
||
current_fileitem=fileitem,
|
||
meta=meta,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.TV,
|
||
overwrite=overwrite,
|
||
)
|
||
self._scrape_images_generic(
|
||
current_fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
item_type=ScrapingTarget.TV,
|
||
overwrite=overwrite,
|
||
)
|
||
else:
|
||
logger.warn("无法识别元数据,跳过")
|
||
|
||
@staticmethod
|
||
async def async_select_recognize_source(
|
||
log_name: str, log_context: str, native_fn, plugin_fn,
|
||
is_recognized=None,
|
||
plugin_event: ChainEventType = ChainEventType.NameRecognize,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
选择识别模式,插件优先或原生优先(异步版本)
|
||
|
||
:param log_name: 用于日志“标题:...”处的名称(如 file_path.name 或 title)
|
||
:param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title)
|
||
:param native_fn: 原生识别函数
|
||
:param plugin_fn: 插件识别函数
|
||
:param is_recognized: 判定识别结果是否有效的谓词,语义同同步版本
|
||
:param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件
|
||
"""
|
||
if is_recognized is None:
|
||
is_recognized = lambda result: bool(result)
|
||
mediainfo = None
|
||
plugin_available = eventmanager.check(plugin_event)
|
||
if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available:
|
||
# 插件优先
|
||
logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...")
|
||
helped = await plugin_fn()
|
||
if is_recognized(helped):
|
||
mediainfo = helped
|
||
else:
|
||
logger.info(
|
||
f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别"
|
||
)
|
||
mediainfo = await native_fn()
|
||
# 辅助结果不采信时保留原生兜底,避免丢失已有识别结果(音乐原生兜底恒非空)
|
||
if helped and not mediainfo:
|
||
mediainfo = helped
|
||
else:
|
||
# 原生优先
|
||
logger.info(f"识别标题:{log_name} ...")
|
||
mediainfo = await native_fn()
|
||
if not is_recognized(mediainfo) and plugin_available:
|
||
logger.info(
|
||
f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别"
|
||
)
|
||
helped = await plugin_fn()
|
||
if is_recognized(helped):
|
||
mediainfo = helped
|
||
return mediainfo
|
||
|
||
async def async_recognize_by_meta(
|
||
self,
|
||
metainfo: MetaBase,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
obtain_images: bool = False,
|
||
mtype: Optional[MediaType] = None,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
根据主副标题识别媒体信息(异步版本)
|
||
|
||
:param metainfo: 标题解析元数据
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param obtain_images: 是否补充图片
|
||
:param mtype: 上游已确定的媒体类型
|
||
:param music_type: 音乐实体类型,用于约束显式音乐身份及插件结果
|
||
:return: 统一媒体信息
|
||
"""
|
||
mediainfo = await self._async_recognize_with_fallback_by_meta(
|
||
metainfo=metainfo,
|
||
mtype=mtype,
|
||
source=source,
|
||
episode_group=episode_group,
|
||
obtain_images=obtain_images,
|
||
music_type=music_type,
|
||
)
|
||
if not mediainfo:
|
||
logger.warn(f"{metainfo.title} 未识别到媒体信息")
|
||
return mediainfo
|
||
|
||
async def _async_recognize_with_fallback_by_meta(
|
||
self,
|
||
metainfo: MetaBase,
|
||
mtype: Optional[MediaType] = None,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
obtain_images: bool = False,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
异步根据标题识别媒体信息,必要时回退到辅助识别。
|
||
|
||
:param metainfo: 标题解析元数据
|
||
:param mtype: 上游已确定的媒体类型
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param obtain_images: 是否补充图片
|
||
:param music_type: 音乐实体类型,用于约束显式音乐身份及插件结果
|
||
:return: 统一媒体信息
|
||
"""
|
||
if not metainfo:
|
||
return None
|
||
title = metainfo.title
|
||
share_meta = deepcopy(metainfo)
|
||
# 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别
|
||
is_music = mtype == MediaType.MUSIC or isinstance(metainfo, MetaMusic)
|
||
is_recognized = (
|
||
(lambda result: bool(result and result.source)) if is_music else None
|
||
)
|
||
|
||
async def native_recognize() -> Optional[MediaInfo]:
|
||
"""异步使用请求级数据源执行原生识别。"""
|
||
return await self.async_recognize_media(
|
||
meta=metainfo,
|
||
mtype=mtype,
|
||
source=source,
|
||
share_meta=share_meta,
|
||
episode_group=episode_group,
|
||
music_type=music_type,
|
||
)
|
||
|
||
async def plugin_recognize() -> Optional[MediaInfo]:
|
||
"""异步执行辅助识别并保持请求级数据源约束。"""
|
||
if is_music and not isinstance(metainfo, MetaMusic):
|
||
return None
|
||
return await self.async_recognize_help(
|
||
title=title,
|
||
org_meta=metainfo,
|
||
share_meta=share_meta,
|
||
source=source,
|
||
episode_group=episode_group,
|
||
music_type=music_type,
|
||
)
|
||
|
||
# 按 config 中设置的识别顺序识别,影视与音乐共用同一选择流程
|
||
mediainfo = await self.async_select_recognize_source(
|
||
log_name=title,
|
||
log_context=title,
|
||
native_fn=native_recognize,
|
||
plugin_fn=plugin_recognize,
|
||
is_recognized=is_recognized,
|
||
plugin_event=(
|
||
ChainEventType.MusicNameRecognize if is_music
|
||
else ChainEventType.NameRecognize
|
||
),
|
||
)
|
||
if not mediainfo:
|
||
return None
|
||
logger.info(
|
||
f"{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}"
|
||
)
|
||
if obtain_images:
|
||
await self.async_obtain_images(mediainfo=mediainfo)
|
||
return mediainfo
|
||
|
||
async def async_recognize_help(
|
||
self,
|
||
title: str,
|
||
org_meta: MetaBase,
|
||
share_meta: MetaBase = None,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
请求辅助识别,返回媒体信息(异步版本);影视与音乐共用同一流程
|
||
|
||
:param title: 标题
|
||
:param org_meta: 原始元数据
|
||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param music_type: 音乐实体类型,仅音乐辅助识别使用
|
||
"""
|
||
# 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件
|
||
if isinstance(org_meta, MetaMusic):
|
||
return await self._async_recognize_music_help(
|
||
title=title,
|
||
org_meta=org_meta,
|
||
share_meta=share_meta,
|
||
source=source,
|
||
music_type=music_type,
|
||
)
|
||
# 发送请求事件,等待结果
|
||
result: Event = await eventmanager.async_send_event(
|
||
ChainEventType.NameRecognize,
|
||
{
|
||
"title": title,
|
||
},
|
||
)
|
||
if not result:
|
||
return None
|
||
# 获取返回事件数据
|
||
event_data = result.event_data or {}
|
||
logger.info(f"获取到辅助识别结果:{event_data}")
|
||
# 处理数据格式
|
||
title, year, season_number, episode_number = None, None, None, None
|
||
if event_data.get("name"):
|
||
title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||
if event_data.get("year"):
|
||
year = str(event_data["year"]).split("/")[0].strip()
|
||
season_number = self._parse_recognize_event_number(event_data.get("season"))
|
||
episode_number = self._parse_recognize_event_number(event_data.get("episode"))
|
||
if not title:
|
||
return None
|
||
if title == "Unknown":
|
||
return None
|
||
if not str(year).isdigit():
|
||
year = None
|
||
# 结果赋值
|
||
if title == org_meta.name and year == org_meta.year:
|
||
logger.info(f"辅助识别与原始识别结果一致,无需重新识别媒体信息")
|
||
return None
|
||
logger.info(f"辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...")
|
||
org_meta.name = title
|
||
org_meta.year = year
|
||
org_meta.begin_season = season_number
|
||
org_meta.begin_episode = episode_number
|
||
if org_meta.begin_season is not None or org_meta.begin_episode is not None:
|
||
org_meta.type = MediaType.TV
|
||
# 重新识别
|
||
return await self.async_recognize_media(
|
||
meta=org_meta,
|
||
source=source,
|
||
share_meta=share_meta,
|
||
episode_group=episode_group,
|
||
)
|
||
|
||
async def _async_recognize_music_help(
|
||
self,
|
||
title: str,
|
||
org_meta: MetaMusic,
|
||
share_meta: MetaBase = None,
|
||
source: Optional[str] = None,
|
||
music_type: Optional[str] = None,
|
||
) -> Optional[MediaInfo]:
|
||
"""
|
||
请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息(异步版本)
|
||
|
||
:param title: 原始音乐标题
|
||
:param org_meta: 原始音乐元数据
|
||
:param share_meta: 共享识别查询/上报使用的原始元数据
|
||
:param source: 请求级识别数据源
|
||
:param music_type: 音乐实体类型
|
||
"""
|
||
# 发送音乐名称识别事件,等待插件返回标题要素
|
||
result: Event = await eventmanager.async_send_event(
|
||
ChainEventType.MusicNameRecognize,
|
||
{
|
||
"title": title,
|
||
"artist": org_meta.artist,
|
||
"album": org_meta.album,
|
||
"year": org_meta.year,
|
||
"music_type": music_type,
|
||
},
|
||
)
|
||
if not result:
|
||
return None
|
||
event_data = result.event_data or {}
|
||
logger.info(f"获取到音乐辅助识别结果:{event_data}")
|
||
name, artist, album, year = self._parse_music_recognize_event(event_data)
|
||
if not name:
|
||
return None
|
||
# 辅助识别要素与原始一致时无需重新匹配
|
||
if (
|
||
name == org_meta.title
|
||
and (not artist or artist in org_meta.artists)
|
||
and (not album or album == org_meta.album)
|
||
and (not year or year == org_meta.year)
|
||
):
|
||
logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息")
|
||
return None
|
||
logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...")
|
||
new_meta = self._build_music_help_meta(
|
||
org_meta=org_meta,
|
||
name=name,
|
||
artist=artist,
|
||
album=album,
|
||
year=year,
|
||
)
|
||
# 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底
|
||
mediainfo = await self.async_recognize_media(
|
||
meta=new_meta,
|
||
source=source,
|
||
share_meta=share_meta,
|
||
music_type=music_type,
|
||
)
|
||
return mediainfo if mediainfo and mediainfo.source else None
|
||
|
||
async def async_recognize_by_path(
|
||
self,
|
||
path: str,
|
||
source: Optional[str] = None,
|
||
episode_group: Optional[str] = None,
|
||
obtain_images: bool = False,
|
||
) -> Optional[Context]:
|
||
"""
|
||
根据文件路径识别媒体信息,影视与音乐统一入口(异步版本)
|
||
|
||
:param path: 文件路径
|
||
:param source: 请求级识别数据源
|
||
:param episode_group: 剧集组
|
||
:param obtain_images: 是否补充图片
|
||
:return: 识别上下文
|
||
"""
|
||
logger.info(f"开始识别媒体信息,文件:{path} ...")
|
||
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
|
||
if self._is_music_path_request(path, source):
|
||
music_meta, music_info = await self.async_recognize_music_by_path(
|
||
path, source=source
|
||
)
|
||
return Context(meta_info=music_meta, media_info=music_info)
|
||
file_path = Path(path)
|
||
# 元数据
|
||
file_meta = MetaInfoPath(file_path)
|
||
mediainfo = await self._async_recognize_with_fallback_by_meta(
|
||
metainfo=file_meta,
|
||
source=source,
|
||
episode_group=episode_group,
|
||
obtain_images=obtain_images,
|
||
)
|
||
if not mediainfo:
|
||
logger.warn(f"{path} 未识别到媒体信息")
|
||
return Context(meta_info=file_meta)
|
||
# 返回上下文
|
||
return Context(meta_info=file_meta, media_info=mediainfo)
|
||
|
||
async def async_search(
|
||
self, title: str, source: Optional[str] = None
|
||
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
||
"""
|
||
搜索媒体/人物信息(异步版本)
|
||
|
||
:param title: 搜索内容
|
||
:param source: 请求级搜索数据源
|
||
:return: 识别元数据,媒体信息列表
|
||
"""
|
||
# 提取要素
|
||
mtype, key_word, season_num, episode_num, year, content = (
|
||
StringUtils.get_keyword(title)
|
||
)
|
||
# 识别
|
||
meta = MetaInfo(content)
|
||
if not meta.name:
|
||
meta.cn_name = content
|
||
# 合并信息
|
||
if mtype:
|
||
meta.type = mtype
|
||
if season_num:
|
||
meta.begin_season = season_num
|
||
if episode_num:
|
||
meta.begin_episode = episode_num
|
||
if year:
|
||
meta.year = year
|
||
# 开始搜索
|
||
logger.info(f"开始搜索媒体信息:{meta.name}")
|
||
medias: Optional[List[MediaInfo]] = await self.async_search_medias(
|
||
meta=meta, source=source
|
||
)
|
||
if not medias:
|
||
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
|
||
return meta, []
|
||
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
|
||
# 识别的元数据,媒体信息列表
|
||
return meta, medias
|
||
|
||
@staticmethod
|
||
def _extract_year_from_bangumi(bangumiinfo: dict) -> Optional[str]:
|
||
"""
|
||
从Bangumi信息中提取年份
|
||
"""
|
||
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
|
||
if release_date:
|
||
return release_date[:4]
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extract_year_from_tmdb(
|
||
tmdbinfo: dict, season: Optional[int] = None
|
||
) -> Optional[str]:
|
||
"""
|
||
从TMDB信息中提取年份
|
||
"""
|
||
year = None
|
||
if tmdbinfo.get("release_date"):
|
||
year = tmdbinfo["release_date"][:4]
|
||
elif tmdbinfo.get("seasons") and season is not None:
|
||
for seainfo in tmdbinfo["seasons"]:
|
||
season_number = seainfo.get("season_number")
|
||
if season_number is None:
|
||
continue
|
||
air_date = seainfo.get("air_date")
|
||
if air_date and season_number == season:
|
||
year = air_date[:4]
|
||
break
|
||
return year
|
||
|
||
def _match_tmdb_with_names(
|
||
self,
|
||
meta_names: list,
|
||
year: Optional[str],
|
||
mtype: MediaType,
|
||
season: Optional[int] = None,
|
||
) -> Optional[dict]:
|
||
"""
|
||
使用名称列表匹配TMDB信息
|
||
"""
|
||
for name in meta_names:
|
||
tmdbinfo = self.match_tmdbinfo(
|
||
name=name, year=year, mtype=mtype, season=season
|
||
)
|
||
if tmdbinfo:
|
||
return tmdbinfo
|
||
return None
|
||
|
||
async def _async_match_tmdb_with_names(
|
||
self,
|
||
meta_names: list,
|
||
year: Optional[str],
|
||
mtype: MediaType,
|
||
season: Optional[int] = None,
|
||
) -> Optional[dict]:
|
||
"""
|
||
使用名称列表匹配TMDB信息(异步版本)
|
||
"""
|
||
for name in meta_names:
|
||
tmdbinfo = await self.async_match_tmdbinfo(
|
||
name=name, year=year, mtype=mtype, season=season
|
||
)
|
||
if tmdbinfo:
|
||
return tmdbinfo
|
||
return None
|
||
|
||
async def async_get_tmdbinfo_by_doubanid(
|
||
self, doubanid: str, mtype: MediaType = None
|
||
) -> Optional[dict]:
|
||
"""
|
||
根据豆瓣ID获取TMDB信息(异步版本)
|
||
"""
|
||
tmdbinfo = None
|
||
doubaninfo = await self.async_douban_info(doubanid=doubanid, mtype=mtype)
|
||
if doubaninfo:
|
||
# 优先使用原标题匹配
|
||
if doubaninfo.get("original_title"):
|
||
meta = MetaInfo(title=doubaninfo.get("title"))
|
||
meta_org = MetaInfo(title=doubaninfo.get("original_title"))
|
||
else:
|
||
meta_org = meta = MetaInfo(title=doubaninfo.get("title"))
|
||
# 年份
|
||
if doubaninfo.get("year"):
|
||
meta.year = doubaninfo.get("year")
|
||
# 处理类型
|
||
if isinstance(doubaninfo.get("media_type"), MediaType):
|
||
meta.type = doubaninfo.get("media_type")
|
||
else:
|
||
meta.type = (
|
||
MediaType.MOVIE
|
||
if doubaninfo.get("type") == "movie"
|
||
else MediaType.TV
|
||
)
|
||
# 匹配TMDB信息
|
||
meta_names = list(
|
||
dict.fromkeys(
|
||
[k for k in [meta_org.name, meta.cn_name, meta.en_name] if k]
|
||
)
|
||
)
|
||
tmdbinfo = await self._async_match_tmdb_with_names(
|
||
meta_names=meta_names,
|
||
year=meta.year,
|
||
mtype=mtype or meta.type,
|
||
season=meta.begin_season,
|
||
)
|
||
if tmdbinfo:
|
||
# 合季季后返回
|
||
tmdbinfo["season"] = meta.begin_season
|
||
return tmdbinfo
|
||
|
||
async def async_get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||
"""
|
||
根据BangumiID获取TMDB信息(异步版本)
|
||
"""
|
||
bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid)
|
||
if bangumiinfo:
|
||
# 优先使用原标题匹配
|
||
if bangumiinfo.get("name_cn"):
|
||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||
meta_cn = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||
else:
|
||
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
|
||
# 年份
|
||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||
# 识别TMDB媒体信息
|
||
meta_names = list(
|
||
dict.fromkeys([k for k in [meta_cn.name, meta.name] if k])
|
||
)
|
||
tmdbinfo = await self._async_match_tmdb_with_names(
|
||
meta_names=meta_names,
|
||
year=year,
|
||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||
season=meta.begin_season,
|
||
)
|
||
return tmdbinfo
|
||
return None
|
||
|
||
async def async_get_doubaninfo_by_tmdbid(
|
||
self, tmdbid: int, mtype: MediaType = None, season: Optional[int] = None
|
||
) -> Optional[dict]:
|
||
"""
|
||
根据TMDBID获取豆瓣信息(异步版本)
|
||
"""
|
||
tmdbinfo = await self.async_tmdb_info(tmdbid=tmdbid, mtype=mtype)
|
||
if tmdbinfo:
|
||
# 名称
|
||
name = tmdbinfo.get("title") or tmdbinfo.get("name")
|
||
# 年份
|
||
year = self._extract_year_from_tmdb(tmdbinfo, season)
|
||
# IMDBID
|
||
imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id")
|
||
return await self.async_match_doubaninfo(
|
||
name=name, year=year, mtype=mtype, imdbid=imdbid
|
||
)
|
||
return None
|
||
|
||
async def async_get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
|
||
"""
|
||
根据BangumiID获取豆瓣信息(异步版本)
|
||
"""
|
||
bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid)
|
||
if bangumiinfo:
|
||
# 优先使用中文标题匹配
|
||
if bangumiinfo.get("name_cn"):
|
||
meta = MetaInfo(title=bangumiinfo.get("name_cn"))
|
||
else:
|
||
meta = MetaInfo(title=bangumiinfo.get("name"))
|
||
# 年份
|
||
year = self._extract_year_from_bangumi(bangumiinfo)
|
||
# 使用名称识别豆瓣媒体信息
|
||
return await self.async_match_doubaninfo(
|
||
name=meta.name,
|
||
year=year,
|
||
mtype=MediaInfo.get_bangumi_media_type(bangumiinfo),
|
||
season=meta.begin_season,
|
||
)
|
||
return None
|