fix: add total episodes to transfer notifications

This commit is contained in:
jxxghp
2026-06-27 22:45:17 +08:00
parent a9197c434e
commit 27e1f634cb
4 changed files with 151 additions and 73 deletions

View File

@@ -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,
)

View File

@@ -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),
# 段/节

View File

@@ -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

View File

@@ -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"