refactor(media): unify media identity and chain responsibilities

This commit is contained in:
jxxghp
2026-08-12 18:31:09 +08:00
parent cbb969e0ff
commit ca32922a7f
155 changed files with 8371 additions and 4526 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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]
)

View File

@@ -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 []