Files
MoviePilot/app/chain/media.py

3397 lines
134 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
MediaSource,
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 = MediaSource.TMDB
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")
media_source = module_kwargs.get("media_source")
if (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or is_music_media_source(media_source)
):
music_chain = MusicChain()
if media_source:
recognize_kwargs = {
"media_source": media_source,
"meta": meta if isinstance(meta, MetaMusic) else None,
"media_id": module_kwargs.get("media_id"),
"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 media_source and isinstance(meta, MetaBase):
module_kwargs = {
**module_kwargs,
"media_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")
media_source = module_kwargs.get("media_source")
if (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or is_music_media_source(media_source)
):
music_chain = MusicChain()
if media_source:
recognize_kwargs = {
"media_source": media_source,
"meta": meta if isinstance(meta, MetaMusic) else None,
"media_id": module_kwargs.get("media_id"),
"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 media_source and isinstance(meta, MetaBase):
module_kwargs = {
**module_kwargs,
"media_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.jpgthumb.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,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
mtype: Optional[MediaType] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息
:param metainfo: 标题解析元数据
:param media_source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:param mtype: 上游已确定的媒体类型
:param music_type: 音乐实体类型,用于约束显式音乐身份及插件结果
"""
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=metainfo,
mtype=mtype,
media_source=media_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.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,
media_source=MediaSource.TMDB,
media_id=str(mediainfo.tmdb_id) if mediainfo.tmdb_id else None,
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,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
根据标题识别媒体信息,必要时回退到辅助识别。
:param metainfo: 标题解析元数据
:param mtype: 上游已确定的媒体类型
:param media_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.media_source)) if is_music else None
)
def native_recognize() -> Optional[MediaInfo]:
"""使用请求级数据源执行原生识别。"""
return self.recognize_media(
meta=metainfo,
mtype=mtype,
media_source=media_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,
media_source=media_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,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求辅助识别,返回媒体信息;影视与音乐共用同一流程,仅要素事件与重组方式不同
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param media_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,
media_source=media_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,
media_source=media_source,
share_meta=share_meta,
episode_group=episode_group,
)
def _recognize_music_help(
self,
title: str,
org_meta: MetaMusic,
share_meta: MetaBase = None,
media_source: Optional[MediaSource] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息
:param title: 原始音乐标题
:param org_meta: 原始音乐元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param media_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,
media_source=media_source,
share_meta=share_meta,
music_type=music_type,
)
return mediainfo if mediainfo and mediainfo.media_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.media_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(
media_source="musicbrainz",
meta=identity_meta,
media_id=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(
media_source="musicbrainz",
meta=identity_meta,
media_id=recording_id,
music_type=MUSIC_ENTITY_RECORDING,
)
def _recognize_music_meta_tier(
self,
meta: Optional[MetaMusic],
media_source: Optional[MediaSource],
tier_name: str,
) -> Optional[MusicInfo]:
"""识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
if not meta:
return None
normalized_source = normalize_media_source(media_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,
media_source=media_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],
media_source: Optional[MediaSource],
tier_name: str,
) -> Optional[MusicInfo]:
"""异步识别单个音乐元数据证据层,标签中的 MBID 优先直查。"""
if not meta:
return None
normalized_source = normalize_media_source(media_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,
media_source=media_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],
media_source: Optional[MediaSource] = None,
) -> Tuple[MetaMusic, MusicInfo]:
"""按指纹、文件标签、文件名三级顺序识别本地音乐。"""
meta, tag_meta, filename_meta = MusicChain.read_path_evidence(path)
info = None
normalized_source = normalize_media_source(media_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,
media_source=media_source,
tier_name="文件标签",
)
if not self._is_remote_music_info(info):
info = self._recognize_music_meta_tier(
meta=filename_meta,
media_source=media_source,
tier_name="文件名",
)
result = self._merge_music_audio_quality(
info or self._music_info_from_path_meta(meta), meta
)
if not result.media_source and media_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],
media_source: Optional[MediaSource] = 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(media_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,
media_source=media_source,
tier_name="文件标签",
)
if not self._is_remote_music_info(info):
info = await self._async_recognize_music_meta_tier(
meta=filename_meta,
media_source=media_source,
tier_name="文件名",
)
result = self._merge_music_audio_quality(
info or self._music_info_from_path_meta(meta), meta
)
if not result.media_source and media_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, media_source: Optional[MediaSource]) -> bool:
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
return self.is_audio_path(path) or is_music_media_source(media_source)
def recognize_by_path(
self,
path: str,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息,影视与音乐统一入口
:param path: 文件路径
:param media_source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
if self._is_music_path_request(path, media_source):
music_meta, music_info = self.recognize_music_by_path(
path, media_source=media_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,
media_source=media_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, media_source: Optional[MediaSource] = None
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体/人物信息
:param title: 搜索内容
:param media_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, media_source=media_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,
media_source: Optional[MediaSource] = 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.media_source
and item_media.media_id
):
album_key = (item_media.media_source, item_media.media_id)
if album_key not in album_cache:
album_cache[album_key] = music_chain.album(
media_source=item_media.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,
media_source=media_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.media_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,
media_source: Optional[MediaSource] = 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,
media_source=media_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,
media_source=media_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],
media_source: Optional[MediaSource],
) -> _MusicScrapeFileResult:
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, media_source=media_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,
media_source=media_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.media_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],
media_source: Optional[MediaSource] = 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, media_source=media_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,
media_source: Optional[MediaSource] = None,
) -> bool:
"""解析单个本地音轨并按独立策略写入标签和封面。"""
scrape_info = scrape_info or self._resolve_music_scrape_info(
local_path,
mediainfo,
media_source=media_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,
media_source=MediaSource.TMDB,
media_id=str(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,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
mtype: Optional[MediaType] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息(异步版本)
:param metainfo: 标题解析元数据
:param media_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,
media_source=media_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,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
异步根据标题识别媒体信息,必要时回退到辅助识别。
:param metainfo: 标题解析元数据
:param mtype: 上游已确定的媒体类型
:param media_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.media_source)) if is_music else None
)
async def native_recognize() -> Optional[MediaInfo]:
"""异步使用请求级数据源执行原生识别。"""
return await self.async_recognize_media(
meta=metainfo,
mtype=mtype,
media_source=media_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,
media_source=media_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,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求辅助识别,返回媒体信息(异步版本);影视与音乐共用同一流程
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param media_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,
media_source=media_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,
media_source=media_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,
media_source: Optional[MediaSource] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息(异步版本)
:param title: 原始音乐标题
:param org_meta: 原始音乐元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param media_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,
media_source=media_source,
share_meta=share_meta,
music_type=music_type,
)
return mediainfo if mediainfo and mediainfo.media_source else None
async def async_recognize_by_path(
self,
path: str,
media_source: Optional[MediaSource] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息,影视与音乐统一入口(异步版本)
:param path: 文件路径
:param media_source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
# 音频文件直接在本链完成标签读取、搜索匹配与专辑目录兜底,封面等图片由刮削环节补充
if self._is_music_path_request(path, media_source):
music_meta, music_info = await self.async_recognize_music_by_path(
path, media_source=media_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,
media_source=media_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, media_source: Optional[MediaSource] = None
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体/人物信息(异步版本)
:param title: 搜索内容
:param media_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, media_source=media_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