mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 11:04:12 +08:00
1885 lines
74 KiB
Python
1885 lines
74 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.lrclib import LrclibChain
|
||
from app.chain.storage import StorageChain
|
||
from app.runtime.cache import async_fresh, cached, fresh
|
||
from app.runtime.config import settings
|
||
from app.domain.context import (
|
||
Context,
|
||
MediaInfo,
|
||
MusicAlbumInfo,
|
||
MusicInfo,
|
||
MusicLyrics,
|
||
)
|
||
from app.runtime.events import eventmanager, Event
|
||
from app.domain.meta.metabase import MetaBase
|
||
from app.domain.meta.metamusic import MetaMusic
|
||
from app.domain.metainfo import MetaInfo, MetaInfoPath
|
||
from app.db.oper.systemconfig import SystemConfigOper
|
||
from app.application.audio import AudioMetadataHelper
|
||
from app.runtime.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.adapters.network.http import RequestUtils
|
||
from app.domain.media import is_music_media_source
|
||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||
from app.runtime.reload import ConfigReloadMixin
|
||
from app.foundation.singleton import Singleton
|
||
|
||
|
||
|
||
from app.chain.media import MediaChain
|
||
|
||
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 ScrapingChain(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,
|
||
)
|
||
|
||
def __init__(self):
|
||
"""初始化存储访问和当前刮削策略。"""
|
||
super().__init__()
|
||
self.storagechain = StorageChain()
|
||
self.scraping_policies = ScrapingConfig.from_system_config()
|
||
|
||
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
|
||
|
||
@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 = MediaChain().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
|
||
lyrics_chain = None
|
||
if not lyrics_option.is_skip:
|
||
lyrics_chain = LrclibChain()
|
||
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 (
|
||
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] = MediaChain().get_music_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,
|
||
lyrics_chain=lyrics_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 ScrapingChain._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,
|
||
lyrics_chain: Optional[LrclibChain] = 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 lyrics_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,
|
||
lyrics_chain=lyrics_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,
|
||
lyrics_chain=lyrics_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,
|
||
lyrics_chain: Optional[LrclibChain],
|
||
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,
|
||
lyrics_chain=lyrics_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 = MediaChain().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,
|
||
lyrics_chain: Optional[LrclibChain],
|
||
album_info: Optional[MusicAlbumInfo],
|
||
) -> str:
|
||
"""按歌词策略查询单个音轨并保存同名旁挂歌词文件。"""
|
||
if not lyrics_option or lyrics_option.is_skip or not lyrics_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 = lyrics_chain.get_music_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
|
||
|
||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||
if not media_source or not media_id:
|
||
logger.warn(f"{filepath.name} 缺少完整媒体身份,无法识别剧集信息!")
|
||
return
|
||
file_mediainfo = MediaChain().recognize_media(
|
||
meta=file_meta,
|
||
media_source=media_source,
|
||
media_id=media_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("无法识别元数据,跳过")
|