From 27e1f634cb248af564cbd990166f83eabca2c6fb Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 27 Jun 2026 22:45:17 +0800 Subject: [PATCH] fix: add total episodes to transfer notifications --- app/chain/transfer.py | 10 ++ app/helper/message.py | 2 + tests/test_template_context_builder.py | 165 ++++++++++++++----------- tests/test_transfer_message.py | 47 +++++++ 4 files changed, 151 insertions(+), 73 deletions(-) create mode 100644 tests/test_transfer_message.py diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 38a55163..895f8903 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -40,6 +40,7 @@ from app.schemas import ( TransferQueue, TransferJob, TransferJobTask, + TmdbEpisode, ) from app.schemas.exception import OperationInterrupted from app.schemas.types import ( @@ -947,6 +948,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mediainfo=task.mediainfo, transferinfo=transferinfo, season_episode=se_str, + episodes_info=task.episodes_info, username=task.username, ) @@ -3395,10 +3397,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mediainfo: MediaInfo, transferinfo: TransferInfo, season_episode: Optional[str] = None, + episodes_info: Optional[List[TmdbEpisode]] = None, username: Optional[str] = None, ): """ 发送入库成功的消息 + :param meta: 文件元数据 + :param mediainfo: 识别的媒体信息 + :param transferinfo: 文件整理信息 + :param season_episode: 已入库季集文本 + :param episodes_info: 当前季的全部集信息 + :param username: 用户名 """ self.post_message( Notification( @@ -3412,6 +3421,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mediainfo=mediainfo, transferinfo=transferinfo, season_episode=season_episode, + episodes_info=episodes_info, username=username, ) diff --git a/app/helper/message.py b/app/helper/message.py index 1e2fc167..3fb9be12 100644 --- a/app/helper/message.py +++ b/app/helper/message.py @@ -180,6 +180,8 @@ class TemplateContextBuilder: "season_fmt": meta.season, # 集号 "episode": meta.episode_seqs, + # 当前季总集数 + "total_episodes": len(episodes) if episodes else 0, # 季集 SxxExx "season_episode": "%s%s" % (meta.season, meta.episode), # 段/节 diff --git a/tests/test_template_context_builder.py b/tests/test_template_context_builder.py index 137cef2b..a9463168 100644 --- a/tests/test_template_context_builder.py +++ b/tests/test_template_context_builder.py @@ -9,93 +9,112 @@ TemplateContextBuilder 的并发安全单元测试。 线程下连续调用 ``build()``,校验每个线程拿到的字典只反映自己的入参。 """ import threading -import unittest from app.helper.message import TemplateContextBuilder +from app.schemas.tmdb import TmdbEpisode -class TemplateContextBuilderConcurrencyTest(unittest.TestCase): +THREAD_COUNT = 8 +ITERATIONS_PER_THREAD = 200 + + +def _build_fake_meta(): + """ + 构造模板上下文测试所需的最小元数据对象。 + """ + meta = type("FakeMeta", (), {})() + meta.begin_episode = None + meta.title = "Movie.2024.1080p.x265.10bit.mkv" + meta.name = "Movie" + meta.en_name = "Movie" + meta.year = "2024" + meta.season_seq = "" + meta.season = "" + meta.episode_seqs = "" + meta.episode = "" + meta.part = None + meta.customization = None + meta.fps = None + meta.resource_type = None + meta.resource_effect = None + meta.edition = "" + meta.resource_pix = "1080p" + meta.resource_term = "1080p" + meta.resource_team = None + meta.video_encode = "x265 10bit" + meta.video_bit = "10bit" + meta.audio_encode = "AAC" + meta.web_source = None + return meta + + +def test_concurrent_build_no_cross_contamination() -> None: """ 使用 8 个线程并发调用同一 TemplateContextBuilder 实例的 build(), 确保各自的 file_extension / 自定义 kwargs 不会被其它线程覆盖。 """ + builder = TemplateContextBuilder() + errors = [] - THREAD_COUNT = 8 - ITERATIONS_PER_THREAD = 200 + def worker(tag: int) -> None: + try: + for _ in range(ITERATIONS_PER_THREAD): + ctx = builder.build( + file_extension=f".{tag}", + marker=tag, + ) + assert ctx.get("fileExt") == f".{tag}" + assert ctx.get("marker") == tag + except AssertionError as exc: + errors.append(exc) - def test_concurrent_build_no_cross_contamination(self): - builder = TemplateContextBuilder() - errors = [] + threads = [ + threading.Thread(target=worker, args=(i,), name=f"builder-{i}") + for i in range(THREAD_COUNT) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() - def worker(tag: int) -> None: - try: - for _ in range(self.ITERATIONS_PER_THREAD): - ctx = builder.build( - file_extension=f".{tag}", - marker=tag, - ) - self.assertEqual(ctx.get("fileExt"), f".{tag}") - self.assertEqual(ctx.get("marker"), tag) - except AssertionError as exc: - errors.append(exc) + assert not errors, f"检测到并发串味,共 {len(errors)} 条;首个错误:{errors[0] if errors else ''}" - threads = [ - threading.Thread(target=worker, args=(i,), name=f"builder-{i}") - for i in range(self.THREAD_COUNT) - ] - for t in threads: - t.start() - for t in threads: - t.join() - self.assertFalse( - errors, - msg=f"检测到并发串味,共 {len(errors)} 条;首个错误:{errors[0] if errors else ''}", - ) +def test_build_returns_independent_dicts() -> None: + """ + 连续两次 build() 应返回相互独立的 dict 实例,避免调用方误用共享结果。 + """ + builder = TemplateContextBuilder() + first = builder.build(file_extension=".a", marker=1) + second = builder.build(file_extension=".b", marker=2) - def test_build_returns_independent_dicts(self): - """ - 即便不开线程,连续两次 build() 也应当返回相互独立的 dict 实例, - 避免无状态化后调用方误以为返回的还是 builder 内部共享对象。 - """ - builder = TemplateContextBuilder() - first = builder.build(file_extension=".a", marker=1) - second = builder.build(file_extension=".b", marker=2) - self.assertIsNot(first, second) - self.assertEqual(first.get("fileExt"), ".a") - self.assertEqual(second.get("fileExt"), ".b") - # 第二次调用不应反向污染第一次的结果 - self.assertEqual(first.get("marker"), 1) + assert first is not second + assert first.get("fileExt") == ".a" + assert second.get("fileExt") == ".b" + assert first.get("marker") == 1 - def test_build_exposes_video_bit_from_meta(self): - """ - 模板上下文应提供独立 videoBit 字段,避免用户只能从 videoCodec 中手工拆位深。 - """ - meta = type("FakeMeta", (), {})() - meta.begin_episode = None - meta.title = "Movie.2024.1080p.x265.10bit.mkv" - meta.name = "Movie" - meta.en_name = "Movie" - meta.year = "2024" - meta.season_seq = "" - meta.season = "" - meta.episode_seqs = "" - meta.episode = "" - meta.part = None - meta.customization = None - meta.fps = None - meta.resource_type = None - meta.resource_effect = None - meta.edition = "" - meta.resource_pix = "1080p" - meta.resource_term = "1080p" - meta.resource_team = None - meta.video_encode = "x265 10bit" - meta.video_bit = "10bit" - meta.audio_encode = "AAC" - meta.web_source = None - context = TemplateContextBuilder().build(meta=meta) +def test_build_exposes_video_bit_from_meta() -> None: + """ + 模板上下文应提供独立 videoBit 字段,避免用户只能从 videoCodec 中手工拆位深。 + """ + context = TemplateContextBuilder().build(meta=_build_fake_meta()) - self.assertEqual(context.get("videoCodec"), "x265 10bit") - self.assertEqual(context.get("videoBit"), "10bit") + assert context.get("videoCodec") == "x265 10bit" + assert context.get("videoBit") == "10bit" + + +def test_build_exposes_total_episodes_from_current_season() -> None: + """ + 模板上下文应提供当前季总集数,供入库通知模板直接引用。 + """ + context = TemplateContextBuilder().build( + meta=_build_fake_meta(), + episodes_info=[ + TmdbEpisode(episode_number=1, name="第一集"), + TmdbEpisode(episode_number=2, name="第二集"), + TmdbEpisode(episode_number=3, name="第三集"), + ], + ) + + assert context.get("total_episodes") == 3 diff --git a/tests/test_transfer_message.py b/tests/test_transfer_message.py new file mode 100644 index 00000000..ee48d734 --- /dev/null +++ b/tests/test_transfer_message.py @@ -0,0 +1,47 @@ +from unittest.mock import patch + +from app.chain.transfer import TransferChain +from app.core.context import MediaInfo +from app.core.meta.metabase import MetaBase +from app.schemas import TransferInfo +from app.schemas.tmdb import TmdbEpisode +from app.schemas.types import ContentType, MediaType, NotificationType + + +def test_send_transfer_message_passes_episode_info_to_template_context() -> None: + """ + 入库成功通知应把当前季集信息传给消息模板,确保 total_episodes 可渲染。 + """ + chain = TransferChain() + meta = MetaBase("Test.Show.S01E01.mkv") + meta.type = MediaType.TV + meta.name = "Test Show" + meta.begin_season = 1 + meta.begin_episode = 1 + episodes_info = [ + TmdbEpisode(episode_number=1, name="第一集"), + TmdbEpisode(episode_number=2, name="第二集"), + ] + mediainfo = MediaInfo( + type=MediaType.TV, + title="Test Show", + season=1, + tmdb_id=12345, + ) + transferinfo = TransferInfo(success=True) + + with patch.object(chain, "post_message") as post_message: + chain.send_transfer_message( + meta=meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + season_episode="S01 E01", + episodes_info=episodes_info, + username="tester", + ) + + message = post_message.call_args.args[0] + assert message.mtype == NotificationType.Organize + assert message.ctype == ContentType.OrganizeSuccess + assert post_message.call_args.kwargs["episodes_info"] is episodes_info + assert post_message.call_args.kwargs["season_episode"] == "S01 E01"