mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 19:14:01 +08:00
refactor(media): unify media identity and chain responsibilities
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 []),
|
||||
|
||||
Reference in New Issue
Block a user