mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 00:32:50 +08:00
refactor(media): unify media identity and chain responsibilities
This commit is contained in:
@@ -20,7 +20,7 @@ from app.schemas.types import (
|
||||
MediaSource,
|
||||
MediaType,
|
||||
)
|
||||
from app.utils.media import normalize_media_source
|
||||
from app.utils.media import normalize_media_source, resolve_media_identity
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
|
||||
@@ -168,8 +168,8 @@ class MusicInfo:
|
||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""将构造参数中的数据源规范化为统一枚举。"""
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
"""将构造参数中的媒体身份规范化为统一成对字段。"""
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
|
||||
@property
|
||||
def artist(self) -> str:
|
||||
@@ -394,8 +394,8 @@ class MusicAlbumInfo:
|
||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""将构造参数中的数据源规范化为统一枚举。"""
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
"""将构造参数中的媒体身份规范化为统一成对字段。"""
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
|
||||
@property
|
||||
def artist(self) -> str:
|
||||
@@ -558,8 +558,8 @@ class MusicArtistInfo:
|
||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""将构造参数中的数据源规范化为统一枚举。"""
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
"""将构造参数中的媒体身份规范化为统一成对字段。"""
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
|
||||
@property
|
||||
def title(self) -> str | None:
|
||||
@@ -703,6 +703,10 @@ class TorrentInfo:
|
||||
# 种子分类 电影/电视剧/音乐
|
||||
category: str = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""将种子声明的媒体身份规范化为统一成对字段。"""
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
self.__dict__[name] = value
|
||||
|
||||
@@ -726,9 +730,7 @@ class TorrentInfo:
|
||||
if key in properties:
|
||||
continue
|
||||
setattr(self, key, value)
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
if self.media_id is not None:
|
||||
self.media_id = str(self.media_id)
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
|
||||
@staticmethod
|
||||
def get_free_string(upload_volume_factor: float, download_volume_factor: float) -> str:
|
||||
@@ -1022,7 +1024,7 @@ class MediaInfo:
|
||||
|
||||
def __post_init__(self):
|
||||
"""规范化媒体来源,并从各来源原始数据初始化统一字段。"""
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
# 设置媒体信息
|
||||
if self.tmdb_info:
|
||||
self.set_tmdb_info(self.tmdb_info)
|
||||
@@ -1032,7 +1034,7 @@ class MediaInfo:
|
||||
self.set_bangumi_info(self.bangumi_info)
|
||||
if self.anilist_info:
|
||||
self.set_anilist_info(self.anilist_info)
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
self.__dict__[name] = value
|
||||
@@ -1057,7 +1059,7 @@ class MediaInfo:
|
||||
if key in properties:
|
||||
continue
|
||||
setattr(self, key, value)
|
||||
self.media_source = normalize_media_source(self.media_source)
|
||||
self.media_source, self.media_id = resolve_media_identity(media=self)
|
||||
if isinstance(self.type, str):
|
||||
self.type = MediaType(self.type)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import regex as re
|
||||
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.media import resolve_media_identity
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@@ -678,12 +679,11 @@ class MetaBase(object):
|
||||
if not self.part:
|
||||
self.part = meta.part
|
||||
# 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对
|
||||
if not (self.media_source and self.media_id) and meta.media_source and meta.media_id:
|
||||
try:
|
||||
self.media_source = MediaSource(meta.media_source)
|
||||
self.media_id = str(meta.media_id)
|
||||
except ValueError:
|
||||
pass
|
||||
current_source, current_id = resolve_media_identity(media=self)
|
||||
if current_source and current_id:
|
||||
self.media_source, self.media_id = current_source, current_id
|
||||
else:
|
||||
self.media_source, self.media_id = resolve_media_identity(media=meta)
|
||||
# 剧集组
|
||||
if not self.episode_group and meta.episode_group:
|
||||
self.episode_group = meta.episode_group
|
||||
|
||||
@@ -5,7 +5,9 @@ from threading import RLock
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils import rust_accel
|
||||
from app.utils.media import resolve_media_identity
|
||||
|
||||
|
||||
_AUDIO_FORMAT_PATTERN = re.compile(
|
||||
@@ -539,6 +541,8 @@ class MusicNameRegistry:
|
||||
|
||||
_patterns: dict[str, MusicNamePattern] = {}
|
||||
_parsers: dict[str, MusicNameParser] = {}
|
||||
_default_patterns: dict[str, MusicNamePattern] = {}
|
||||
_default_parsers: dict[str, MusicNameParser] = {}
|
||||
_lock = RLock()
|
||||
|
||||
@classmethod
|
||||
@@ -613,6 +617,32 @@ class MusicNameRegistry:
|
||||
return None
|
||||
return parser.handler(context, matched)
|
||||
|
||||
@classmethod
|
||||
def _capture_default_components(cls) -> None:
|
||||
"""保存内置命名组件的对象快照,供 Rust 快路判断兼容性。"""
|
||||
with cls._lock:
|
||||
cls._default_patterns = dict(cls._patterns)
|
||||
cls._default_parsers = dict(cls._parsers)
|
||||
|
||||
@classmethod
|
||||
def _uses_default_components(cls) -> bool:
|
||||
"""判断当前注册表是否仍为未替换的内置命名组件。"""
|
||||
with cls._lock:
|
||||
if not cls._default_patterns or not cls._default_parsers:
|
||||
return False
|
||||
if (
|
||||
cls._patterns.keys() != cls._default_patterns.keys()
|
||||
or cls._parsers.keys() != cls._default_parsers.keys()
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
component is cls._default_patterns[name]
|
||||
for name, component in cls._patterns.items()
|
||||
) and all(
|
||||
component is cls._default_parsers[name]
|
||||
for name, component in cls._parsers.items()
|
||||
)
|
||||
|
||||
|
||||
class MetaMusic(MetaBase):
|
||||
"""音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。"""
|
||||
@@ -637,7 +667,7 @@ class MetaMusic(MetaBase):
|
||||
bitrate: Optional[int] = None,
|
||||
duration: Optional[int] = None,
|
||||
isrc: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
parse_title: bool = False,
|
||||
):
|
||||
@@ -662,12 +692,75 @@ class MetaMusic(MetaBase):
|
||||
self.bitrate = bitrate
|
||||
self.duration = duration
|
||||
self.isrc = isrc
|
||||
self.media_source = media_source
|
||||
self.media_id = media_id
|
||||
self.media_source, self.media_id = resolve_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
if parse_title:
|
||||
# 种子/文件名字符串场景:解析艺术家、曲名、年份并补充音质参数
|
||||
self.apply_title(self.title or org_string or "")
|
||||
|
||||
@classmethod
|
||||
def parse_query(cls, query: str) -> "MetaMusic":
|
||||
"""把用户输入或资源标题解析为音乐元数据。"""
|
||||
return cls(org_string=query, title=query, parse_title=True)
|
||||
|
||||
@classmethod
|
||||
def from_music_info(cls, info: Any) -> "MetaMusic":
|
||||
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
|
||||
return cls(
|
||||
title=info.title,
|
||||
artists=list(info.artists),
|
||||
album=info.album,
|
||||
album_artist=info.album_artist,
|
||||
year=info.year,
|
||||
disc_number=info.disc_number,
|
||||
track_number=info.track_number,
|
||||
total_discs=getattr(info, "total_discs", None),
|
||||
total_tracks=info.total_tracks,
|
||||
version=info.version,
|
||||
audio_format=info.audio_format,
|
||||
audio_lossless=info.audio_lossless,
|
||||
bit_depth=info.bit_depth,
|
||||
sample_rate=info.sample_rate,
|
||||
bitrate=info.bitrate,
|
||||
duration=info.duration,
|
||||
isrc=info.isrc,
|
||||
media_source=info.media_source,
|
||||
media_id=info.media_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_album_context(
|
||||
cls,
|
||||
directory_name: str,
|
||||
tracks: list["MetaMusic"],
|
||||
) -> "MetaMusic":
|
||||
"""按目录名和多数音轨标签汇总专辑识别条件。"""
|
||||
directory = cls.parse_album_dir(directory_name)
|
||||
album_votes: dict[str, int] = {}
|
||||
artist_votes: dict[str, int] = {}
|
||||
for track in tracks:
|
||||
if track.album:
|
||||
album_votes[track.album] = album_votes.get(track.album, 0) + 1
|
||||
artist = track.album_artist or (track.artists[0] if track.artists else None)
|
||||
if artist:
|
||||
artist_votes[artist] = artist_votes.get(artist, 0) + 1
|
||||
majority_album = max(album_votes, key=album_votes.get) if album_votes else None
|
||||
majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None
|
||||
threshold = max(2, len(tracks) // 2)
|
||||
album = majority_album if majority_album and album_votes[majority_album] >= threshold else None
|
||||
artist = majority_artist if majority_artist and artist_votes[majority_artist] >= threshold else None
|
||||
return cls(
|
||||
org_string=directory_name,
|
||||
title=album or directory.get("album") or directory_name,
|
||||
album=album or directory.get("album"),
|
||||
artists=[artist or directory.get("artist")]
|
||||
if artist or directory.get("artist") else [],
|
||||
album_artist=artist or directory.get("artist"),
|
||||
year=directory.get("year"),
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""返回搜索和展示使用的音乐名称,优先专辑名其次标题。"""
|
||||
@@ -725,6 +818,14 @@ class MetaMusic(MetaBase):
|
||||
命名模式和对应解析器,最后统一回填结构化字段并提取曲序前缀。
|
||||
"""
|
||||
raw = str(value or "")
|
||||
if MusicNameRegistry._uses_default_components():
|
||||
rust_result = rust_accel.parse_metamusic(
|
||||
raw,
|
||||
artists=list(self.artists) or None,
|
||||
year=self.year,
|
||||
)
|
||||
if rust_result and self._apply_rust_title_result(rust_result):
|
||||
return
|
||||
self.apply_audio_quality(raw)
|
||||
context = self._prepare_name_context(
|
||||
raw=raw,
|
||||
@@ -743,6 +844,30 @@ class MetaMusic(MetaBase):
|
||||
self._apply_name_result(context, parsed)
|
||||
self._apply_track_prefix()
|
||||
|
||||
def _apply_rust_title_result(self, parsed: dict[str, Any]) -> bool:
|
||||
"""回填 Rust 音乐解析结果,并保留调用方已有的高可信字段。"""
|
||||
if "title" not in parsed:
|
||||
return False
|
||||
parsed_meta = type(self).from_dict(parsed)
|
||||
self.title = parsed_meta.title
|
||||
for field_name in (
|
||||
"artists",
|
||||
"album",
|
||||
"year",
|
||||
"disc_number",
|
||||
"track_number",
|
||||
"audio_format",
|
||||
"audio_lossless",
|
||||
"bit_depth",
|
||||
"sample_rate",
|
||||
"bitrate",
|
||||
):
|
||||
current_value = getattr(self, field_name, None)
|
||||
parsed_value = getattr(parsed_meta, field_name, None)
|
||||
if current_value in (None, "", []) and parsed_value not in (None, "", []):
|
||||
setattr(self, field_name, parsed_value)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _prepare_name_context(
|
||||
cls,
|
||||
@@ -1778,6 +1903,7 @@ def _register_default_name_components() -> None:
|
||||
MusicNameRegistry.register_pattern(pattern)
|
||||
for parser in parsers:
|
||||
MusicNameRegistry.register_parser(parser)
|
||||
MusicNameRegistry._capture_default_components()
|
||||
|
||||
|
||||
_register_default_name_components()
|
||||
|
||||
@@ -110,8 +110,9 @@ def _normalize_metainfo_identity(metainfo: dict) -> dict:
|
||||
if not media_source:
|
||||
for source, key in _LEGACY_ID_KEYS:
|
||||
value = normalized.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
media_source, media_id = source, str(value).strip()
|
||||
normalized_id = str(value).strip() if value is not None else ""
|
||||
if normalized_id and normalized_id != "0":
|
||||
media_source, media_id = source, normalized_id
|
||||
break
|
||||
for _, key in _LEGACY_ID_KEYS:
|
||||
normalized.pop(key, None)
|
||||
@@ -177,9 +178,11 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
||||
legacy_matches = []
|
||||
for source, pattern in _LEGACY_BRACED_ID_PATTERNS:
|
||||
legacy_match = pattern.search(result)
|
||||
if legacy_match and legacy_match.group(0).isdigit():
|
||||
legacy_identities[source] = legacy_match.group(0)
|
||||
if legacy_match:
|
||||
legacy_matches.append(legacy_match)
|
||||
normalized_id = legacy_match.group(0)
|
||||
if normalized_id.isdigit() and normalized_id != "0":
|
||||
legacy_identities[source] = normalized_id
|
||||
# 查找媒体类型
|
||||
mtype = _BRACED_TYPE_RE.search(result)
|
||||
if mtype:
|
||||
@@ -223,14 +226,16 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
||||
# 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。
|
||||
tmdb_match = _EMBY_TMDB_RE_LIST[0].search(title)
|
||||
if tmdb_match:
|
||||
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
|
||||
if tmdb_match.group(1) != "0":
|
||||
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
|
||||
title = _EMBY_TMDB_RE_LIST[0].sub('', title).strip()
|
||||
elif MediaSource.TMDB not in legacy_identities:
|
||||
# 保持原有优先级:[tmdbid] > [tmdb] > {tmdbid} > {tmdb}
|
||||
for tmdb_re in _EMBY_TMDB_RE_LIST[1:]:
|
||||
tmdb_match = tmdb_re.search(title)
|
||||
if tmdb_match:
|
||||
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
|
||||
if tmdb_match.group(1) != "0":
|
||||
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
|
||||
title = tmdb_re.sub('', title).strip()
|
||||
break
|
||||
|
||||
@@ -242,7 +247,8 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
||||
media_id_match = media_id_re.search(title)
|
||||
if not media_id_match:
|
||||
continue
|
||||
legacy_identities[source] = media_id_match.group(1)
|
||||
if media_id_match.group(1) != "0":
|
||||
legacy_identities[source] = media_id_match.group(1)
|
||||
title = media_id_re.sub('', title).strip()
|
||||
break
|
||||
|
||||
@@ -426,14 +432,17 @@ def _requires_python_metainfo(
|
||||
custom_words: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签。
|
||||
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的媒体身份标签。
|
||||
|
||||
:param title: 原始标题
|
||||
:param custom_words: 临时识别词
|
||||
:return: 是否必须使用Python解析器
|
||||
"""
|
||||
candidates = [title or "", *(custom_words or [])]
|
||||
if any(_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates):
|
||||
contains_generic_id = any(
|
||||
_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
|
||||
)
|
||||
if contains_generic_id and not rust_accel.supports_unified_media_identity():
|
||||
return True
|
||||
contains_extended_id = any(
|
||||
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
|
||||
@@ -486,14 +495,11 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool =
|
||||
# 音频文件直接构造音乐元数据,不参与父目录季集合并,影视附加音轨强制走视频解析
|
||||
audio_suffix = path.suffix.lower()
|
||||
if not force_video and audio_suffix in settings.RMT_AUDIOEXT:
|
||||
music_meta = MetaMusic(
|
||||
return MetaMusic(
|
||||
org_string=path.name,
|
||||
title=path.stem,
|
||||
audio_format=audio_suffix.lstrip(".").upper() or None,
|
||||
parse_title=True,
|
||||
)
|
||||
# 无标签音频只能依靠文件名和目录结构,补充曲序、碟号、歌手和专辑线索
|
||||
return music_meta.apply_path_context(path)
|
||||
).apply_path_context(path)
|
||||
path_context = " ".join(
|
||||
[path.name, path.parent.name, path.parent.parent.name]
|
||||
)
|
||||
|
||||
@@ -1336,7 +1336,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
if not settings.PLUGIN_MARKET:
|
||||
return []
|
||||
|
||||
# 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取
|
||||
# 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。
|
||||
compatible_flags = (
|
||||
[settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])
|
||||
if settings.VERSION_FLAG else []
|
||||
@@ -1348,10 +1348,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
# future -> (market_index, is_higher, flag_priority)
|
||||
futures_meta: Dict[concurrent.futures.Future, Tuple[int, bool, int]] = {}
|
||||
for market_index, m in enumerate(markets):
|
||||
# 提交任务获取 v1 版本插件
|
||||
# 默认索引只展示声明 V2 或当前版本兼容的共享实现。
|
||||
base_future = executor.submit(self.get_plugins_from_market, m, None, force)
|
||||
futures_meta[base_future] = (market_index, False, 0)
|
||||
# 提交任务获取高版本插件(如 v3)及向后兼容版本(如 v2)
|
||||
# 提交当前专用索引(如 v3)及可扫描的旧索引(如 v2)。
|
||||
for flag_priority, flag in enumerate(compatible_flags):
|
||||
higher_future = executor.submit(self.get_plugins_from_market, m, flag, force)
|
||||
futures_meta[higher_future] = (market_index, True, flag_priority)
|
||||
@@ -1628,8 +1628,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
return None
|
||||
|
||||
plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy())
|
||||
# 如 package_version 为空(package.json 来源),则需要判断插件是否兼容当前版本或任一向后兼容版本
|
||||
if not package_version and not PluginHelper.is_plugin_info_compatible(plugin_info):
|
||||
if not PluginHelper.is_package_plugin_compatible(
|
||||
plugin_info, package_version or ""
|
||||
):
|
||||
# 插件当前版本不兼容
|
||||
return None
|
||||
|
||||
@@ -1762,7 +1763,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
base_version_plugins = []
|
||||
tasks = []
|
||||
|
||||
# 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取
|
||||
# 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。
|
||||
compatible_flags = (
|
||||
[settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])
|
||||
if settings.VERSION_FLAG else []
|
||||
|
||||
Reference in New Issue
Block a user