From 11c4b0394a0d495e09c310c2902439fd8e66dbff Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 16 Oct 2026 12:44:39 +0800 Subject: [PATCH] fix(events): preserve video metadata when replaying durable events (#7024) --- app/application/chain/events.py | 3 +- tests/test_chain_durable_events.py | 103 ++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/app/application/chain/events.py b/app/application/chain/events.py index ea0a44683..a406943d6 100644 --- a/app/application/chain/events.py +++ b/app/application/chain/events.py @@ -340,7 +340,8 @@ def _restore_meta(payload: dict[str, Any]) -> MetaBase: or payload.get("name") or "" ) - meta = MetaInfo(title) + # 音乐已按快照类型分流;影视发布名尾部的 .AAC 等编码不能再次触发音乐解析。 + meta = MetaInfo(title, force_video=True) for key, value in payload.items(): if key in {"season_episode", "edition", "name", "episode_list"}: continue diff --git a/tests/test_chain_durable_events.py b/tests/test_chain_durable_events.py index d4af49cd3..00ad14c0e 100644 --- a/tests/test_chain_durable_events.py +++ b/tests/test_chain_durable_events.py @@ -35,6 +35,9 @@ from app.application.outbox import ( DOWNLOAD_MODULE_TOPIC, DOWNLOAD_NOTIFICATION_TOPIC, DOWNLOAD_SUBTITLE_TOPIC, + TRANSFER_COMPLETED_TOPIC, + ClaimedOutboxMessage, + OutboxDispatcher, PostCommitEffectError, ) from app.application.transfer.execution import ( @@ -55,10 +58,12 @@ from app.db.models.transfersettlementreceipt import TransferSettlementReceipt from app.db.oper.transferhistory import TransferHistoryOper from app.db.uow import SqlAlchemyUnitOfWork from app.domain.context import Context, MediaInfo, TorrentInfo +from app.domain.meta import runtime as meta_runtime +from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo -from app.schemas.types import MediaSource, MediaType +from app.schemas.types import EventType, MediaSource, MediaType def _session_factory(): @@ -271,6 +276,102 @@ def test_durable_snapshots_are_json_and_restore_plugin_runtime_objects(): assert isinstance(restored_transfer["transferinfo"], TransferInfo) +@pytest.mark.parametrize("title", [ + "一瓯春.S01E01.2026.2160p.WEB-DL.H265.SDR.60FPS.AAC", + "云雀叫天录.S01E01.2026.2160p.WEB-DL.H264.SDR.25FPS.AAC", + "Demo.2026.2160p.WEB-DL.H265.aac", + "Demo.2026.2160p.WEB-DL.H265.FLAC", + "[Group] Demo - 01 [1080p].AAC", +]) +def test_durable_video_snapshots_preserve_metadata_with_audio_suffix(title, monkeypatch): + """影视发布名尾部的音频编码不能在持久事件恢复时改变解析对象类型。""" + monkeypatch.setattr(meta_runtime, "_audio_extensions_provider", lambda: (".aac", ".flac")) + meta = MetaInfo(f"{title}.mkv", force_video=True) + assert meta.org_string == title + meta.original_name = "整理时确认的原始名称" + context = Context(meta_info=meta) + transfer = json.loads(json.dumps(snapshot_transfer_result({"meta": meta}))) + download = json.loads(json.dumps(snapshot_download_added({"context": context}))) + processing = json.loads(json.dumps(snapshot_download_processing( + context=context, + download_dir=Path("/downloads"), + torrent_content=b"torrent-bytes", + ))) + + restored = [ + restore_transfer_result(transfer)["meta"], + restore_download_added(download)["context"].meta_info, + restore_download_processing(processing).context.meta_info, + ] + + for result in restored: + assert type(result) is type(meta) + assert result.type == meta.type + assert result.original_name == meta.original_name + assert result.to_dict() == meta.to_dict() + + +@pytest.mark.parametrize("media_type", [MediaType.MUSIC.value, "music"]) +@pytest.mark.parametrize("title", ["Artist - Song.aac", "Artist - Album"]) +def test_durable_music_snapshots_keep_explicit_music_type(media_type, title): + """真实音乐仍按快照恢复标签,兼容旧类型标记及没有音频后缀的资源。""" + meta = MetaMusic( + org_string=title, + title="标签中的曲名", + artists=["标签中的歌手"], + album="标签中的专辑", + disc_number=2, + track_number=3, + audio_format="AAC", + ) + snapshot = json.loads(json.dumps(snapshot_transfer_result({"meta": meta}))) + snapshot["meta"]["type"] = media_type + + restored = restore_transfer_result(snapshot)["meta"] + + assert isinstance(restored, MetaMusic) + assert restored.to_dict() == meta.to_dict() + + +def test_transfer_outbox_dispatches_video_release_ending_in_aac(monkeypatch): + """真实整理 handler 恢复 AAC 尾缀影视快照后应派发事件并完成消息结算。""" + from app.startup.composition import outbox as outbox_composition + + monkeypatch.setattr(meta_runtime, "_audio_extensions_provider", lambda: (".aac",)) + title = "一瓯春.S01E01.2026.2160p.WEB-DL.H265.SDR.60FPS.AAC" + meta = MetaInfo(f"{title}.mkv", force_video=True) + assert meta.org_string == title + fileitem = FileItem( + storage="local", path=f"/downloads/{title}.mkv", name=f"{title}.mkv", type="file", + ) + payload = json.loads(json.dumps(snapshot_transfer_result({ + "meta": meta, + "fileitem": fileitem, + "idempotency_key": "transfer.completed:7023:v1", + }))) + message = ClaimedOutboxMessage( + 7023, "transfer.completed:7023:v1", TRANSFER_COMPLETED_TOPIC, payload, 1, 1, + ) + repository = Mock() + repository.claim.return_value = message + event_manager = Mock() + monkeypatch.setattr(outbox_composition, "EventManager", lambda: event_manager) + dispatcher = OutboxDispatcher(repository, outbox_composition.build_outbox_handlers()) + + assert dispatcher.dispatch_one() is True + + event_manager.send_event_strict.assert_called_once() + event_type, restored = event_manager.send_event_strict.call_args.args + assert event_type == EventType.TransferComplete + assert restored["meta"].type == MediaType.TV + assert restored["meta"].to_dict() == meta.to_dict() + assert restored["fileitem"] == fileitem + assert restored["idempotency_key"] == message.event_key + repository.complete.assert_called_once() + assert repository.complete.call_args.args[:2] == (message.message_id, message.attempt) + repository.retry.assert_not_called() + + def test_download_history_and_event_intent_share_one_transaction(): """下载历史、文件和四项具名 intent 在同一事务内提交。""" factory = _session_factory()