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 logging
import traceback import traceback
from dataclasses import dataclass from dataclasses import asdict, dataclass
from typing import Union, Optional, List, Self from typing import Any, Union, Optional, List, Self
import cn2an import cn2an
import regex as re import regex as re
@@ -40,6 +40,100 @@ VIDEO_BIT_RE = re.compile(
re.IGNORECASE, 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 @dataclass
class MetaBase(object): class MetaBase(object):
@@ -652,45 +746,19 @@ class MetaBase(object):
self.begin_episode = meta.begin_episode self.begin_episode = meta.begin_episode
self.end_episode = meta.end_episode self.end_episode = meta.end_episode
self.total_episode = meta.total_episode self.total_episode = meta.total_episode
# 版本 # 普通可选字段统一遵循文件优先、父目录补空,新增字段只维护一份策略。
if not self.resource_type: for field_name in _META_OPTIONAL_MERGE_FIELDS:
self.resource_type = meta.resource_type if not getattr(self, field_name):
# 分辨率 setattr(self, field_name, getattr(meta, field_name))
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
# 帧率信息 # 帧率信息
if not self.fps: if not self.fps:
self.fps = meta.fps self.fps = meta.fps
# Part
if not self.part:
self.part = meta.part
# 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对 # 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对
current_source, current_id = resolve_media_identity(media=self) current_source, current_id = resolve_media_identity(media=self)
if current_source and current_id: if current_source and current_id:
self.media_source, self.media_id = current_source, current_id self.media_source, self.media_id = current_source, current_id
else: else:
self.media_source, self.media_id = resolve_media_identity(media=meta) 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): def to_dict(self):
""" """
+1
View File
@@ -127,6 +127,7 @@ class MetaVideo(MetaBase):
and title.isdigit() \ and title.isdigit() \
and len(title) < 5: and len(title) < 5:
self.begin_episode = int(title) self.begin_episode = int(title)
self.total_episode = 1
self.type = MediaType.TV self.type = MediaType.TV
return return
# 全名为Season xx 及 Sxx 直接返回 # 全名为Season xx 及 Sxx 直接返回
+80 -32
View File
@@ -1,8 +1,9 @@
import hashlib import hashlib
import logging import logging
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from functools import lru_cache from functools import lru_cache
from typing import Tuple, List, Optional from typing import Mapping, Tuple, List, Optional
import regex as re 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: def _empty_metainfo() -> dict:
""" """
返回媒体标签的默认结构,避免不同识别请求之间共享可变状态。 返回媒体标签的默认结构,避免不同识别请求之间共享可变状态。
@@ -263,37 +276,38 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
return title, _normalize_metainfo_identity(metainfo) return title, _normalize_metainfo_identity(metainfo)
def _build_meta_info( def _prepare_meta_input(
title: str, title: str,
subtitle: Optional[str] = None, subtitle: Optional[str] = None,
custom_words: List[str] = None, custom_words: List[str] = None,
) -> MetaBase: ) -> _PreparedMetaInput:
""" """
根据标题构造元数据 应用识别词、显式标签和文件后缀规则,生成稳定的解析阶段输入。
""" """
# 原标题 original_title = title
org_title = title parsed_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
# 预处理标题 # 完整 Rust 入口已经失败或被禁用,参考实现不得再次跨边界调用部分 Rust 解析器。
title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words) parsed_title, explicit_metainfo = _find_metainfo_python(parsed_title)
# 获取标题中媒体信息
title, metainfo = find_metainfo(title)
# 判断是否处理文件
media_exts = get_media_extensions() 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: if title_path and title_path.suffix.lower() in media_exts:
isfile = True isfile = True
# 去掉后缀 parsed_title = title_path.stem
title = title_path.stem
else: else:
isfile = False isfile = False
# 识别 return _PreparedMetaInput(
meta = MetaAnime(title, subtitle, isfile) if is_anime(title) else MetaVideo(title, subtitle, isfile) original_title=original_title,
# 记录原标题 parsed_title=parsed_title,
meta.title = org_title subtitle=subtitle,
# 记录使用的识别词 isfile=isfile,
meta.apply_words = apply_words or [] apply_words=tuple(apply_words or ()),
# 修正媒体信息 explicit_metainfo=explicit_metainfo,
media_source, media_id = resolve_media_identity(media=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: if media_source and media_id:
meta.media_source = media_source meta.media_source = media_source
meta.media_id = media_id meta.media_id = media_id
@@ -313,6 +327,42 @@ def _build_meta_info(
meta.end_episode = metainfo['end_episode'] meta.end_episode = metainfo['end_episode']
if metainfo.get('total_episode') is not None: if metainfo.get('total_episode') is not None:
meta.total_episode = metainfo['total_episode'] 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 return meta
@@ -473,13 +523,11 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
) )
if rust_meta: if rust_meta:
return rust_meta return rust_meta
meta = _build_meta_info(title=title, subtitle=subtitle, custom_words=custom_words) return _build_python_meta_info(
if meta.apply_words: title=title,
original_meta = _build_meta_info(title=title, subtitle=subtitle) subtitle=subtitle,
meta.original_name = original_meta.name or meta.name custom_words=custom_words,
else: )
meta.original_name = meta.name
return meta
def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool = False) -> MetaBase: 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: if rust_meta:
return 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): if should_use_parent_title_for_file_stem(path.stem, path.parent.name, file_meta):
clear_parsed_title_for_parent_merge(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: if file_meta.type == MediaType.TV or dir_meta.type != MediaType.TV:
# 合并元数据 # 合并元数据
file_meta.merge(dir_meta) 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: if file_meta.type == MediaType.TV or root_meta.type != MediaType.TV:
# 合并元数据 # 合并元数据
file_meta.merge(root_meta) 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 import metainfo as metainfo_module
from app.domain.meta.metaanime import MetaAnime 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.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.domain.metainfo import MetaInfo, MetaInfoPath
from app.adapters.system import rust as rust_accel from app.adapters.system import rust as rust_accel
from app.runtime.settings import RuntimeSettingsCompat
from tests.cases.meta import meta_cases 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]: def build_video_inputs(repeat: int) -> list[BenchmarkInput]:
"""构造覆盖影视 MetaInfo 和 MetaInfoPath 生产入口的基准输入。""" """构造覆盖影视 MetaInfo 和 MetaInfoPath 生产入口的基准输入。"""
inputs: list[BenchmarkInput] = [] inputs: list[BenchmarkInput] = []
@@ -124,24 +144,10 @@ def _enum_value(value: Any) -> Any:
def project_video_result(meta: Any) -> dict[str, Any]: def project_video_result(meta: Any) -> dict[str, Any]:
"""提取影视识别对外契约字段,排除 Python 解析器的临时内部状态。""" """提取影视识别完整稳定契约,排除解析器的临时内部状态和派生展示字段"""
return { result = MetaInfoSnapshot.from_meta(meta).to_dict()
"kind": "anime" if isinstance(meta, MetaAnime) else "video", result["apply_words"] = list(result["apply_words"])
"type": _enum_value(meta.type), return result
"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,
}
def project_music_result(meta: Any) -> dict[str, Any]: def project_music_result(meta: Any) -> dict[str, Any]:
@@ -252,9 +258,17 @@ def benchmark_suite(
def validate_rust_runtime() -> None: def validate_rust_runtime() -> None:
"""确认 Rust 总开关和音乐扩展入口可用,拒绝静默回退形成伪基准。""" """确认领域入口已注入可工作的 Rust 影视与音乐解析器,拒绝伪基准。"""
if not rust_accel.is_enabled(): if not rust_accel.is_enabled():
raise RuntimeError("Rust 加速未启用或 moviepilot-rust 扩展不可用") 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)): if not callable(getattr(rust_accel, "parse_metamusic", None)):
raise RuntimeError("MoviePilot 后端缺少 rust_accel.parse_metamusic 适配器") raise RuntimeError("MoviePilot 后端缺少 rust_accel.parse_metamusic 适配器")
extension = getattr(rust_accel, "_moviepilot_rust", None) extension = getattr(rust_accel, "_moviepilot_rust", None)
@@ -316,6 +330,7 @@ def main() -> int:
"""运行影视与音乐 Rust/Python 生产入口基准测试。""" """运行影视与音乐 Rust/Python 生产入口基准测试。"""
args = parse_args() args = parse_args()
try: try:
configure_benchmark_runtime()
validate_rust_runtime() validate_rust_runtime()
video_inputs = build_video_inputs(args.repeat_inputs) video_inputs = build_video_inputs(args.repeat_inputs)
music_inputs = build_music_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") 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): def test_parse_input_uses_public_production_entries(monkeypatch):
"""输入分发应调用 MetaInfo、MetaInfoPath 和 MetaMusic.parse_query 公开入口。""" """输入分发应调用 MetaInfo、MetaInfoPath 和 MetaMusic.parse_query 公开入口。"""
title_result = object() 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): def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
"""运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。""" """运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。"""
rust_accel = benchmark.rust_accel rust_accel = benchmark.rust_accel
@@ -149,6 +180,12 @@ def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
benchmark.validate_rust_runtime() benchmark.validate_rust_runtime()
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True)) 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, "parse_metamusic", Mock(return_value={}), raising=False)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", SimpleNamespace()) 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 rust_accel = benchmark.rust_accel
extension = SimpleNamespace(parse_metamusic_fast=Mock()) extension = SimpleNamespace(parse_metamusic_fast=Mock())
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True)) 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, "_moviepilot_rust", extension)
monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value=None), raising=False) 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): 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, "validate_rust_runtime", Mock())
monkeypatch.setattr(benchmark, "build_video_inputs", Mock(return_value=[("title", "V", None)])) monkeypatch.setattr(benchmark, "build_video_inputs", Mock(return_value=[("title", "V", None)]))
monkeypatch.setattr( 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): def test_main_reports_runtime_failure_with_nonzero_exit(monkeypatch, capsys):
"""Rust 未就绪时主程序应明确报错并返回非零状态。""" """Rust 未就绪时主程序应明确报错并返回非零状态。"""
monkeypatch.setattr(benchmark, "configure_benchmark_runtime", Mock())
monkeypatch.setattr( monkeypatch.setattr(
benchmark, benchmark,
"validate_rust_runtime", "validate_rust_runtime",
+79 -1
View File
@@ -7,9 +7,15 @@ import pytest
from app.domain.context import MediaInfo from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo 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.metamusic import MetaMusic
from app.domain.meta.metaanime import MetaAnime 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.application.torrent import TorrentHelper
from app.schemas.types import MediaSource, MediaType from app.schemas.types import MediaSource, MediaType
from tests.cases.meta import meta_cases from tests.cases.meta import meta_cases
@@ -100,6 +106,78 @@ def test_metainfopath_with_empty_custom_words():
assert meta is not None 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(): def test_custom_words_apply_words_recording():
"""测试 apply_words 记录功能。""" """测试 apply_words 记录功能。"""
custom_words = ["替换词 => 新词"] custom_words = ["替换词 => 新词"]