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

@@ -4,20 +4,58 @@ import sys
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Optional
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from app.core import metainfo as metainfo_module
from app.core.meta import MetaAnime, MetaMusic
from app.core.metainfo import MetaInfo, MetaInfoPath
from tests.cases.meta import meta_cases
def build_inputs(repeat: int):
"""
构造覆盖 MetaInfo 和 MetaInfoPath 的基准输入。
"""
inputs = []
BenchmarkInput = tuple[str, str, Optional[str]]
ResultProjector = Callable[[Any], dict[str, Any]]
_MUSIC_CASES: tuple[BenchmarkInput, ...] = (
("music_query", "毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]", None),
(
"music_query",
"VA-Once.Upon.a.Time.in.Hollywood.Original.Motion.Picture.Soundtrack."
"2019.FLAC.24bit.96kHz",
None,
),
("music_query", "李宗盛《理性与感性作品音乐会-CD2》2006-FLAC-分轨", None),
("music_query", "天国的情人-邓丽君作品全集1967-1995", None),
(
"music_query",
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv",
None,
),
("title", "周杰伦 - 晴天.flac", None),
("title", "01.我的地盘.wav", None),
(
"path",
"/benchmark/music/周杰伦 - 七里香 (2004) [FLAC 24bit-96kHz]/01.我的地盘.flac",
None,
),
(
"path",
"/benchmark/music/Daft Punk - Discovery (2001)/CD1/01 - One More Time.flac",
None,
),
(
"path",
"/benchmark/music/喜多郎 - 古事记 (1990) [SACD]/1-02 古事记.dsf",
None,
),
)
def build_video_inputs(repeat: int) -> list[BenchmarkInput]:
"""构造覆盖影视 MetaInfo 和 MetaInfoPath 生产入口的基准输入。"""
inputs: list[BenchmarkInput] = []
for _ in range(repeat):
for item in meta_cases:
if item.get("path"):
@@ -27,88 +65,277 @@ def build_inputs(repeat: int):
return inputs
def build_music_inputs(repeat: int) -> list[BenchmarkInput]:
"""构造覆盖音乐查询、音频文件名和目录路径生产入口的基准输入。"""
return list(_MUSIC_CASES) * repeat
def disabled_rust_parse(*_args, **_kwargs):
"""
关闭 Rust MetaInfo 快路径,用于测量旧 Python 链路。
"""
"""关闭一个 Rust 快路径,使生产入口自然回退到 Python 实现。"""
return None
@contextmanager
def selected_meta_parser(use_rust: bool):
"""
在 Rust 入口和 Python 旧实现之间切换。
"""
original_parse = metainfo_module.rust_accel.parse_metainfo
original_parse_path = metainfo_module.rust_accel.parse_metainfo_path
original_find = metainfo_module.rust_accel.find_metainfo
"""在 Rust 入口和 Python 回退链路之间切换,并在退出时恢复适配器。"""
parser_names = (
"parse_metainfo",
"parse_metainfo_path",
"find_metainfo",
"parse_metamusic",
)
rust_accel = metainfo_module.rust_accel
original_parsers = {
name: getattr(rust_accel, name)
for name in parser_names
}
if not use_rust:
metainfo_module.rust_accel.parse_metainfo = disabled_rust_parse
metainfo_module.rust_accel.parse_metainfo_path = disabled_rust_parse
metainfo_module.rust_accel.find_metainfo = disabled_rust_parse
for name in parser_names:
setattr(rust_accel, name, disabled_rust_parse)
try:
yield
finally:
metainfo_module.rust_accel.parse_metainfo = original_parse
metainfo_module.rust_accel.parse_metainfo_path = original_parse_path
metainfo_module.rust_accel.find_metainfo = original_find
for name, parser in original_parsers.items():
setattr(rust_accel, name, parser)
def parse_all(inputs, use_rust: bool):
"""
执行一轮完整 MetaInfo/MetaInfoPath 入口解析。
"""
with selected_meta_parser(use_rust):
parsed = []
for kind, value, subtitle in inputs:
if kind == "path":
parsed.append(MetaInfoPath(Path(value)))
else:
parsed.append(MetaInfo(title=value, subtitle=subtitle, custom_words=["#"]))
return parsed
def parse_input(item: BenchmarkInput):
"""按输入类型调用应用实际使用的公开识别入口。"""
kind, value, subtitle = item
if kind == "path":
return MetaInfoPath(Path(value))
if kind == "music_query":
return MetaMusic.parse_query(value)
if kind == "title":
return MetaInfo(title=value, subtitle=subtitle, custom_words=["#"])
raise ValueError(f"未知基准输入类型:{kind}")
def measure(inputs, use_rust: bool, loops: int, repeats: int):
"""
多轮测量 MetaInfo 入口解析耗时。
"""
def parse_all(inputs: list[BenchmarkInput]) -> list[Any]:
"""通过生产入口解析一轮完整输入。"""
return [parse_input(item) for item in inputs]
def _enum_value(value: Any) -> Any:
"""把枚举值归一为稳定的可比较值。"""
return getattr(value, "value", value)
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,
}
def project_music_result(meta: Any) -> dict[str, Any]:
"""提取音乐识别持久字段和派生音质字段,用于 Rust/Python 等价校验。"""
return {
"type": _enum_value(meta.type),
"org_string": meta.org_string,
"title": meta.title,
"artists": list(meta.artists),
"album": meta.album,
"album_artist": meta.album_artist,
"year": meta.year,
"disc_number": meta.disc_number,
"track_number": meta.track_number,
"total_discs": meta.total_discs,
"total_tracks": meta.total_tracks,
"version": meta.version,
"audio_format": meta.audio_format,
"audio_lossless": meta.audio_lossless,
"bit_depth": meta.bit_depth,
"sample_rate": meta.sample_rate,
"bitrate": meta.bitrate,
"duration": meta.duration,
"isrc": meta.isrc,
"media_source": _enum_value(meta.media_source),
"media_id": meta.media_id,
"audio_quality": meta.audio_quality,
"audio_quality_score": meta.audio_quality_score,
"audio_specs": meta.audio_specs,
}
def assert_projected_results_equal(
inputs: list[BenchmarkInput],
rust_results: list[Any],
python_results: list[Any],
projector: ResultProjector,
) -> None:
"""逐项校验 Rust/Python 稳定输出,首个差异携带输入和字段明细。"""
if len(rust_results) != len(python_results):
raise AssertionError(
f"Rust/Python 结果数量不一致:{len(rust_results)} != {len(python_results)}"
)
for index, (rust_result, python_result) in enumerate(zip(rust_results, python_results)):
rust_projection = projector(rust_result)
python_projection = projector(python_result)
if rust_projection == python_projection:
continue
differences = {
key: (rust_projection.get(key), python_projection.get(key))
for key in sorted(set(rust_projection) | set(python_projection))
if rust_projection.get(key) != python_projection.get(key)
}
raise AssertionError(
f"Rust/Python 输出不等价index={index} input={inputs[index]!r} "
f"differences={differences!r}"
)
def validate_equivalent_results(
inputs: list[BenchmarkInput],
projector: ResultProjector,
) -> None:
"""分别通过 Rust/Python 生产链路解析并校验稳定输出等价。"""
with selected_meta_parser(use_rust=True):
rust_results = parse_all(inputs)
with selected_meta_parser(use_rust=False):
python_results = parse_all(inputs)
assert_projected_results_equal(inputs, rust_results, python_results, projector)
def measure(
inputs: list[BenchmarkInput],
use_rust: bool,
loops: int,
repeats: int,
) -> tuple[float, int]:
"""在一次解析器切换上下文中预热并多轮测量生产入口耗时。"""
samples = []
parsed_count = 0
for _ in range(repeats):
start = time.perf_counter()
for _ in range(loops):
parsed = parse_all(inputs, use_rust)
parsed_count = len(parsed)
samples.append((time.perf_counter() - start) * 1000 / loops)
with selected_meta_parser(use_rust):
parse_all(inputs)
for _ in range(repeats):
start = time.perf_counter()
for _ in range(loops):
parsed_count = len(parse_all(inputs))
samples.append((time.perf_counter() - start) * 1000 / loops)
return statistics.median(samples), parsed_count
def benchmark_suite(
inputs: list[BenchmarkInput],
projector: ResultProjector,
loops: int,
repeats: int,
) -> dict[str, float | int]:
"""先校验一个媒体域的结果等价,再返回 Rust/Python 独立性能指标。"""
validate_equivalent_results(inputs, projector)
rust_ms, rust_count = measure(inputs, use_rust=True, loops=loops, repeats=repeats)
python_ms, python_count = measure(inputs, use_rust=False, loops=loops, repeats=repeats)
return {
"rust_ms": rust_ms,
"python_ms": python_ms,
"rust_count": rust_count,
"python_count": python_count,
"speedup": python_ms / rust_ms if rust_ms else 0,
}
def validate_rust_runtime() -> None:
"""确认 Rust 总开关和音乐扩展入口可用,拒绝静默回退形成伪基准。"""
rust_accel = metainfo_module.rust_accel
if not rust_accel.is_enabled():
raise RuntimeError("Rust 加速未启用或 moviepilot-rust 扩展不可用")
if not callable(getattr(rust_accel, "parse_metamusic", None)):
raise RuntimeError("MoviePilot 后端缺少 rust_accel.parse_metamusic 适配器")
extension = getattr(rust_accel, "_moviepilot_rust", None)
if not callable(getattr(extension, "parse_metamusic_fast", None)):
raise RuntimeError("moviepilot-rust 版本过旧,缺少 parse_metamusic_fast")
probe = rust_accel.parse_metamusic("Daft Punk - Get Lucky 2013 FLAC")
if not isinstance(probe, dict):
raise RuntimeError("Rust 音乐解析探针未返回有效结果,拒绝测量 Python 回退")
def positive_int(value: str) -> int:
"""解析命令行正整数,拒绝空循环和空样本配置。"""
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("必须为正整数")
return parsed
def parse_args():
"""
解析命令行参数。
"""
parser = argparse.ArgumentParser(description="Benchmark MetaInfo parsing through public entries")
parser.add_argument("--repeat-inputs", type=int, default=20, help="Repeat meta cases per loop")
parser.add_argument("--loops", type=int, default=10, help="Loops per repeat")
parser.add_argument("--repeats", type=int, default=5, help="Repeat count")
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="Benchmark video and music metadata through public entries"
)
parser.add_argument(
"--repeat-inputs",
type=positive_int,
default=20,
help="Repeat video and music cases per loop",
)
parser.add_argument("--loops", type=positive_int, default=10, help="Loops per repeat")
parser.add_argument("--repeats", type=positive_int, default=5, help="Repeat count")
return parser.parse_args()
def main() -> int:
"""
运行 MetaInfo Rust 与 Python 入口链路基准测试。
"""
args = parse_args()
inputs = build_inputs(args.repeat_inputs)
rust_ms, rust_count = measure(inputs, use_rust=True, loops=args.loops, repeats=args.repeats)
python_ms, python_count = measure(inputs, use_rust=False, loops=args.loops, repeats=args.repeats)
speedup = python_ms / rust_ms if rust_ms else 0
def print_suite_result(
name: str,
inputs: list[BenchmarkInput],
result: dict[str, float | int],
loops: int,
repeats: int,
) -> None:
"""按媒体域输出等价状态、耗时、单项耗时和性能提升倍数。"""
rust_ms = float(result["rust_ms"])
python_ms = float(result["python_ms"])
print(f"{name}_items_per_loop={len(inputs)} loops={loops} repeats={repeats}")
print(
f"{name}_rust_items={result['rust_count']} "
f"{name}_python_items={result['python_count']}"
)
print(f"{name}_equivalent=true")
print(f"{name}_rust_ms_per_loop={rust_ms:.3f}")
print(f"{name}_python_ms_per_loop={python_ms:.3f}")
print(f"{name}_rust_us_per_item={rust_ms * 1000 / len(inputs):.3f}")
print(f"{name}_python_us_per_item={python_ms * 1000 / len(inputs):.3f}")
print(f"{name}_speedup={float(result['speedup']):.2f}x")
print(f"items_per_loop={len(inputs)} loops={args.loops} repeats={args.repeats}")
print(f"rust_items={rust_count} python_items={python_count}")
print(f"rust_chain_ms_per_loop={rust_ms:.3f}")
print(f"python_chain_ms_per_loop={python_ms:.3f}")
print(f"speedup={speedup:.2f}x")
def main() -> int:
"""运行影视与音乐 Rust/Python 生产入口基准测试。"""
args = parse_args()
try:
validate_rust_runtime()
video_inputs = build_video_inputs(args.repeat_inputs)
music_inputs = build_music_inputs(args.repeat_inputs)
video_result = benchmark_suite(
video_inputs,
project_video_result,
loops=args.loops,
repeats=args.repeats,
)
music_result = benchmark_suite(
music_inputs,
project_music_result,
loops=args.loops,
repeats=args.repeats,
)
except (AssertionError, RuntimeError) as err:
print(f"benchmark_error={err}", file=sys.stderr)
return 2
print_suite_result("video", video_inputs, video_result, args.loops, args.repeats)
print_suite_result("music", music_inputs, music_result, args.loops, args.repeats)
return 0

View File

@@ -148,11 +148,11 @@ def fetch_titles(site_keys: list[str], max_pages: int) -> list[tuple[str, str]]:
def recognize_one(module, title: str) -> dict:
"""对单条标题执行与识别测试页相同的解析+识别链路,并给出失败归因。"""
from app.chain.music import MusicChain
from app.core.meta import MetaMusic
row = {"title": title}
try:
meta = MusicChain.parse_query(title)
meta = MetaMusic.parse_query(title)
row.update({
"parsed_title": meta.title,
"parsed_artists": " / ".join(meta.artists or []),