refactor(meta): stabilize recognition contract

This commit is contained in:
jxxghp
2026-08-25 08:12:04 +08:00
parent fcf6f0c7df
commit 8e99abd678
6 changed files with 340 additions and 85 deletions
+100 -32
View File
@@ -1,7 +1,7 @@
import logging
import traceback
from dataclasses import dataclass
from typing import Union, Optional, List, Self
from dataclasses import asdict, dataclass
from typing import Any, Union, Optional, List, Self
import cn2an
import regex as re
@@ -40,6 +40,100 @@ VIDEO_BIT_RE = re.compile(
re.IGNORECASE,
)
_META_OPTIONAL_MERGE_FIELDS = (
"resource_type",
"resource_pix",
"resource_team",
"customization",
"resource_effect",
"web_source",
"video_encode",
"video_bit",
"audio_encode",
"part",
"episode_group",
)
@dataclass(frozen=True, slots=True)
class MetaInfoSnapshot:
"""MetaInfo 解析器的不可变稳定输出,用于跨实现等价校验和安全缓存。"""
kind: str
isfile: bool
title: str
org_string: Optional[str]
subtitle: Optional[str]
type: str
cn_name: Optional[str]
en_name: Optional[str]
original_name: Optional[str]
year: Optional[str]
total_season: int
begin_season: Optional[int]
end_season: Optional[int]
total_episode: int
begin_episode: Optional[int]
end_episode: Optional[int]
part: Optional[str]
resource_type: Optional[str]
resource_effect: Optional[str]
resource_pix: Optional[str]
resource_team: Optional[str]
customization: Optional[str]
web_source: Optional[str]
video_encode: Optional[str]
video_bit: Optional[str]
audio_encode: Optional[str]
apply_words: tuple[str, ...]
media_source: Optional[str]
media_id: Optional[str]
episode_group: Optional[str]
fps: Optional[int]
@classmethod
def from_meta(cls, meta: "MetaBase") -> "MetaInfoSnapshot":
"""从兼容的可变 MetaBase 对象提取不包含临时状态的完整契约。"""
media_type = getattr(meta.type, "value", meta.type)
media_source = getattr(meta.media_source, "value", meta.media_source)
return cls(
kind="anime" if type(meta).__name__ == "MetaAnime" else "video",
isfile=bool(meta.isfile),
title=meta.title or "",
org_string=meta.org_string,
subtitle=meta.subtitle,
type=media_type,
cn_name=meta.cn_name,
en_name=meta.en_name,
original_name=meta.original_name,
year=meta.year,
total_season=meta.total_season,
begin_season=meta.begin_season,
end_season=meta.end_season,
total_episode=meta.total_episode,
begin_episode=meta.begin_episode,
end_episode=meta.end_episode,
part=meta.part,
resource_type=meta.resource_type,
resource_effect=meta.resource_effect,
resource_pix=meta.resource_pix,
resource_team=meta.resource_team,
customization=meta.customization,
web_source=meta.web_source,
video_encode=meta.video_encode,
video_bit=meta.video_bit,
audio_encode=meta.audio_encode,
apply_words=tuple(meta.apply_words or ()),
media_source=media_source,
media_id=meta.media_id,
episode_group=meta.episode_group,
fps=meta.fps,
)
def to_dict(self) -> dict[str, Any]:
"""返回便于差异报告和序列化的独立字典。"""
return asdict(self)
@dataclass
class MetaBase(object):
@@ -652,45 +746,19 @@ class MetaBase(object):
self.begin_episode = meta.begin_episode
self.end_episode = meta.end_episode
self.total_episode = meta.total_episode
# 版本
if not self.resource_type:
self.resource_type = meta.resource_type
# 分辨率
if not self.resource_pix:
self.resource_pix = meta.resource_pix
# 制作组/字幕组
if not self.resource_team:
self.resource_team = meta.resource_team
# 自定义占位符
if not self.customization:
self.customization = meta.customization
# 特效
if not self.resource_effect:
self.resource_effect = meta.resource_effect
# 视频编码
if not self.video_encode:
self.video_encode = meta.video_encode
# 视频位深
if not self.video_bit:
self.video_bit = meta.video_bit
# 音频编码
if not self.audio_encode:
self.audio_encode = meta.audio_encode
# 普通可选字段统一遵循文件优先、父目录补空,新增字段只维护一份策略。
for field_name in _META_OPTIONAL_MERGE_FIELDS:
if not getattr(self, field_name):
setattr(self, field_name, getattr(meta, field_name))
# 帧率信息
if not self.fps:
self.fps = meta.fps
# Part
if not self.part:
self.part = meta.part
# 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对
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
def to_dict(self):
"""
+1
View File
@@ -127,6 +127,7 @@ class MetaVideo(MetaBase):
and title.isdigit() \
and len(title) < 5:
self.begin_episode = int(title)
self.total_episode = 1
self.type = MediaType.TV
return
# 全名为Season xx 及 Sxx 直接返回
+80 -32
View File
@@ -1,8 +1,9 @@
import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path
from functools import lru_cache
from typing import Tuple, List, Optional
from typing import Mapping, Tuple, List, Optional
import regex as re
@@ -88,6 +89,18 @@ _LEGACY_ID_KEYS = (
)
@dataclass(frozen=True, slots=True)
class _PreparedMetaInput:
"""Python 回退解析的阶段输入,保留原文与预处理结果之间的明确边界。"""
original_title: str
parsed_title: str
subtitle: Optional[str]
isfile: bool
apply_words: tuple[str, ...]
explicit_metainfo: Mapping[str, object]
def _empty_metainfo() -> dict:
"""
返回媒体标签的默认结构,避免不同识别请求之间共享可变状态。
@@ -263,37 +276,38 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
return title, _normalize_metainfo_identity(metainfo)
def _build_meta_info(
def _prepare_meta_input(
title: str,
subtitle: Optional[str] = None,
custom_words: List[str] = None,
) -> MetaBase:
) -> _PreparedMetaInput:
"""
根据标题构造元数据
应用识别词、显式标签和文件后缀规则,生成稳定的解析阶段输入。
"""
# 原标题
org_title = title
# 预处理标题
title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
# 获取标题中媒体信息
title, metainfo = find_metainfo(title)
# 判断是否处理文件
original_title = title
parsed_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
# 完整 Rust 入口已经失败或被禁用,参考实现不得再次跨边界调用部分 Rust 解析器。
parsed_title, explicit_metainfo = _find_metainfo_python(parsed_title)
media_exts = get_media_extensions()
title_path = Path(title) if title else None
title_path = Path(parsed_title) if parsed_title else None
if title_path and title_path.suffix.lower() in media_exts:
isfile = True
# 去掉后缀
title = title_path.stem
parsed_title = title_path.stem
else:
isfile = False
# 识别
meta = MetaAnime(title, subtitle, isfile) if is_anime(title) else MetaVideo(title, subtitle, isfile)
# 记录原标题
meta.title = org_title
# 记录使用的识别词
meta.apply_words = apply_words or []
# 修正媒体信息
media_source, media_id = resolve_media_identity(media=metainfo)
return _PreparedMetaInput(
original_title=original_title,
parsed_title=parsed_title,
subtitle=subtitle,
isfile=isfile,
apply_words=tuple(apply_words or ()),
explicit_metainfo=explicit_metainfo,
)
def _apply_explicit_metainfo(meta: MetaBase, metainfo: Mapping[str, object]) -> None:
"""以显式标签覆盖推断字段,保持用户声明拥有最高优先级。"""
media_source, media_id = resolve_media_identity(media=dict(metainfo))
if media_source and media_id:
meta.media_source = media_source
meta.media_id = media_id
@@ -313,6 +327,42 @@ def _build_meta_info(
meta.end_episode = metainfo['end_episode']
if metainfo.get('total_episode') is not None:
meta.total_episode = metainfo['total_episode']
def _build_meta_info(
title: str,
subtitle: Optional[str] = None,
custom_words: List[str] = None,
) -> MetaBase:
"""按准备、分类解析、显式覆盖三个阶段构造 Python MetaInfo。"""
prepared = _prepare_meta_input(title, subtitle, custom_words)
meta = MetaAnime(
prepared.parsed_title,
prepared.subtitle,
prepared.isfile,
) if is_anime(prepared.parsed_title) else MetaVideo(
prepared.parsed_title,
prepared.subtitle,
prepared.isfile,
)
meta.title = prepared.original_title
meta.apply_words = list(prepared.apply_words)
_apply_explicit_metainfo(meta, prepared.explicit_metainfo)
return meta
def _build_python_meta_info(
title: str,
subtitle: Optional[str] = None,
custom_words: List[str] = None,
) -> MetaBase:
"""构造并完成 original_name 的纯 Python 参考解析结果。"""
meta = _build_meta_info(title=title, subtitle=subtitle, custom_words=custom_words)
if meta.apply_words:
original_meta = _build_meta_info(title=title, subtitle=subtitle)
meta.original_name = original_meta.name or meta.name
else:
meta.original_name = meta.name or None
return meta
@@ -473,13 +523,11 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
)
if rust_meta:
return rust_meta
meta = _build_meta_info(title=title, subtitle=subtitle, custom_words=custom_words)
if meta.apply_words:
original_meta = _build_meta_info(title=title, subtitle=subtitle)
meta.original_name = original_meta.name or meta.name
else:
meta.original_name = meta.name
return meta
return _build_python_meta_info(
title=title,
subtitle=subtitle,
custom_words=custom_words,
)
def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool = False) -> MetaBase:
@@ -512,16 +560,16 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool =
if rust_meta:
return rust_meta
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=path.name, custom_words=custom_words)
file_meta = _build_python_meta_info(title=path.name, custom_words=custom_words)
if should_use_parent_title_for_file_stem(path.stem, path.parent.name, file_meta):
clear_parsed_title_for_parent_merge(file_meta)
# 上级目录元数据
dir_meta = MetaInfo(title=path.parent.name, custom_words=custom_words)
dir_meta = _build_python_meta_info(title=path.parent.name, custom_words=custom_words)
if file_meta.type == MediaType.TV or dir_meta.type != MediaType.TV:
# 合并元数据
file_meta.merge(dir_meta)
# 上上级目录元数据
root_meta = MetaInfo(title=path.parent.parent.name, custom_words=custom_words)
root_meta = _build_python_meta_info(title=path.parent.parent.name, custom_words=custom_words)
if file_meta.type == MediaType.TV or root_meta.type != MediaType.TV:
# 合并元数据
file_meta.merge(root_meta)
+35 -20
View File
@@ -11,10 +11,16 @@ sys.path.insert(0, str(PROJECT_ROOT))
from app.domain import metainfo as metainfo_module
from app.domain.meta.metaanime import MetaAnime
from app.domain.meta.metabase import MetaInfoSnapshot
from app.domain.meta.metamusic import MetaMusic
from app.domain.meta.runtime import get_audio_extensions
from app.domain.meta.runtime import (
configure_recognition_runtime,
get_audio_extensions,
get_metainfo_accelerator,
)
from app.domain.metainfo import MetaInfo, MetaInfoPath
from app.adapters.system import rust as rust_accel
from app.runtime.settings import RuntimeSettingsCompat
from tests.cases.meta import meta_cases
@@ -56,6 +62,20 @@ _MUSIC_CASES: tuple[BenchmarkInput, ...] = (
)
def configure_benchmark_runtime() -> None:
"""注入独立基准所需的文件类型和 Rust 适配器,避免静默测量 Python 回退。"""
settings = RuntimeSettingsCompat()
configure_recognition_runtime(
media_extensions_provider=lambda: (
*settings.RMT_MEDIAEXT,
*settings.RMT_SUBEXT,
*settings.RMT_AUDIOEXT,
),
audio_extensions_provider=lambda: settings.RMT_AUDIOEXT,
accelerator=rust_accel,
)
def build_video_inputs(repeat: int) -> list[BenchmarkInput]:
"""构造覆盖影视 MetaInfo 和 MetaInfoPath 生产入口的基准输入。"""
inputs: list[BenchmarkInput] = []
@@ -124,24 +144,10 @@ def _enum_value(value: Any) -> Any:
def project_video_result(meta: Any) -> dict[str, Any]:
"""提取影视识别对外契约字段,排除 Python 解析器的临时内部状态。"""
return {
"kind": "anime" if isinstance(meta, MetaAnime) else "video",
"type": _enum_value(meta.type),
"cn_name": meta.cn_name or "",
"en_name": meta.en_name or "",
"year": meta.year or "",
"part": meta.part or "",
"season": meta.season,
"episode": meta.episode,
"resource_type": meta.edition,
"resource_pix": meta.resource_pix or "",
"video_encode": meta.video_encode or "",
"audio_encode": meta.audio_encode or "",
"fps": meta.fps or None,
"media_source": _enum_value(meta.media_source),
"media_id": meta.media_id,
}
"""提取影视识别完整稳定契约,排除解析器的临时内部状态和派生展示字段"""
result = MetaInfoSnapshot.from_meta(meta).to_dict()
result["apply_words"] = list(result["apply_words"])
return result
def project_music_result(meta: Any) -> dict[str, Any]:
@@ -252,9 +258,17 @@ def benchmark_suite(
def validate_rust_runtime() -> None:
"""确认 Rust 总开关和音乐扩展入口可用,拒绝静默回退形成伪基准。"""
"""确认领域入口已注入可工作的 Rust 影视与音乐解析器,拒绝伪基准。"""
if not rust_accel.is_enabled():
raise RuntimeError("Rust 加速未启用或 moviepilot-rust 扩展不可用")
if get_metainfo_accelerator() is not rust_accel:
raise RuntimeError("MetaInfo 领域入口未注入 Rust 加速器")
video_probe = rust_accel.parse_metainfo(
"Benchmark Movie 2026 1080p WEB-DL H265",
options={"media_exts": [".mkv"]},
)
if not isinstance(video_probe, dict) or video_probe.get("en_name") != "Benchmark Movie":
raise RuntimeError("Rust 影视解析探针未返回有效结果,拒绝测量 Python 回退")
if not callable(getattr(rust_accel, "parse_metamusic", None)):
raise RuntimeError("MoviePilot 后端缺少 rust_accel.parse_metamusic 适配器")
extension = getattr(rust_accel, "_moviepilot_rust", None)
@@ -316,6 +330,7 @@ def main() -> int:
"""运行影视与音乐 Rust/Python 生产入口基准测试。"""
args = parse_args()
try:
configure_benchmark_runtime()
validate_rust_runtime()
video_inputs = build_video_inputs(args.repeat_inputs)
music_inputs = build_music_inputs(args.repeat_inputs)
+45
View File
@@ -23,6 +23,19 @@ def test_build_inputs_separates_video_and_music_domains():
for kind, value, _subtitle in music_once if kind == "music_query")
def test_configure_benchmark_runtime_injects_extensions_and_accelerator(monkeypatch):
"""独立基准必须显式注入文件类型和 Rust 适配器,不能依赖应用启动副作用。"""
configure_runtime = Mock()
monkeypatch.setattr(benchmark, "configure_recognition_runtime", configure_runtime)
benchmark.configure_benchmark_runtime()
kwargs = configure_runtime.call_args.kwargs
assert kwargs["accelerator"] is benchmark.rust_accel
assert ".mkv" in kwargs["media_extensions_provider"]()
assert ".flac" in kwargs["audio_extensions_provider"]()
def test_parse_input_uses_public_production_entries(monkeypatch):
"""输入分发应调用 MetaInfo、MetaInfoPath 和 MetaMusic.parse_query 公开入口。"""
title_result = object()
@@ -140,6 +153,24 @@ def test_video_projection_ignores_python_parser_internal_state():
)
def test_video_projection_covers_stable_path_merge_fields():
"""差异投影必须覆盖路径合并容易遗漏的平台、效果、位深和范围字段。"""
result = benchmark.MetaInfo("Show S01E01 2026 2160p AMZN WEB-DL HDR H265 10bit")
result.web_source = "Amazon"
result.resource_team = "GROUP"
result.customization = "CUSTOM"
projected = benchmark.project_video_result(result)
assert projected["web_source"] == "Amazon"
assert projected["resource_effect"] == "HDR"
assert projected["video_bit"] == "10bit"
assert projected["begin_season"] == 1
assert projected["begin_episode"] == 1
assert projected["resource_team"] == "GROUP"
assert projected["customization"] == "CUSTOM"
def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
"""运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。"""
rust_accel = benchmark.rust_accel
@@ -149,6 +180,12 @@ def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
benchmark.validate_rust_runtime()
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True))
monkeypatch.setattr(benchmark, "get_metainfo_accelerator", Mock(return_value=rust_accel))
monkeypatch.setattr(
rust_accel,
"parse_metainfo",
Mock(return_value={"en_name": "Benchmark Movie"}),
)
monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value={}), raising=False)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", SimpleNamespace())
@@ -161,6 +198,12 @@ def test_validate_rust_runtime_requires_successful_music_probe(monkeypatch):
rust_accel = benchmark.rust_accel
extension = SimpleNamespace(parse_metamusic_fast=Mock())
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True))
monkeypatch.setattr(benchmark, "get_metainfo_accelerator", Mock(return_value=rust_accel))
monkeypatch.setattr(
rust_accel,
"parse_metainfo",
Mock(return_value={"en_name": "Benchmark Movie"}),
)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", extension)
monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value=None), raising=False)
@@ -170,6 +213,7 @@ def test_validate_rust_runtime_requires_successful_music_probe(monkeypatch):
def test_main_outputs_independent_video_and_music_metrics(monkeypatch, capsys):
"""主程序应分别输出影视和音乐等价状态、耗时及性能提升。"""
monkeypatch.setattr(benchmark, "configure_benchmark_runtime", Mock())
monkeypatch.setattr(benchmark, "validate_rust_runtime", Mock())
monkeypatch.setattr(benchmark, "build_video_inputs", Mock(return_value=[("title", "V", None)]))
monkeypatch.setattr(
@@ -212,6 +256,7 @@ def test_main_outputs_independent_video_and_music_metrics(monkeypatch, capsys):
def test_main_reports_runtime_failure_with_nonzero_exit(monkeypatch, capsys):
"""Rust 未就绪时主程序应明确报错并返回非零状态。"""
monkeypatch.setattr(benchmark, "configure_benchmark_runtime", Mock())
monkeypatch.setattr(
benchmark,
"validate_rust_runtime",
+79 -1
View File
@@ -7,9 +7,15 @@ import pytest
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metabase import MetaBase, MetaInfoSnapshot
from app.domain.meta.metamusic import MetaMusic
from app.domain.meta.metaanime import MetaAnime
from app.domain.meta.runtime import (
configure_recognition_runtime,
get_audio_extensions,
get_media_extensions,
get_metainfo_accelerator,
)
from app.application.torrent import TorrentHelper
from app.schemas.types import MediaSource, MediaType
from tests.cases.meta import meta_cases
@@ -100,6 +106,78 @@ def test_metainfopath_with_empty_custom_words():
assert meta is not None
def test_metainfo_snapshot_exposes_complete_immutable_contract():
"""稳定快照应包含路径合并字段,且不受原 MetaBase 后续修改影响。"""
meta = MetaInfo("Show.S01E01.2026.2160p.WEB-DL.HDR.H265.10bit-GROUP.mkv")
meta.web_source = "Amazon"
snapshot = MetaInfoSnapshot.from_meta(meta)
meta.web_source = "Netflix"
assert snapshot.kind == "video"
assert snapshot.begin_season == 1
assert snapshot.begin_episode == 1
assert snapshot.resource_effect == "HDR"
assert snapshot.video_bit == "10bit"
assert snapshot.web_source == "Amazon"
assert snapshot.apply_words == ()
def test_metainfopath_merges_parent_streaming_platform():
"""文件名缺少平台时应从父目录补充,避免路径识别丢失稳定资源字段。"""
media_extensions = get_media_extensions()
audio_extensions = get_audio_extensions()
accelerator = get_metainfo_accelerator()
configure_recognition_runtime(
media_extensions_provider=lambda: (".mkv",),
audio_extensions_provider=lambda: (),
accelerator=None,
)
try:
meta = MetaInfoPath(Path("/Show 2024 AMZN WEB-DL/Show.S01E01.mkv"))
finally:
configure_recognition_runtime(
media_extensions_provider=lambda: media_extensions,
audio_extensions_provider=lambda: audio_extensions,
accelerator=accelerator,
)
assert meta.web_source == "Amazon"
assert meta.year == "2024"
assert meta.episode == "E01"
def test_numeric_video_filename_sets_single_episode_total():
"""纯数字视频文件名表示单集时,范围字段必须保持自洽。"""
media_extensions = get_media_extensions()
audio_extensions = get_audio_extensions()
accelerator = get_metainfo_accelerator()
configure_recognition_runtime(
media_extensions_provider=lambda: (".mkv",),
audio_extensions_provider=lambda: (),
accelerator=None,
)
try:
meta = MetaInfo("5.mkv")
finally:
configure_recognition_runtime(
media_extensions_provider=lambda: media_extensions,
audio_extensions_provider=lambda: audio_extensions,
accelerator=accelerator,
)
assert meta.begin_episode == 5
assert meta.end_episode is None
assert meta.total_episode == 1
def test_empty_video_title_keeps_optional_original_name_none():
"""无法提取标题时 original_name 保持空值,不使用含义不同的空字符串。"""
meta = MetaInfo("S02E1000.mkv")
assert meta.name == ""
assert meta.original_name is None
def test_custom_words_apply_words_recording():
"""测试 apply_words 记录功能。"""
custom_words = ["替换词 => 新词"]