Files
MoviePilot/tests/test_subscribe_chain.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

5396 lines
199 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证订阅处理链中的检索、下载和完成判定。
"""
import asyncio
import sys
import types
from contextlib import contextmanager
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app import schemas
from app.application.subscription.contract import (
SubscriptionPatch,
SubscriptionWriteResult,
)
from app.application.subscription.mutation import SubscriptionMutation
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType
def _load_subscribe_chain_class():
"""
隔离加载 SubscribeChain,避免测试依赖完整运行时环境。
"""
module_name = "_test_subscribe_chain"
if module_name in sys.modules:
module = sys.modules[module_name]
return module, module.SubscribeChain
# 交互处理器模块须在打桩上下文之外预先真实加载:其模块级会话管理器单例
# 由 SlashInteractionManager 构造,若在桩内导入会绑定桩类并残留 sys.modules,
# 污染依赖真实会话管理器的后续测试
import app.application.messaging.subscribe # noqa: F401
stub_deps = {}
def ensure_module(name: str, module: types.ModuleType):
"""
登记一个加载期临时替换模块;实际替换与精确还原由 stub_modules 在加载时统一处理。
"""
stub_deps[name] = module
return module
chain_module = ensure_module("app.chain", types.ModuleType("app.chain"))
chain_module.__path__ = []
class _ChainBase:
subscription_repository = SimpleNamespace()
def __init__(self):
"""
装配隔离链依赖和显式同步订阅修改作用域。
"""
self.messagehelper = SimpleNamespace(put=lambda *args, **kwargs: None)
self.subscription_repository = type(self).subscription_repository
self.sync_subscription_mutation_scope = self._subscription_mutation_scope
@contextmanager
def _subscription_mutation_scope(self):
"""
提供委托当前测试 repository 的同步修改命令。
"""
yield SimpleNamespace(update=self._update_subscription)
def _update_subscription(
self,
subscribe_id,
payload,
_actor,
existing=None,
scene="update",
):
"""
记录测试写入并返回与生产命令一致的前后快照。
"""
updated = self.subscription_repository.update(
subscribe_id,
SubscriptionPatch(payload),
)
old = existing.to_dict() if existing else {}
new = {**old, **payload} if updated else {}
return SubscriptionMutation(
snapshot=replace(existing, **payload),
old=old,
new=new,
)
def post_message(self, *args, **kwargs):
"""
忽略测试中的通知发送,避免触达真实消息渠道。
"""
return None
async def async_post_message(self, *args, **kwargs):
"""
忽略测试中的异步通知发送,避免触达真实消息渠道。
"""
return None
def recognize_media(self, *args, **kwargs):
"""
模拟媒体未识别成功,不调用外部媒体服务。
:return: 固定返回 None
"""
return None
chain_base_module = ensure_module(
"app.chain.base",
types.ModuleType("app.chain.base"),
)
chain_base_module.ChainBase = _ChainBase
# 链内功能域 mixin:交互四件套委托与音乐订阅域,隔离加载以空 mixin 注入
interaction_mixin_module = ensure_module("app.chain._interaction", types.ModuleType("app.chain._interaction"))
interaction_mixin_module.InteractionChainMixin = type("InteractionChainMixin", (), {})
music_mixin_module = ensure_module("app.chain._music", types.ModuleType("app.chain._music"))
music_mixin_module.MusicSubscribeMixin = type("MusicSubscribeMixin", (), {})
class _MediaChain:
"""
提供订阅链隔离测试所需的统一媒体识别接口。
"""
def recognize_media(self, *args, **kwargs):
"""
同步识别默认返回空结果,由具体用例显式替换。
"""
return None
async def async_recognize_media(self, *args, **kwargs):
"""
异步识别默认返回空结果,由具体用例显式替换。
"""
return None
def recognize_by_meta(self, *args, **kwargs):
"""
同步按元数据识别默认返回空结果。
"""
return None
async def async_recognize_by_meta(self, *args, **kwargs):
"""
异步按元数据识别默认返回空结果。
"""
return None
def supplement_media_info(self, mediainfo, *args, **kwargs):
"""
隔离测试不访问外部附加信息源,原样返回识别结果。
"""
return mediainfo
interaction_module = ensure_module(
"app.application.messaging.interaction", types.ModuleType("app.application.messaging.interaction")
)
class _SlashInteractionManager:
def create_or_replace(self, *args, **kwargs):
"""
返回固定请求标识,避免访问真实请求存储。
:return: 测试请求标识
"""
return SimpleNamespace(request_id="request-id")
def get_by_id(self, *args, **kwargs):
"""
模拟按标识查询时请求不存在。
:return: 固定返回 None
"""
return None
def get_by_user(self, *args, **kwargs):
"""
模拟用户没有等待处理的请求。
:return: 固定返回 None
"""
return None
def remove(self, *args, **kwargs):
"""
忽略测试请求删除,不修改真实存储。
"""
return None
# 真实导入 app.application.messaging.subscribe 需要 MessageGateway 类型符号
interaction_module.MessageGateway = type("MessageGateway", (), {})
interaction_module.SlashInteractionManager = _SlashInteractionManager
interaction_module.build_navigation_buttons = lambda *args, **kwargs: []
interaction_module.format_markdown_table = lambda *args, **kwargs: ""
interaction_module.page_items = lambda *args, **kwargs: []
interaction_module.supports_interaction_buttons = lambda *args, **kwargs: False
interaction_module.supports_markdown = lambda *args, **kwargs: False
interaction_module.update_or_post_message = lambda *args, **kwargs: None
config_module = ensure_module("app.runtime.config", types.ModuleType("app.runtime.config"))
config_module.global_vars = SimpleNamespace(is_system_stopped=False)
config_module.settings = SimpleNamespace(
RECOGNIZE_SOURCE="themoviedb",
MP_DOMAIN=lambda path: path,
)
context_module = ensure_module("app.domain.context", types.ModuleType("app.domain.context"))
context_module.TorrentInfo = SimpleNamespace
context_module.Context = SimpleNamespace
context_module.MediaInfo = SimpleNamespace
context_module.MusicInfo = SimpleNamespace
context_module.MUSIC_ENTITY_ALBUM = "album"
context_module.MUSIC_ENTITY_RECORDING = "recording"
event_module = ensure_module("app.runtime.events", types.ModuleType("app.runtime.events"))
class _EventManager:
@staticmethod
def send_event(*args, **kwargs):
"""
忽略测试事件,避免触发真实订阅者。
"""
return None
@staticmethod
async def async_send_event(*args, **kwargs):
"""
忽略异步测试事件,避免触发真实订阅者。
"""
return None
@staticmethod
def register(*args, **kwargs):
"""
保留被装饰的处理函数,但不注册真实事件监听器。
:return: 原样返回处理函数的装饰器
"""
def decorator(func):
return func
return decorator
@staticmethod
def add_event_listener(*args, **kwargs):
"""
兼容模块导入时注册配置变更监听。
"""
return None
event_module.eventmanager = _EventManager()
event_module.Event = SimpleNamespace
meta_module = ensure_module("app.domain.meta", types.ModuleType("app.domain.meta"))
meta_module.MetaBase = SimpleNamespace
meta_module.MetaMusic = SimpleNamespace
metainfo_module = ensure_module("app.domain.metainfo", types.ModuleType("app.domain.metainfo"))
class _MetaInfo(SimpleNamespace):
"""
提供订阅刷新测试需要的 MetaInfo 核心字段。
"""
def __init__(self, title="", *args, **kwargs):
super().__init__(name=title, episode_list=[])
@property
def season_seq(self):
"""
生成识别季序号;电视剧未指定起始季时默认第一季。
:return: 季序号文本;非电视剧且未指定季时为空文本
"""
if getattr(self, "begin_season", None) is not None:
return str(self.begin_season)
if getattr(self, "type", None) == MediaType.TV:
return "1"
return ""
@property
def season(self):
"""
生成带 S 前缀的季标识;电视剧未指定季时默认 S01。
:return: 格式化季标识;非电视剧且未指定季时为空文本
"""
if getattr(self, "begin_season", None) is not None:
return f"S{str(self.begin_season).rjust(2, '0')}"
if getattr(self, "type", None) == MediaType.TV:
return "S01"
return ""
metainfo_module.MetaInfo = _MetaInfo
words_module = ensure_module("app.domain.meta.words", types.ModuleType("app.domain.meta.words"))
class _WordsMatcher:
def prepare(self, title, custom_words=None):
"""
保留输入标题并返回空替换结果,隔离自定义识别词处理。
:return: 原标题和空替换列表
"""
return title, []
words_module.WordsMatcher = _WordsMatcher
class _Notification:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
class _SubscribeSchema:
_fields = {
"name",
"type",
"year",
"tmdbid",
"doubanid",
"bangumiid",
"media_source",
"media_id",
"season",
"episode_group",
"best_version",
"save_path",
"search_imdbid",
"custom_words",
"media_category",
"filter_groups",
"music_type",
"total_tracks",
}
def __init__(self, **kwargs):
for field in self._fields:
setattr(self, field, None)
for key, value in kwargs.items():
setattr(self, key, value)
class _NotExistMediaInfo:
def __init__(
self, season=None, episodes=None, total_episode=None, start_episode=None, require_complete_coverage=False
):
self.season = season
self.episodes = episodes or []
self.total_episode = total_episode
self.start_episode = start_episode
self.require_complete_coverage = require_complete_coverage
class _SubscribeEpisodeInfo:
def __init__(self):
self.downloading = []
self.downloaded = []
self.library = []
class _SubscrbieInfo:
def __init__(self):
self.subscribe = None
self.episodes = {}
class _SubscribeDownloadFileInfo:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _SubscribeLibraryFileInfo:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _MediaRecognizeConvertEventData:
def __init__(self, **kwargs):
self.mediaid = kwargs.get("mediaid")
self.convert_type = kwargs.get("convert_type")
self.media_dict = kwargs.get("media_dict")
class _SubscribeEpisodesRefreshEventData:
def __init__(self, **kwargs):
self.updated = kwargs.get("updated", False)
self.total_episode = kwargs.get("total_episode")
self.source = kwargs.get("source", "未知来源")
self.reason = kwargs.get("reason", "")
self.__dict__.update(kwargs)
class _SubscribeCompletionCheckEventData:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _SubscribeDeletedEventData:
"""
提供订阅删除应用服务在隔离加载时需要的事件快照。
"""
def __init__(self, **kwargs):
"""
保存事件字段,行为与测试所需的 schema 投影一致。
"""
self.__dict__.update(kwargs)
mediaserver_schema_module = ensure_module(
"app.schemas.mediaserver",
types.ModuleType("app.schemas.mediaserver"),
)
mediaserver_schema_module.NotExistMediaInfo = _NotExistMediaInfo
message_schema_module = ensure_module(
"app.schemas.message",
types.ModuleType("app.schemas.message"),
)
message_schema_module.Message = _Notification
subscribe_schema_module = ensure_module(
"app.schemas.subscribe",
types.ModuleType("app.schemas.subscribe"),
)
subscribe_schema_module.Subscribe = _SubscribeSchema
subscribe_schema_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo
subscribe_schema_module.SubscrbieInfo = _SubscrbieInfo
subscribe_schema_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
subscribe_schema_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
event_schema_module = ensure_module(
"app.schemas.event",
types.ModuleType("app.schemas.event"),
)
event_schema_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData
event_schema_module.SubscribeEpisodesRefreshEventData = _SubscribeEpisodesRefreshEventData
event_schema_module.SubscribeCompletionCheckEventData = _SubscribeCompletionCheckEventData
event_schema_module.SubscribeDeletedEventData = _SubscribeDeletedEventData
logger_module = ensure_module("app.runtime.log", types.ModuleType("app.runtime.log"))
class _Logger:
def info(self, *args, **kwargs):
"""
忽略普通日志,避免测试向真实日志设施输出。
"""
return None
def debug(self, *args, **kwargs):
"""
忽略调试日志,避免测试向真实日志设施输出。
"""
return None
def warning(self, *args, **kwargs):
"""
忽略警告日志,避免测试向真实日志设施输出。
"""
return None
def warn(self, *args, **kwargs):
"""
兼容旧警告方法并忽略输出,避免访问真实日志设施。
"""
return None
def error(self, *args, **kwargs):
"""
忽略错误日志,避免测试向真实日志设施输出。
"""
return None
logger_module.logger = _Logger()
helper_server_module = ensure_module(
"app.adapters.external.server", types.ModuleType("app.adapters.external.server")
)
class _MoviePilotServerHelper:
@staticmethod
def sub_done_async(*args, **kwargs):
"""
忽略订阅完成统计上报。
"""
return None
@staticmethod
def sub_reg_async(*args, **kwargs):
"""
忽略订阅新增统计上报。
"""
return None
@staticmethod
async def async_sub_reg(*args, **kwargs):
"""
忽略异步订阅新增统计上报。
"""
return None
@staticmethod
def get_subscribe_shares():
"""
返回空的订阅共享数据。
"""
return []
helper_server_module.MoviePilotServerHelper = _MoviePilotServerHelper
helper_mediaserver_module = ensure_module(
"app.application.mediaserver", types.ModuleType("app.application.mediaserver")
)
helper_mediaserver_module.MediaServerHelper = type("MediaServerHelper", (), {})
helper_torrent_module = ensure_module(
"app.application.torrent.download",
types.ModuleType("app.application.torrent.download"),
)
helper_torrent_module.TorrentHelper = type("TorrentHelper", (), {})
db_model_module = ensure_module("app.db.models.subscribe", types.ModuleType("app.db.models.subscribe"))
class _SubscribeModel:
def __init__(self, **kwargs):
self.best_version_full = 0
self.bangumiid = None
self.anilistid = None
self.media_source = None
self.media_id = None
self.mediaid = None
self.music_type = None
self.total_tracks = None
self.episode_group = None
for key, value in kwargs.items():
setattr(self, key, value)
def to_dict(self):
"""
导出测试对象的当前属性快照。
:return: 实例属性字典的浅拷贝
"""
return dict(self.__dict__)
db_model_module.Subscribe = _SubscribeModel
subscribe_oper_module = ensure_module("app.db.oper.subscribe", types.ModuleType("app.db.oper.subscribe"))
class _SubscribeOper:
def update(self, *args, **kwargs):
"""
忽略订阅更新,避免测试访问真实数据库。
"""
return None
def get(self, *args, **kwargs):
"""
模拟订阅记录不存在。
:return: 固定返回 None
"""
return None
def list(self, *args, **kwargs):
"""
模拟数据库中没有订阅记录。
:return: 空订阅列表
"""
return []
def delete(self, *args, **kwargs):
"""
忽略订阅删除,避免修改真实数据库。
"""
return None
def add_history(self, *args, **kwargs):
"""
忽略历史记录写入,避免修改真实数据库。
"""
return None
subscribe_oper_module.SubscribeOper = _SubscribeOper
simple_oper_modules = {
"app.db.oper.downloadhistory": "DownloadHistoryOper",
"app.db.oper.site": "SiteOper",
"app.db.oper.systemconfig": "SystemConfigOper",
}
for module_name_key, class_name in simple_oper_modules.items():
module = ensure_module(module_name_key, types.ModuleType(module_name_key))
if class_name == "SystemConfigOper":
class _SystemConfigOper:
def get(self, *args, **kwargs):
"""
模拟尚未保存系统配置。
:return: 固定返回 None
"""
return None
def set(self, *args, **kwargs):
"""
忽略系统配置写入,避免修改真实数据库。
"""
return None
setattr(module, class_name, _SystemConfigOper)
else:
setattr(module, class_name, type(class_name, (), {}))
chain_dependencies = {
"app.chain.download": "DownloadChain",
"app.chain.mediaserver": "MediaServerChain",
"app.chain.search": "SearchChain",
"app.chain.tmdb": "TmdbChain",
"app.chain.torrents": "TorrentsChain",
}
for module_name_key, class_name in chain_dependencies.items():
module = ensure_module(module_name_key, types.ModuleType(module_name_key))
setattr(module, class_name, type(class_name, (), {}))
media_chain_module = ensure_module("app.chain.media", types.ModuleType("app.chain.media"))
media_chain_module.MediaChain = _MediaChain
import app.chain._music as music_owner
import app.chain.subscribe.completion as completion_owner
import app.chain.subscribe.create as create_owner
import app.chain.subscribe.match as match_owner
import app.chain.subscribe.notify as notification_owner
import app.chain.subscribe.policy as policy_owner
import app.chain.subscribe.query as query_owner
import app.chain.subscribe.refresh as refresh_owner
import app.chain.subscribe.search as search_owner
import app.startup.composition.chain as chain_composition
from app.application.messaging.subscribe import SubscribeInteractionHandler
from app.application.subscription.contract import SubscriptionSnapshot, build_subscribe_meta
from app.chain.subscribe.context import _SubscribeCreateContext
from app.chain.subscribe.facade import SubscribeChain as ProductionSubscribeChain
class SubscribeChain(ProductionSubscribeChain):
"""
保留存量单元测试按调用时替换 repository 的隔离接缝。
"""
subscription_repository = SimpleNamespace()
def __init__(self):
"""
复用真实运行时依赖,只把同步修改 scope 改为动态读取测试 repository。
"""
super().__init__()
self.subscription_repository = type(self).subscription_repository
self.sync_subscription_mutation_scope = self._subscription_mutation_scope
@contextmanager
def _subscription_mutation_scope(self):
"""
为当前测试 repository 提供同步修改命令。
"""
yield SimpleNamespace(update=self._update_subscription)
def _update_subscription(
self,
subscribe_id,
payload,
_actor,
existing=None,
scene="update",
):
"""
记录测试写入并返回与生产命令一致的前后快照。
"""
updated = self.subscription_repository.update(
subscribe_id,
SubscriptionPatch(payload),
)
old = existing.to_dict() if existing else {}
new = updated.to_dict() if updated else {}
return SubscriptionMutation(
snapshot=updated,
old=old,
new=new,
)
class _SubscribeOwnerPatchSurface:
"""
把旧单文件测试接缝转发到拆包后的唯一实现 owner。
"""
def __init__(self):
"""
登记实现依赖的真实所属模块,不向生产包根增加重复导出。
"""
object.__setattr__(self, "SubscribeChain", SubscribeChain)
object.__setattr__(self, "SubscribeInteractionHandler", SubscribeInteractionHandler)
object.__setattr__(self, "SubscriptionSnapshot", SubscriptionSnapshot)
object.__setattr__(self, "_SubscribeCreateContext", _SubscribeCreateContext)
object.__setattr__(self, "build_subscribe_meta", build_subscribe_meta)
object.__setattr__(
self,
"_targets",
{
"MediaChain": (
create_owner,
completion_owner,
match_owner,
query_owner,
refresh_owner,
search_owner,
),
"TorrentHelper": (match_owner,),
"DownloadChain": (music_owner, policy_owner, refresh_owner),
"eventmanager": (refresh_owner,),
"get_configured_system_config": (
match_owner,
query_owner,
search_owner,
),
"MoviePilotServerHelper": (chain_composition,),
"MetaInfo": (create_owner, match_owner, query_owner),
"logger": (
completion_owner,
create_owner,
match_owner,
notification_owner,
policy_owner,
query_owner,
refresh_owner,
search_owner,
),
"add_subscribe": (create_owner,),
"async_add_subscribe": (create_owner,),
"get_runtime_setting": (create_owner,),
},
)
def __getattr__(self, name):
"""
读取首个 owner 的依赖,供 unittest.mock 保存并恢复原值。
"""
targets = object.__getattribute__(self, "_targets")
if name not in targets:
raise AttributeError(name)
return getattr(targets[name][0], name)
def __setattr__(self, name, value):
"""
把测试替身同步设置到声明该依赖的所有 owner。
"""
targets = object.__getattribute__(self, "_targets")
if name not in targets:
object.__setattr__(self, name, value)
return
for target in targets[name]:
setattr(target, name, value)
def __delattr__(self, name):
"""
兼容 patch.object 对临时新增属性的清理协议。
"""
targets = object.__getattribute__(self, "_targets")
if name not in targets:
object.__delattr__(self, name)
return
for target in targets[name]:
delattr(target, name)
module = _SubscribeOwnerPatchSurface()
return module, SubscribeChain
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
# 进度格式化已迁移到交互处理器,经由隔离加载的模块获取
SubscribeInteractionHandler = SUBSCRIBE_CHAIN_MODULE.SubscribeInteractionHandler
def _patch_media_recognize(module, result):
"""
将隔离测试中的统一媒体识别入口替换为指定结果或回调。
"""
recognizer = result if callable(result) else lambda **_kwargs: result
media_chain = SimpleNamespace(
recognize_media=recognizer,
supplement_media_info=lambda mediainfo: mediainfo,
)
return patch.object(module, "MediaChain", return_value=media_chain)
class TestSubscribeChain:
"""
验证订阅缺失范围、资源质量目标和下载完成判定。
"""
def _build_subscribe(self, **overrides):
data = {
"id": 1,
"name": "Test Show",
"season": 1,
"best_version": 1,
"best_version_full": 0,
"type": MediaType.TV.value,
"start_episode": 1,
"total_episode": 3,
"current_priority": None,
"episode_priority": None,
"lack_episode": 3,
"state": "R",
"note": [],
"manual_total_episode": 0,
"year": "2026",
"media_source": "themoviedb",
"media_id": "1",
"episode_group": None,
"poster": None,
"backdrop": None,
"description": None,
"last_update": None,
"username": None,
"custom_words": None,
}
data.update(overrides)
return SUBSCRIBE_CHAIN_MODULE.SubscriptionSnapshot(**data)
@staticmethod
def _build_download(priority, selected_episodes=None, meta_episodes=None):
return SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=priority),
selected_episodes=selected_episodes,
meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or selected_episodes or []),
media_info=SimpleNamespace(type=MediaType.TV, media_source="themoviedb", media_id="1"),
confirmed_full_coverage=False,
)
def test_default_kwargs_respects_explicit_zero_best_version(self):
"""
显式关闭洗版时必须保留 0,仅未传值才应用默认订阅规则。
"""
def _default_config(_mtype, key):
return 1 if key in {"best_version", "best_version_full"} else None
with patch.object(SubscribeChain, "_SubscribeChain__get_default_subscribe_config", side_effect=_default_config):
explicit = SubscribeChain()._SubscribeChain__get_default_kwargs(
MediaType.TV,
best_version=0,
best_version_full=0,
)
omitted = SubscribeChain()._SubscribeChain__get_default_kwargs(MediaType.TV)
assert explicit["best_version"] == 0
assert explicit["best_version_full"] == 0
assert omitted["best_version"] == 1
assert omitted["best_version_full"] == 1
def test_format_subscribe_progress_preserves_special_season_zero(self):
"""
订阅列表展示必须把 S0 当作合法季号,而不是回退到第 1 季。
"""
subscribe = self._build_subscribe(season=0, total_episode=5, lack_episode=2)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
assert progress == "第0季 [3/5]"
def test_format_subscribe_progress_preserves_special_season_zero_without_total(self):
"""
S0 没有总集数时仍显示特别季季号。
"""
subscribe = self._build_subscribe(season=0, total_episode=None, lack_episode=None)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
assert progress == "第0季"
def test_format_subscribe_progress_shows_accumulated_album_tracks(self):
"""
消息订阅列表应像电视剧一样显示专辑已累计曲目进度。
"""
subscribe = self._build_subscribe(
type=MediaType.MUSIC.value,
music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11,
downloaded_tracks=["[1,1]", "[1,2]", "[1,2]"],
)
progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe)
assert progress == "专辑 [2/11]"
def test_match_title_fallback_calls_torrent_match_from_class(self):
"""
确保标题兜底匹配不依赖 TorrentHelper 实例绑定。
"""
reached = []
class _PlainTorrentHelper:
"""
模拟需要按类调用的 TorrentHelper 形态。
"""
def match_torrent(mediainfo, torrent_meta, torrent):
"""
标记类级调用已经正确进入匹配逻辑。
"""
reached.append((mediainfo, torrent_meta, torrent))
return False
def filter_torrent(self, *args, **kwargs):
"""
保持订阅匹配后续过滤流程可继续执行。
"""
return True
subscribe = self._build_subscribe(
best_version=0,
custom_words=None,
episode_group=None,
sites=[],
)
mediainfo = SimpleNamespace(
clear=lambda: None,
douban_id=None,
title_year="Test Show (2026)",
tmdb_id=1,
type=MediaType.TV,
)
context = SimpleNamespace(
media_info=None,
media_recognize_fail_count=3,
meta_info=SimpleNamespace(
begin_season=1,
episode_list=[],
org_string="Test Show",
season_list=[1],
),
torrent_info=SimpleNamespace(
description="",
site=1,
site_name="TestSite",
title="Test Show S01",
),
)
class _SubscribeOper:
"""
提供单条订阅,避免依赖真实数据库。
"""
def list(self, *args, **kwargs):
"""
返回当前测试构造的订阅列表。
"""
return [subscribe]
def get(self, subscribe_id):
"""
返回取得订阅准入后的最新快照。
"""
return subscribe if subscribe_id == subscribe.id else None
chain = SubscribeChain()
chain.subscription_repository = _SubscribeOper()
chain.check_and_handle_existing_media = lambda **kwargs: (False, {})
chain.finish_subscribe_or_not = lambda **kwargs: None
with (
patch.object(
SUBSCRIBE_CHAIN_MODULE,
"TorrentHelper",
_PlainTorrentHelper,
),
_patch_media_recognize(SUBSCRIBE_CHAIN_MODULE, mediainfo),
):
chain.match({"test.example": [context]})
assert len(reached) == 1
actual_media, actual_meta, actual_torrent = reached[0]
assert actual_media.title_year == mediainfo.title_year
assert actual_meta is context.meta_info
assert actual_torrent is context.torrent_info
def test_match_accepts_special_season_zero_candidate(self):
"""
S0 订阅应允许 S00 候选资源进入下载候选,不能按未指定季处理。
"""
class _TorrentHelper:
def filter_torrent(self, *args, **kwargs):
"""
允许测试种子通过筛选,将断言集中在下载和进度处理。
:return: 固定返回 True
"""
return True
subscribe = self._build_subscribe(
best_version=0,
custom_words=None,
episode_group=None,
filter_groups=[],
keyword=None,
media_category=None,
save_path=None,
search_imdbid=False,
season=0,
sites=[],
username="",
downloader=None,
)
mediainfo = SimpleNamespace(
clear=lambda: None,
media_source="themoviedb",
media_id="1",
title_year="Test Show (2026)",
type=MediaType.TV,
)
torrent_media = SimpleNamespace(
clear=lambda: None,
media_source="themoviedb",
media_id="1",
type=MediaType.TV,
)
context = SimpleNamespace(
media_info=torrent_media,
media_recognize_fail_count=0,
match_source="unknown",
meta_info=SimpleNamespace(
begin_season=0,
episode_list=[1],
org_string="Test Show S00E01",
season_list=[0],
),
torrent_info=SimpleNamespace(
description="",
pri_order=100,
site=1,
site_name="TestSite",
title="Test Show S00E01",
),
)
download_calls = []
class _SubscribeOper:
"""
提供单条订阅,避免依赖真实数据库。
"""
def list(self, *args, **kwargs):
"""
返回当前测试构造的订阅列表。
"""
return [subscribe]
def get(self, *args, **kwargs):
"""
下载后仍返回当前订阅。
"""
return subscribe
def _download(self, **kwargs):
download_calls.append(kwargs)
return [context], {}
chain = SubscribeChain()
chain.subscription_repository = _SubscribeOper()
chain.check_and_handle_existing_media = lambda **kwargs: (False, {})
chain.get_sub_sites = lambda *_args, **_kwargs: []
chain.get_params = lambda *_args, **_kwargs: {}
chain.filter_torrents = lambda **_kwargs: [context.torrent_info]
chain.finish_subscribe_or_not = lambda **_kwargs: None
with (
patch.object(
SUBSCRIBE_CHAIN_MODULE,
"TorrentHelper",
_TorrentHelper,
),
patch.object(
SubscribeChain,
"_SubscribeChain__download_best_version_with_full_pack_first",
_download,
),
_patch_media_recognize(SUBSCRIBE_CHAIN_MODULE, mediainfo),
):
chain.match({"test.example": [context]})
assert len(download_calls) == 1
assert download_calls[0]["contexts"][0].meta_info.begin_season == 0
execution_context = download_calls[0]["execution_context"]
assert execution_context.lease.subscription_id == subscribe.id
assert execution_context.lease.operation == "match"
replacement = chain._subscription_execution_admission.try_acquire(
subscription_id=subscribe.id,
operation="search",
ttl_seconds=60,
)
assert replacement is not None
assert chain._subscription_execution_admission.release(replacement) is True
def test_get_episode_priority_falls_back_to_current_priority(self):
"""
旧订阅没有分集优先级时,将整体优先级应用到目标集数。
"""
subscribe = self._build_subscribe(current_priority=80, episode_priority=None)
assert SubscribeChain.get_episode_priority(subscribe) == {"1": 80, "2": 80, "3": 80}
def test_full_best_version_does_not_materialize_episode_priority_from_baseline(self):
"""
整季洗版保留整体优先级基准,不据此生成分集优先级。
"""
subscribe = self._build_subscribe(
best_version_full=1,
current_priority=82,
episode_priority=None,
)
assert SubscribeChain.get_episode_priority(subscribe) == {}
assert SubscribeChain.get_best_version_current_priority(subscribe) == 82
def test_get_pending_best_version_episodes_uses_per_episode_status(self):
"""
待洗版集数包含低于目标优先级和尚无优先级记录的集。
"""
subscribe = self._build_subscribe(
total_episode=5,
episode_priority={"1": 100, "2": 80, "4": 100},
)
assert SubscribeChain._get_pending_best_version_episodes(subscribe) == [2, 3, 5]
def test_best_version_progress_helpers_return_remaining_priority(self):
"""
部分目标集尚未达到最高优先级时,整体进度不能标记完成。
"""
subscribe = self._build_subscribe(
total_episode=5,
episode_priority={"1": 100, "2": 80, "4": 100, "5": 70},
current_priority=100,
)
assert SubscribeChain.get_best_version_current_priority(subscribe) == 0
assert not (SubscribeChain.is_best_version_complete(subscribe))
def test_best_version_current_priority_uses_legacy_fallback_when_episode_priority_empty(self):
"""
分集记录为空的历史订阅继续使用整体优先级展示进度。
"""
subscribe = self._build_subscribe(total_episode=3, current_priority=80, episode_priority=None)
assert SubscribeChain.get_best_version_current_priority(subscribe) == 80
def test_best_version_progress_helpers_mark_complete_when_all_target_episodes_done(self):
"""
所有目标集均达到最高优先级后,整体优先级和完成状态同步更新。
"""
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 100},
current_priority=90,
)
assert SubscribeChain.get_best_version_current_priority(subscribe) == 100
assert SubscribeChain.is_best_version_complete(subscribe)
def test_get_subscribe_no_exists_expands_whole_missing_when_custom_start_skips_existing_range(self):
"""
自定义开始集跳过季初集数时,缺失整季需要转成显式目标集。
"""
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=[],
total_episode=48,
start_episode=1,
require_complete_coverage=False,
)
}
}
exist_flag, result = SubscribeChain._SubscribeChain__get_subscribe_no_exits(
subscribe_name="主角 S01",
no_exists=no_exists,
mediakey="media-key",
begin_season=1,
total_episode=48,
start_episode=44,
)
assert not (exist_flag)
assert result["media-key"][1].episodes == [44, 45, 46, 47, 48]
assert result["media-key"][1].start_episode == 44
assert result["media-key"][1].total_episode == 48
def test_get_subscribe_no_exists_keeps_whole_missing_when_custom_start_matches_original_start(self):
"""
自定义开始集没有缩小范围时,仍保留空集列表表示整季缺失。
"""
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=[],
total_episode=48,
start_episode=1,
require_complete_coverage=False,
)
}
}
exist_flag, result = SubscribeChain._SubscribeChain__get_subscribe_no_exits(
subscribe_name="主角 S01",
no_exists=no_exists,
mediakey="media-key",
begin_season=1,
total_episode=48,
start_episode=1,
)
assert not (exist_flag)
assert result["media-key"][1].episodes == []
assert result["media-key"][1].start_episode == 1
assert result["media-key"][1].total_episode == 48
def test_resolve_subscribe_missing_uses_subscription_season_when_meta_has_none(self):
"""
搜索词未携带季号时,开始集数裁剪必须覆盖原缺失季键。
"""
subscribe = self._build_subscribe(
best_version=0,
start_episode=239,
total_episode=240,
lack_episode=4,
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=None, season=None)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: list(range(1, 241))},
title_year="Test Show (2026)",
)
library_missing = {
1: {
1: SimpleNamespace(
season=1,
episodes=[1, 2, 239, 240],
total_episode=240,
start_episode=1,
require_complete_coverage=False,
)
}
}
class _DownloadChain:
def get_no_exists_info(self, **_kwargs):
"""
返回预设媒体库缺集,隔离真实媒体服务器查询。
:return: 未全部存在的标志和测试缺集结构
"""
return False, library_missing
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain):
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert not (satisfied)
assert set(no_exists[1]) == {1}
assert set(no_exists[1][1].episodes) == {239, 240}
def test_resolve_subscribe_missing_combines_library_gap_and_download_history_without_side_effects(self):
"""
目标满足查询应复用主程序媒体库缺集与订阅下载历史的合并口径,且不推进订阅状态。
"""
subscribe = self._build_subscribe(
best_version=0,
total_episode=20,
lack_episode=10,
note=list(range(11, 21)),
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: list(range(1, 21))},
title_year="Test Show (2026)",
)
library_missing = {
1: {
1: SimpleNamespace(
season=1,
episodes=list(range(11, 21)),
total_episode=20,
start_episode=11,
require_complete_coverage=False,
)
}
}
updates = []
class _DownloadChain:
def get_no_exists_info(self, **kwargs):
"""
记录缺集查询参数,并返回预设媒体库缺集。
:return: 未全部存在的标志和测试缺集结构
"""
self.kwargs = kwargs
return False, library_missing
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
chain = SubscribeChain()
chain.finish_subscribe_or_not = lambda **_kwargs: self.fail("resolve_subscribe_missing must not finish")
with (
patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain),
patch.object(
SubscribeChain,
"subscription_repository",
_SubscribeOper(),
create=True,
),
):
satisfied, no_exists = chain.resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert satisfied
assert no_exists == {}
assert updates == []
def test_resolve_subscribe_missing_keeps_library_gap_when_download_history_does_not_cover_it(self):
"""
订阅前媒体库已有部分剧集时,目标满足查询应保留仍需下载的媒体库缺口。
"""
subscribe = self._build_subscribe(
best_version=0,
total_episode=20,
lack_episode=20,
note=[],
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: list(range(1, 21))},
title_year="Test Show (2026)",
)
library_missing = {
1: {
1: SimpleNamespace(
season=1,
episodes=list(range(11, 21)),
total_episode=20,
start_episode=11,
require_complete_coverage=False,
)
}
}
class _DownloadChain:
def get_no_exists_info(self, **_kwargs):
"""
返回预设媒体库缺集,隔离真实媒体服务器查询。
:return: 未全部存在的标志和测试缺集结构
"""
return False, library_missing
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain):
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert not (satisfied)
assert no_exists[1][1].episodes == list(range(11, 21))
assert no_exists[1][1].start_episode == 1
assert no_exists[1][1].total_episode == 20
def test_resolve_subscribe_missing_uses_readonly_effective_total_from_mediainfo(self):
"""
只读目标查询应使用最新媒体信息扩大有效总集数,但不能写回订阅或发送刷新事件。
"""
subscribe = self._build_subscribe(
best_version=0,
total_episode=10,
lack_episode=0,
note=list(range(1, 11)),
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: list(range(1, 21))},
title_year="Test Show (2026)",
)
captured_totals = []
class _DownloadChain:
def get_no_exists_info(self, **kwargs):
"""
记录查询使用的总集数,并模拟共二十集且尚缺第十一至二十集。
:return: 未全部存在的标志和十集缺失信息
"""
captured_totals.append(kwargs["totals"])
return False, {
1: {
1: SimpleNamespace(
season=1,
episodes=list(range(11, 21)),
total_episode=20,
start_episode=11,
require_complete_coverage=False,
)
}
}
class _EventManager:
def send_event(self, *_args, **_kwargs):
"""
阻止缺集查询触发集数刷新事件。
:raises AssertionError: 测试路径意外发送事件
"""
raise AssertionError("resolve_subscribe_missing must not send refresh events")
with (
patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain),
patch.object(
SUBSCRIBE_CHAIN_MODULE,
"eventmanager",
_EventManager(),
),
):
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert not (satisfied)
assert captured_totals == [{1: 20}]
assert no_exists[1][1].episodes == list(range(11, 21))
assert subscribe.total_episode == 10
assert subscribe.lack_episode == 0
assert subscribe.note == list(range(1, 11))
def test_resolve_subscribe_missing_preserves_special_season_zero_totals(self):
"""
特别季 S0 是合法订阅季,目标满足查询必须按订阅总集数裁剪媒体库缺集。
"""
subscribe = self._build_subscribe(
best_version=0,
season=0,
total_episode=5,
lack_episode=2,
note=[1, 2, 3],
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=0, season=0)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={0: list(range(1, 4))},
title_year="Test Show (2026)",
)
captured_totals = []
class _DownloadChain:
def get_no_exists_info(self, **kwargs):
"""
仅在特别篇总集数为五集时报告第四、五集缺失,检验查询范围。
:return: 对应查询范围的存在状态和缺集结构
"""
captured_totals.append(kwargs["totals"])
if kwargs["totals"] == {0: 5}:
return False, {
1: {
0: SimpleNamespace(
season=0,
episodes=[4, 5],
total_episode=5,
start_episode=1,
require_complete_coverage=False,
)
}
}
return True, {}
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain):
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert not (satisfied)
assert captured_totals == [{0: 5}]
assert no_exists[1][0].episodes == [4, 5]
def test_build_subscribe_meta_preserves_special_season_zero(self):
"""
订阅构造 MetaInfo 的统一入口必须保留 S0。
"""
subscribe = self._build_subscribe(season=0)
meta = SUBSCRIBE_CHAIN_MODULE.build_subscribe_meta(subscribe)
assert meta.begin_season == 0
assert meta.type == MediaType.TV
def test_follow_preserves_shared_special_season_and_episode_group(self):
"""
Follow 分享必须保留合法 S0 与自定义剧集组的完整订阅范围。
"""
added_calls = []
exists_calls = []
history_calls = []
class _SubscribeOper:
"""
提供订阅存在性查询,避免依赖真实数据库。
"""
def exists(self, identity):
"""
记录订阅查重条件并模拟尚无重复订阅。
:return: 固定返回 False
"""
exists_calls.append(identity)
return False
def history_exists(self, identity):
"""
记录历史查重条件并模拟尚无重复记录。
:return: 固定返回 False
"""
history_calls.append(identity)
return False
class _SystemConfigOper:
"""
提供 follow 用户配置。
"""
def get(self, *args, **kwargs):
"""
返回预设关注用户,隔离真实系统配置查询。
:return: 测试关注用户列表
"""
return ["follow-user"]
class _MoviePilotServerHelper:
"""
提供单条 S0 分享订阅。
"""
@staticmethod
def get_subscribe_shares():
"""
提供带剧集组的豆瓣特别篇共享订阅,检验媒体身份传递。
:return: 预设共享订阅列表
"""
return [
{
"share_uid": "follow-user",
"name": "Test Show",
"type": MediaType.TV.value,
"year": "2026",
"media_source": "douban",
"media_id": "12345",
"season": 0,
"episode_group": "eg-special",
"best_version": 0,
"save_path": None,
"search_imdbid": False,
"custom_words": None,
"media_category": None,
"filter_groups": [],
}
]
def _add(self, **kwargs):
added_calls.append(kwargs)
return 1, None
def _metainfo(title):
return SimpleNamespace(name=title, begin_season=None, episode_list=[])
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
SUBSCRIBE_CHAIN_MODULE,
"get_configured_system_config",
_SystemConfigOper,
),
patch.object(
SUBSCRIBE_CHAIN_MODULE,
"MoviePilotServerHelper",
_MoviePilotServerHelper,
),
patch.object(
SUBSCRIBE_CHAIN_MODULE,
"MetaInfo",
_metainfo,
),
patch.object(
SubscribeChain,
"add",
_add,
),
):
SubscribeChain().follow()
assert len(added_calls) == 1
assert added_calls[0]["season"] == 0
assert added_calls[0]["episode_group"] == "eg-special"
assert exists_calls[0].episode_group == "eg-special"
assert history_calls[0].episode_group == "eg-special"
def test_resolve_subscribe_missing_accepts_downloaded_episode_best_version_targets(self):
"""
外部完成守卫可按任意已下载版本判定分集洗版目标已满足。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
total_episode=3,
note=[1],
episode_priority={"2": 80, "3": 99},
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: [1, 2, 3]},
title_year="Test Show (2026)",
)
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
best_version_accept_downloaded=True,
)
assert satisfied
assert no_exists == {}
def test_total_episode_decrease_rejects_invalid_missing_scope(self):
"""
缺集范围无效时不据此推断已下载集数下限,保留候选总集数。
"""
subscribe = self._build_subscribe(best_version=0, total_episode=100, note=[])
missing_info = SimpleNamespace(
episodes=list(range(91, 101)),
require_complete_coverage=False,
)
chain = SubscribeChain()
with patch.object(chain, "resolve_subscribe_missing", return_value=(False, {1: {1: missing_info}})):
total_episode = chain._SubscribeChain__resolve_total_episode_decrease(
subscribe=subscribe,
candidate_total=1,
meta=SimpleNamespace(type=MediaType.TV, begin_season=1, season=1),
mediainfo=SimpleNamespace(type=MediaType.TV, seasons={1: [1]}),
mediakey=1,
)
assert total_episode == 1
def test_resolve_subscribe_missing_accepts_downloaded_legacy_current_priority_targets(self):
"""
外部完成守卫读取按集事实时,应保留 current_priority 整体快照兼容。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
total_episode=3,
current_priority=80,
episode_priority=None,
note=[],
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: [1, 2, 3]},
title_year="Test Show (2026)",
)
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
best_version_accept_downloaded=True,
)
assert satisfied
assert no_exists == {}
def test_resolve_subscribe_missing_default_best_version_requires_top_priority(self):
"""
主程序洗版完成口径默认仍要求目标分集达到最高优先级。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
total_episode=3,
note=[1],
episode_priority={"2": 80, "3": 99},
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: [1, 2, 3]},
title_year="Test Show (2026)",
)
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert not (satisfied)
assert no_exists[1][1].episodes == [1, 2, 3]
assert no_exists[1][1].total_episode == 3
def test_resolve_subscribe_missing_default_best_version_uses_readonly_effective_total(self):
"""
只读目标查询扩大有效总集数时,默认洗版口径应把新增集纳入待洗范围。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 100},
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: [1, 2, 3, 4, 5]},
title_year="Test Show (2026)",
)
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
)
assert not (satisfied)
assert no_exists[1][1].episodes == [4, 5]
assert no_exists[1][1].total_episode == 5
assert subscribe.total_episode == 3
def test_resolve_subscribe_missing_accept_downloaded_keeps_best_version_gap(self):
"""
任意版本满足口径仍应保留从未下载过的目标分集。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
total_episode=3,
note=[1],
episode_priority={"2": 80},
)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(
type=MediaType.TV,
seasons={1: [1, 2, 3]},
title_year="Test Show (2026)",
)
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=1,
best_version_accept_downloaded=True,
)
assert not (satisfied)
assert no_exists[1][1].episodes == [3]
assert no_exists[1][1].total_episode == 3
def test_get_subscribe_no_exists_preserves_complete_coverage_requirement(self):
"""
缺集裁剪重建 NotExistMediaInfo 时必须保留全集洗版完整覆盖约束。
"""
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=list(range(1, 13)),
total_episode=12,
start_episode=1,
require_complete_coverage=True,
)
}
}
exist_flag, result = SubscribeChain._SubscribeChain__get_subscribe_no_exits(
subscribe_name="主角 S01",
no_exists=no_exists,
mediakey="media-key",
begin_season=1,
total_episode=12,
start_episode=1,
downloaded_episodes=[1, 2, 3],
)
assert not (exist_flag)
assert result["media-key"][1].require_complete_coverage
assert result["media-key"][1].episodes == list(range(4, 13))
def test_check_existing_media_refreshes_total_before_resolving_missing(self):
"""
主流程应先执行完成前总集数刷新,再复用无副作用缺集查询口径。
"""
subscribe = self._build_subscribe(best_version=0, total_episode=10, lack_episode=0)
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
mediainfo = SimpleNamespace(type=MediaType.TV, title_year="Test Show (2026)")
calls = []
def fake_refresh(_self, subscribe, mediainfo, meta=None, mediakey=None):
"""
隔离总集数写入并记录执行顺序。
"""
calls.append(("refresh", subscribe.total_episode))
return replace(subscribe, total_episode=20)
def fake_resolve(_self, subscribe, meta, mediainfo, mediakey=None):
"""
返回尚未完成的缺集结果。
"""
calls.append(("resolve", subscribe.total_episode))
return False, {"media-key": {1: SimpleNamespace(episodes=[11], total_episode=20, start_episode=1)}}
chain = SubscribeChain()
with (
patch.object(
SubscribeChain,
"_SubscribeChain__refresh_total_episode_before_completion",
fake_refresh,
),
patch.object(
SubscribeChain,
"resolve_subscribe_missing",
fake_resolve,
),
patch.object(
chain,
"_SubscribeChain__refresh_subscribe_progress_with_no_exists",
) as refresh_progress,
):
exist_flag, no_exists = chain.check_and_handle_existing_media(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey="media-key",
)
assert not (exist_flag)
assert calls == [("refresh", 10), ("resolve", 20)]
assert no_exists["media-key"][1].episodes == [11]
refresh_progress.assert_called_once_with(
subscribe=replace(subscribe, total_episode=20),
no_exists=no_exists,
scene="existing_media",
)
def test_best_version_full_pack_first_keeps_whole_missing_for_custom_start_episode(self):
"""
分集洗版优先全集时,空集列表仍表示下载链按整季资源处理。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=0,
start_episode=44,
total_episode=48,
episode_priority={str(episode): 80 for episode in range(44, 49)},
)
result = SubscribeChain._SubscribeChain__build_full_pack_first_no_exists(
subscribe=subscribe,
mediakey="media-key",
)
assert result["media-key"][1].episodes == []
assert result["media-key"][1].start_episode == 44
assert result["media-key"][1].total_episode == 48
assert result["media-key"][1].require_complete_coverage
def test_is_episode_range_covered_matches_pending_episodes(self):
"""
资源须覆盖待处理集数;没有分集列表的整包仍可进入后续检查。
"""
subscribe = self._build_subscribe(
total_episode=12,
episode_priority={
**{str(ep): 100 for ep in range(1, 5)},
**{str(ep): 100 for ep in range(8, 13)},
},
)
assert SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[5, 6, 7]),
subscribe=subscribe,
)
assert not (
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[1, 2, 3, 4]),
subscribe=subscribe,
)
)
assert SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[]),
subscribe=subscribe,
)
def test_full_best_version_rejects_episode_resource(self):
"""
整季洗版排除只覆盖单集的资源。
"""
subscribe = self._build_subscribe(best_version_full=1, total_episode=3)
assert not (
SubscribeChain._SubscribeChain__is_full_season_best_version_resource(
meta=SimpleNamespace(season_list=[1], episode_list=[1]),
subscribe=subscribe,
)
)
def test_full_best_version_accepts_full_pack_resource(self):
"""
整季洗版接受没有分集列表的整包及覆盖全部目标集的资源。
"""
subscribe = self._build_subscribe(best_version_full=1, total_episode=3)
assert SubscribeChain._SubscribeChain__is_full_season_best_version_resource(
meta=SimpleNamespace(season_list=[1], episode_list=[]),
subscribe=subscribe,
)
assert SubscribeChain._SubscribeChain__is_full_season_best_version_resource(
meta=SimpleNamespace(season_list=[1], episode_list=[1, 2, 3]),
subscribe=subscribe,
)
def test_episode_best_version_downloads_full_pack_before_episode_fallback(self):
"""
分集洗版先尝试整季包,下载成功后不再尝试单集,并保留自定义识别词。
"""
subscribe = self._build_subscribe(
best_version_full=0,
total_episode=3,
custom_words="S04 => S01\n第 <> 集 >> EP+66",
)
full_pack_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=90),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[]),
)
episode_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=90),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[2]),
)
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=[2],
total_episode=3,
start_episode=1,
require_complete_coverage=False,
)
}
}
calls = []
class _FakeDownloadChain:
"""
记录批量下载调用,用于验证分集洗版会先尝试全集资源。
"""
def batch_download(self, **kwargs):
"""
记录下载参数并模拟整季包成功下载。
:return: 整包下载结果和空剩余缺集
"""
calls.append(kwargs)
return [full_pack_context], {}
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain):
downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first(
contexts=[episode_context, full_pack_context],
no_exists=no_exists,
subscribe=subscribe,
mediakey="media-key",
username="user",
save_path="/downloads",
downloader="qb",
source="subscribe",
)
assert downloads == [full_pack_context]
assert lefts == {}
assert len(calls) == 1
assert calls[0]["contexts"] == [full_pack_context]
assert calls[0]["no_exists"]["media-key"][1].episodes == []
# 订阅识别词须作为入参随下载下传,供整理时复现识别(避免下载模块反查订阅的循环依赖)
assert calls[0]["custom_words"] == "S04 => S01\n第 <> 集 >> EP+66"
def test_episode_best_version_falls_back_when_full_pack_not_downloaded(self):
"""
整季包未下载成功时,使用原始缺集信息继续尝试单集资源。
"""
subscribe = self._build_subscribe(best_version_full=0, total_episode=3)
full_pack_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=90),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[]),
)
episode_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=90),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[2]),
)
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=[2],
total_episode=3,
start_episode=1,
require_complete_coverage=False,
)
}
}
calls = []
class _FakeDownloadChain:
"""
模拟全集下载失败,验证后续会回退到按集下载。
"""
def batch_download(self, **kwargs):
"""
记录下载尝试;首次整包失败,随后单集下载成功。
:return: 当前尝试的下载结果和剩余缺集
"""
calls.append(kwargs)
if len(calls) == 1:
return [], kwargs["no_exists"]
return [episode_context], {}
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain):
downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first(
contexts=[episode_context, full_pack_context],
no_exists=no_exists,
subscribe=subscribe,
mediakey="media-key",
)
assert downloads == [episode_context]
assert lefts == {}
assert len(calls) == 2
assert calls[0]["contexts"] == [full_pack_context]
assert calls[1]["no_exists"] is no_exists
def test_episode_best_version_skips_full_pack_first_when_pack_priority_equals_existing_episode(self):
"""
验证全集优先级等于目标分集时回退到分集下载。
"""
subscribe = self._build_subscribe(
best_version_full=0,
total_episode=3,
episode_priority={"1": 80, "2": 80, "3": 80},
current_priority=80,
)
full_pack_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=80),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[]),
)
episode_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=90),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[2]),
)
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=[2],
total_episode=3,
start_episode=1,
require_complete_coverage=False,
)
}
}
calls = []
class _FakeDownloadChain:
"""
记录回退下载调用,确保全集候选仍可参与拆包匹配。
"""
def batch_download(self, **kwargs):
"""
记录下载参数并模拟单集资源下载成功。
:return: 单集下载结果和空剩余缺集
"""
calls.append(kwargs)
return [episode_context], {}
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain):
downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first(
contexts=[episode_context, full_pack_context],
no_exists=no_exists,
subscribe=subscribe,
mediakey="media-key",
)
assert downloads == [episode_context]
assert lefts == {}
assert len(calls) == 1
assert calls[0]["contexts"] == [episode_context, full_pack_context]
assert calls[0]["no_exists"] is no_exists
def test_episode_best_version_falls_back_when_full_pack_does_not_exceed_every_target(self):
"""
整包候选未严格高于每个目标集时,应回退到按集下载。
"""
subscribe = self._build_subscribe(
best_version_full=0,
total_episode=3,
episode_priority={"1": 90, "2": 80, "3": 80},
current_priority=80,
)
full_pack_context = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=85),
media_info=SimpleNamespace(type=MediaType.TV),
meta_info=SimpleNamespace(season_list=[1], episode_list=[]),
)
no_exists = {
"media-key": {
1: SimpleNamespace(
season=1,
episodes=[2],
total_episode=3,
start_episode=1,
require_complete_coverage=False,
)
}
}
calls = []
class _FakeDownloadChain:
"""
记录整包优先和回退调用,验证整体门槛口径。
"""
def batch_download(self, **kwargs):
"""
记录下载参数并模拟下载未成功,完整保留原缺集信息。
:return: 空下载结果和原缺集结构
"""
calls.append(kwargs)
return [], kwargs["no_exists"]
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _FakeDownloadChain):
downloads, lefts = SubscribeChain()._SubscribeChain__download_best_version_with_full_pack_first(
contexts=[full_pack_context],
no_exists=no_exists,
subscribe=subscribe,
mediakey="media-key",
)
assert downloads == []
assert lefts is no_exists
assert len(calls) == 1
assert calls[0]["contexts"] == [full_pack_context]
assert calls[0]["no_exists"] is no_exists
def test_episode_full_pack_priority_must_strictly_exceed_all_targets(self):
"""
缺失集按 0 参与比较,且候选与任一目标集相等时也不优先整包。
"""
subscribe = self._build_subscribe(
best_version_full=0,
total_episode=3,
episode_priority={"1": 90, "2": 80},
current_priority=0,
)
assert not (
SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version(
subscribe=subscribe,
priority=82,
)
)
assert not (
SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version(
subscribe=subscribe,
priority=90,
)
)
assert SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version(
subscribe=subscribe,
priority=91,
)
def test_full_pack_priority_check_uses_current_priority_fallback(self):
"""
没有按集优先级状态时使用 current_priority 兜底判断。
"""
subscribe = self._build_subscribe(total_episode=3, current_priority=80, episode_priority=None)
assert not (
SubscribeChain._SubscribeChain__should_prefer_full_pack_for_episode_best_version(
subscribe=subscribe,
priority=80,
)
)
def test_full_best_version_completion_uses_baseline_priority(self):
"""
全集洗版完成只认整包准入基线,不受保留的按集事实影响。
"""
subscribe = self._build_subscribe(
best_version_full=1,
current_priority=82,
episode_priority={"1": 100, "2": 100, "3": 100},
)
assert not (SubscribeChain.is_best_version_complete(subscribe))
completed = replace(
subscribe,
current_priority=100,
episode_priority={"1": 80},
)
assert SubscribeChain.is_best_version_complete(completed)
def test_full_best_version_candidate_uses_baseline_without_allowed_episodes(self):
"""
全集候选只比较整包准入基线,不进入按集 interested/allowed_episodes 路径。
"""
subscribe = self._build_subscribe(
best_version_full=1,
current_priority=82,
episode_priority={"1": 100, "2": 100, "3": 100},
)
context = SimpleNamespace(
selected_episodes=None,
meta_info=SimpleNamespace(episode_list=[1, 2, 3]),
allowed_episodes=None,
)
with patch.object(
SubscribeChain,
"_SubscribeChain__get_best_version_interested_episodes",
side_effect=AssertionError("full mode must not inspect episode priorities"),
):
assert not (
SubscribeChain._SubscribeChain__prepare_best_version_tv_candidate(
subscribe=subscribe,
context=context,
priority=82,
)
)
assert SubscribeChain._SubscribeChain__prepare_best_version_tv_candidate(
subscribe=subscribe,
context=context,
priority=83,
)
assert context.allowed_episodes is None
def test_episode_best_version_candidate_sets_allowed_episodes(self):
"""
分集候选继续把实际可提升剧集下传到下载层。
"""
subscribe = self._build_subscribe(
best_version_full=0,
current_priority=80,
episode_priority={"1": 90, "2": 80},
)
context = SimpleNamespace(
selected_episodes=None,
meta_info=SimpleNamespace(episode_list=[1, 2, 3]),
)
accepted = SubscribeChain._SubscribeChain__prepare_best_version_tv_candidate(
subscribe=subscribe,
context=context,
priority=85,
)
assert accepted
assert context.allowed_episodes == {2, 3}
def test_record_download_facts_uses_selected_episodes(self):
"""
下载事实只更新实际选中集的优先级和记录,不提前重算整体进度。
"""
subscribe = self._build_subscribe(
total_episode=4,
episode_priority={"1": 100, "2": 80, "3": 70, "4": 60},
current_priority=80,
lack_episode=3,
)
download = self._build_download(
priority=90,
selected_episodes=[3],
meta_episodes=[2, 3, 4],
)
chain = SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
snapshot = chain._SubscribeChain__record_subscribe_download_facts(
subscribe=subscribe,
mediainfo=mediainfo,
downloads=[download],
)
subscribe_oper.update.assert_called_once()
payload = subscribe_oper.update.call_args.args[1].to_payload()
assert payload["episode_priority"] == {"1": 100, "2": 80, "3": 90, "4": 60}
assert payload["note"] == [3]
assert snapshot["episodes"] == [3]
assert "current_priority" not in payload
assert "lack_episode" not in payload
assert snapshot["subscribe"].episode_priority == {"1": 100, "2": 80, "3": 90, "4": 60}
assert subscribe.current_priority == 80
assert subscribe.lack_episode == 3
def test_record_download_facts_updates_all_target_episodes_without_finishing(self):
"""
下载覆盖全部目标集也只记录事实,由后续进度流程判断是否完成。
"""
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 90, "3": 80},
current_priority=90,
lack_episode=2,
)
downloads = [
self._build_download(priority=100, selected_episodes=[2]),
self._build_download(priority=100, selected_episodes=[3]),
]
chain = SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
with (
patch.object(SUBSCRIBE_CHAIN_MODULE, "logger") as logger_mock,
):
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
chain._SubscribeChain__record_subscribe_download_facts(
subscribe=subscribe,
mediainfo=mediainfo,
downloads=downloads,
)
payload = subscribe_oper.update.call_args.args[1].to_payload()
assert payload["episode_priority"] == {"1": 100, "2": 100, "3": 100}
assert payload["note"] == [2, 3]
assert "current_priority" not in payload
assert "lack_episode" not in payload
assert not ([call for call in logger_mock.info.call_args_list if "洗版完成" in call.args[0]])
def test_download_facts_require_full_coverage_confirmation_when_pack_has_no_episode_metadata(self):
"""
整包没有分集元数据且未确认完整覆盖时,不能推断已下载集数。
"""
subscribe = self._build_subscribe(
best_version_full=1,
total_episode=3,
episode_priority={"1": 80, "2": 80, "3": 80},
current_priority=80,
lack_episode=3,
)
download = self._build_download(priority=100, selected_episodes=[], meta_episodes=[])
chain = SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
snapshot = chain._SubscribeChain__record_subscribe_download_facts(
subscribe=subscribe,
mediainfo=mediainfo,
downloads=[download],
)
assert snapshot["episodes"] == []
subscribe_oper.update.assert_not_called()
assert subscribe.episode_priority == {"1": 80, "2": 80, "3": 80}
def test_download_facts_write_all_targets_when_full_coverage_is_confirmed(self):
"""
确认整包完整覆盖后,将全部目标集记入下载事实而不直接更新整体进度。
"""
subscribe = self._build_subscribe(
best_version_full=0,
total_episode=3,
episode_priority={"1": 80, "2": 80, "3": 80},
current_priority=80,
lack_episode=3,
)
download = self._build_download(priority=100, selected_episodes=[], meta_episodes=[])
download.confirmed_full_coverage = True
chain = SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
chain._SubscribeChain__record_subscribe_download_facts(
subscribe=subscribe,
mediainfo=mediainfo,
downloads=[download],
)
payload = subscribe_oper.update.call_args.args[1].to_payload()
assert payload["episode_priority"] == {"1": 100, "2": 100, "3": 100}
assert payload["note"] == [1, 2, 3]
assert "current_priority" not in payload
assert "lack_episode" not in payload
def test_finish_subscribe_or_not_does_not_finish_best_version_twice_after_download_completion(self):
"""
洗版订阅本轮下载已触发完成时,流程尾部不应对同一订阅再次完成。
"""
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 90, "3": 90},
current_priority=90,
lack_episode=2,
)
downloads = [
self._build_download(priority=100, selected_episodes=[2]),
self._build_download(priority=100, selected_episodes=[3]),
]
chain = SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
meta = SimpleNamespace(type=MediaType.TV)
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
with (
patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
) as finish_mock,
):
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
downloads=downloads,
lefts={},
)
finish_mock.assert_called_once()
completed = finish_mock.call_args.kwargs["subscribe"]
assert completed.note == [2, 3]
assert completed.episode_priority == {"1": 100, "2": 100, "3": 100}
assert finish_mock.call_args.kwargs["meta"] is meta
assert finish_mock.call_args.kwargs["mediainfo"] is mediainfo
def test_check_keeps_sparse_priority_when_new_episodes_expand_target_range(self):
"""
新增目标集保持无优先级记录,巡检据此增加缺集数并撤销完成状态。
"""
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 100},
current_priority=100,
lack_episode=0,
)
chain = SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
mediainfo = SimpleNamespace(
seasons={1: [1, 2, 3, 4, 5]},
title="Test Show",
year="2026",
vote_average=9.5,
overview="overview",
imdb_id="tt1234567",
tvdb_id=99,
source="themoviedb",
tmdb_id=1,
douban_id=None,
bangumi_id=None,
anilist_id=None,
get_poster_image=lambda: "poster",
get_backdrop_image=lambda: "backdrop",
)
with (
_patch_media_recognize(SUBSCRIBE_CHAIN_MODULE, mediainfo),
):
subscribe_oper.list.return_value = [subscribe]
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
chain.check()
payload = subscribe_oper.update.call_args.args[1].to_payload()
assert payload["total_episode"] == 5
assert payload["lack_episode"] == 2
assert payload["current_priority"] == 0
assert payload["episode_priority"] == {"1": 100, "2": 100, "3": 100}
assert "4" not in payload["episode_priority"]
assert "5" not in payload["episode_priority"]
assert subscribe.total_episode == 3
assert subscribe.lack_episode == 0
assert subscribe.current_priority == 100
def test_best_version_interested_episodes_excludes_same_priority(self):
"""
同 pri_order 的候选不应再把已达到该优先级的集列为可升级集。
回归场景:E2 已记录在 episode_priority 中为 99,候选种子标题覆盖 E2/E3 且
其 pri_order=99;E2 不应进入 interested 集合,E3(None)则应进入。这是
洗版重复下载链路的源头判定,必须保持"严格大于"语义。
"""
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 99},
current_priority=100,
)
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=99,
)
assert interested == [3]
def test_best_version_interested_episodes_uses_title_episode_list_for_full_pack(self):
"""
整包候选(标题展开的集列表)只把仍可提升优先级的集纳入 interested。
标题显示"第53-104集",实际目标范围只有 1..92,episode_priority
已经把 1..82 升到 100,E83 已经记到 99。同 pri_order=99 的同一资源再来时,
interested 应只剩 [84..92],绝不能含 E83,否则后续下载层会再下一次同优先级。
"""
subscribe = self._build_subscribe(
total_episode=92,
episode_priority={
**{str(ep): 100 for ep in range(1, 83)},
"83": 99,
},
current_priority=99,
)
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=99,
)
assert interested == list(range(84, 93))
class TestSubscribeFilterAllowedEpisodes:
"""
验证洗版过滤循环会把 interested 集合落到 context.allowed_episodes 上。
这条用例直接覆盖回归点:当 __get_best_version_interested_episodes 返回非空
集合时,候选必须带着允许集进入下载层,下游 batch_download 才能在标题元数据
与实际种子文件错位时做出正确取舍。
"""
def _build_subscribe(self, **overrides):
return TestSubscribeChain()._build_subscribe(**overrides)
def test_filter_writes_allowed_episodes_to_context(self):
"""
筛选结果将允许下载的集数写入上下文,并排除目标范围之前的集。
"""
subscribe = self._build_subscribe(
total_episode=92,
episode_priority={
**{str(ep): 100 for ep in range(1, 83)},
"83": 99,
},
current_priority=99,
)
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=99,
)
# 复刻 subscribe.py 过滤循环中的赋值,确认结果作为允许集传递。
context.allowed_episodes = set(interested) if interested else None
assert context.allowed_episodes is not None
assert context.allowed_episodes == set(range(84, 93))
# E83 已达到 99,不在允许集内;下游交集后即不会再下 E83。
assert 83 not in context.allowed_episodes
def test_filter_leaves_allowed_episodes_none_when_no_upgrade(self):
"""
同 pri_order 且目标集均已达到该优先级时,候选不应被放行,
相应地也不会有 allowed_episodes 被写入。
"""
subscribe = self._build_subscribe(
total_episode=3,
episode_priority={"1": 100, "2": 99, "3": 99},
current_priority=99,
)
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=99,
)
assert interested == []
def test_filter_writes_allowed_episodes_in_match_path(self):
"""
RSS/订阅刷新分支 match() 需要与 search() 对称地写入 allowed_episodes。
match() 路径下候选是 `_context = copy.copy(context)`,再走 best_version
判定。此用例复刻 match() 的过滤序列,验证浅拷贝后的 _context 在写入
allowed_episodes 时不会污染原始 context,且写入结果与 search() 一致。
若 match() 分支漏写 allowed_episodes,下游 batch_download 将看不到允许集
约束,导致同优先级资源重复下载。
"""
import copy
subscribe = self._build_subscribe(
total_episode=92,
episode_priority={
**{str(ep): 100 for ep in range(1, 83)},
"83": 99,
},
current_priority=99,
)
original_context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
selected_episodes=None,
allowed_episodes=None,
)
_context = copy.copy(original_context)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=_context,
priority=99,
)
# 复刻 match() 中的赋值;search() 与 match() 必须保持同形以避免分支漏改。
if interested:
_context.allowed_episodes = set(interested)
assert _context.allowed_episodes == set(range(84, 93))
# 浅拷贝 + 新字段写入不应反向污染源 context(match() 中 contexts 缓存可能跨多次匹配复用)。
assert original_context.allowed_episodes is None
class TestSubscribeNoteTracking:
"""
覆盖洗版与非洗版下 subscribe.note 的下载历史追踪。
finish_subscribe_or_not 有下载事实时必须追加 note;__get_downloaded 在洗版
分支只返回 priority==100 的完成集,普通订阅分支继续读取 note。
"""
def _build_subscribe(self, **overrides):
return TestSubscribeChain()._build_subscribe(**overrides)
@staticmethod
def _build_download_context(episodes):
"""
构造一个最小化下载 context:只携带 finish_subscribe_or_not / __update_subscribe_note 路径会读到的字段。
"""
return SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(episodes)),
media_info=SimpleNamespace(
type=MediaType.TV,
media_source="themoviedb",
media_id="1",
),
torrent_info=SimpleNamespace(pri_order=99, title="fake-torrent"),
selected_episodes=list(episodes),
)
def test_finish_subscribe_writes_note_for_best_version_downloads(self):
"""
洗版分支若产生 downloads,subscribe.note 必须被追加。
"""
subscribe = self._build_subscribe(
best_version=1,
total_episode=92,
episode_priority={"1": 100},
note=[1],
)
chain = SubscribeChain()
downloads = [self._build_download_context([83])]
captured_updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
captured_updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
def get(self, *args, **kwargs):
"""
返回当前测试订阅快照,隔离真实数据库读取。
:return: 预设订阅快照
"""
return subscribe
chain.subscription_repository = _SubscribeOper()
with (
patch.object(
SubscribeChain,
"_SubscribeChain__update_movie_download_priority",
),
patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
),
):
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.TV),
mediainfo=SimpleNamespace(
title_year="Test Show (2026)",
type=MediaType.TV,
media_source="themoviedb",
media_id="1",
),
downloads=downloads,
lefts=None,
)
# note 更新必然发生在 SubscribeOper.update 上,定位"note" 键的最近一次写入。
note_writes = [payload["note"] for _, payload in captured_updates if "note" in payload]
assert note_writes
assert 83 in note_writes[-1]
# 既有 note 保留
assert 1 in note_writes[-1]
def test_finish_subscribe_skips_note_when_no_downloads(self):
"""
没有 downloads 时不应触碰 note,避免空写入或误清除。
"""
subscribe = self._build_subscribe(best_version=1, total_episode=92, note=[1, 2])
chain = SubscribeChain()
captured_updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
captured_updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
def get(self, *args, **kwargs):
"""
返回当前测试订阅快照,隔离真实数据库读取。
:return: 预设订阅快照
"""
return subscribe
chain.subscription_repository = _SubscribeOper()
with (
patch.object(
SubscribeChain,
"_SubscribeChain__is_best_version_complete",
return_value=False,
),
patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
),
):
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.TV),
mediainfo=SimpleNamespace(title_year="Test Show (2026)", type=MediaType.TV, tmdb_id=1, douban_id=None),
downloads=None,
lefts=None,
)
# 无下载时不应该有 note 写入。
assert not ([payload for _, payload in captured_updates if "note" in payload])
def test_get_downloaded_best_version_returns_only_completed_episodes(self):
"""
洗版分支不得把 note 合并进 __get_downloaded 返回值。
否则 check_and_handle_existing_media → __get_subscribe_no_exits 会把
priority<100 但已下载的集从 pending no_exists 中减掉,配合 force=True 但
__is_best_version_complete=False 的 finish_subscribe_or_not,会让订阅每轮
都跳过搜索却又永远不完成。__get_downloaded 在洗版下的语义是"无需再处理的
集",只有 priority==100 才满足该语义。
"""
subscribe = self._build_subscribe(
best_version=1,
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 99},
note=[1, 2, 3],
)
downloaded = SubscribeChain._SubscribeChain__get_downloaded(subscribe)
# E3 priority=99 仍是 pending,绝对不能合并到 downloaded 里
assert downloaded == [1, 2]
assert 3 not in downloaded
def test_get_downloaded_non_best_version_reads_note_after_wash_migration(self):
"""
订阅切回普通模式时 __get_downloaded 从非洗版分支读取 note。
"""
subscribe = self._build_subscribe(
best_version=0,
total_episode=5,
# 普通分支不读取按集洗版优先级。
episode_priority={"1": 100, "2": 99},
note=[1, 2, 3],
)
downloaded = SubscribeChain._SubscribeChain__get_downloaded(subscribe)
assert downloaded == [1, 2, 3]
class TestSubscribeProgressEntrypoint:
"""
验证统一进度入口对下载事实和媒体库缺集的解释。
"""
def setup_method(self):
"""
为每个用例加载隔离的订阅链模块,避免测试状态相互影响。
"""
self.module, self.SubscribeChain = _load_subscribe_chain_class()
def _build_subscribe(self, **overrides):
values = {
"id": 1,
"name": "测试剧",
"type": MediaType.TV.value,
"season": 1,
"start_episode": 1,
"total_episode": 5,
"lack_episode": 5,
"note": [],
"best_version": 1,
"best_version_full": 0,
"current_priority": None,
"episode_priority": {},
"last_update": None,
"media_source": "themoviedb",
"media_id": "10001",
"year": "2026",
"manual_total_episode": 0,
}
values.update(overrides)
return self.module.SubscriptionSnapshot(**values)
def test_compute_lack_episode_counts_best_version_note_and_positive_priority(self):
"""
缺集统计合并已有集记录和正优先级分集,忽略非法记录及零优先级。
"""
subscribe = self._build_subscribe(
note=[1, "bad"],
episode_priority={"2": 80, "3": 0, "4": 100},
)
lack = self.SubscribeChain.compute_lack_episode(subscribe)
assert lack == 2
def test_compute_lack_episode_normal_tv_no_exists_boundaries(self):
"""
普通订阅分别处理无缺集、整季缺失和部分缺失,避免把无关媒体计入。
"""
subscribe = self._build_subscribe(best_version=0, note=[1])
missing_all = {"tmdb:10001": {1: NotExistMediaInfo(season=1, episodes=[], total_episode=5, start_episode=1)}}
missing_some = {
"tmdb:10001": {1: NotExistMediaInfo(season=1, episodes=[2, 4], total_episode=5, start_episode=1)}
}
assert self.SubscribeChain.compute_lack_episode(subscribe, no_exists={}) == 0
assert self.SubscribeChain.compute_lack_episode(subscribe, no_exists={"other": {}}) == 0
assert self.SubscribeChain.compute_lack_episode(subscribe, no_exists=missing_all) == 5
assert self.SubscribeChain.compute_lack_episode(subscribe, no_exists=missing_some) == 2
def test_compute_lack_episode_defaults_empty_no_exists_for_normal_tv(self):
"""
普通订阅省略缺集信息时按无缺集计算。
"""
subscribe = self._build_subscribe(best_version=0, note=[1])
assert self.SubscribeChain.compute_lack_episode(subscribe) == 0
def test_note_only_backfill_does_not_satisfy_best_version_quality_target(self):
"""
仅补录已有集数可减少缺集数,但不能证明这些集已达到洗版质量目标。
"""
subscribe = self._build_subscribe(
total_episode=3,
note=[1],
episode_priority={},
lack_episode=2,
)
assert self.SubscribeChain.compute_lack_episode(subscribe) == 2
assert self.SubscribeChain._get_pending_best_version_episodes(subscribe) == [1, 2, 3]
def test_backfill_existing_episodes_writes_note_only_without_priority(self):
"""
补录已有集数时过滤重复、越界和非法输入,不凭空添加分集优先级。
"""
subscribe = self._build_subscribe(note=[1], episode_priority={"2": 80}, lack_episode=4)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1, 2, 3, 9, "bad"],
priority=None,
scene="unit",
)
assert summary["accepted"] == [2, 3]
assert summary["ignored"] == [
{"episode": 1, "reason": "duplicate"},
{"episode": 9, "reason": "out_of_range"},
{"episode": "bad", "reason": "invalid"},
]
assert summary["subscribe"].note == [1, 2, 3]
assert summary["subscribe"].episode_priority == {"2": 80}
assert summary["subscribe"].lack_episode == 2
assert updates[-1][1]["lack_episode"] == 2
def test_backfill_existing_episodes_writes_priority_only_upwards(self):
"""
补录时可提高分集优先级,但目标范围尚未全部完成时不能标记整体完成。
"""
subscribe = self._build_subscribe(note=[], episode_priority={"1": 90, "2": 100}, lack_episode=5)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1, 2, 3],
priority=100,
scene="unit",
)
assert summary["accepted"] == [1, 2, 3]
assert summary["subscribe"].note == [1, 2, 3]
assert summary["subscribe"].episode_priority == {"1": 100, "2": 100, "3": 100}
assert summary["subscribe"].current_priority == 0
assert updates[-1]["current_priority"] == 0
def test_backfill_existing_episodes_ignores_invalid_priority_and_does_not_downgrade(self):
"""
补录忽略超范围和布尔优先级,已有更高优先级不得被较低输入覆盖。
"""
subscribe = self._build_subscribe(note=[], episode_priority={"1": 90}, lack_episode=5)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
invalid = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1, 2],
priority=101,
scene="unit",
)
subscribe = invalid["subscribe"]
lower = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1, 2],
priority=80,
scene="unit",
)
subscribe = lower["subscribe"]
boolean_priority = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[3],
priority=True,
scene="unit",
)
summary = boolean_priority
assert invalid["accepted"] == [1, 2]
assert invalid["ignored_priority"] == 101
assert lower["accepted"] == []
assert lower["ignored"] == [
{"episode": 1, "reason": "duplicate"},
{"episode": 2, "reason": "duplicate"},
]
assert lower["priority_ignored"] == [
{"episode": 1, "reason": "not_higher_priority"},
]
assert lower["priority_updated"] == [2]
assert boolean_priority["accepted"] == [3]
assert boolean_priority["ignored_priority"] is True
assert summary["subscribe"].note == [1, 2, 3]
assert summary["subscribe"].episode_priority == {"1": 90, "2": 80}
def test_backfill_existing_episodes_accepts_note_without_downgrading_priority(self):
"""
新增已有集记录与质量提升分别处理,较低优先级不妨碍记录该集已存在。
"""
subscribe = self._build_subscribe(note=[], episode_priority={"1": 90}, lack_episode=5)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1],
priority=80,
scene="unit",
)
assert summary["accepted"] == [1]
assert summary["priority_updated"] == []
assert summary["subscribe"].note == [1]
assert summary["subscribe"].episode_priority == {"1": 90}
assert "episode_priority" not in updates[-1]
def test_backfill_existing_episodes_updates_priority_for_existing_note(self):
"""
已记录的集仍可提升优先级,无须再次加入已有集列表。
"""
subscribe = self._build_subscribe(note=[1], episode_priority={}, lack_episode=4)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1],
priority=100,
scene="unit",
)
assert summary["accepted"] == []
assert summary["ignored"] == [{"episode": 1, "reason": "duplicate"}]
assert summary["priority_updated"] == [1]
assert summary["subscribe"].note == [1]
assert summary["subscribe"].episode_priority == {"1": 100}
assert updates[-1]["episode_priority"] == {"1": 100}
def test_backfill_existing_episodes_marks_current_priority_complete_only_when_all_targets_are_top(self):
"""
补录覆盖全部目标集且均达到最高优先级时,整体优先级才标记完成。
"""
subscribe = self._build_subscribe(note=[], episode_priority={"1": 90}, lack_episode=5)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[1, 2, 3, 4, 5],
priority=100,
scene="unit",
)
assert summary["accepted"] == [1, 2, 3, 4, 5]
assert summary["subscribe"].current_priority == 100
assert updates[-1]["current_priority"] == 100
def test_backfill_materializes_legacy_current_priority_before_partial_write(self):
"""
局部补录前先展开历史整体优先级,保留其他目标集的原有质量记录。
"""
subscribe = self._build_subscribe(
total_episode=3,
current_priority=80,
episode_priority=None,
note=[],
lack_episode=0,
)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain().backfill_existing_episodes(
subscribe,
[3],
priority=100,
scene="unit",
)
assert summary["subscribe"].episode_priority == {"1": 80, "2": 80, "3": 100}
assert summary["subscribe"].note == [3]
assert summary["subscribe"].current_priority == 80
assert updates[-1]["episode_priority"] == {"1": 80, "2": 80, "3": 100}
def test_backfill_existing_episodes_refreshes_normal_tv_with_public_progress_entrypoint(self):
"""
普通订阅补录后通过公开进度入口重算缺集数,首次写入只保存已有集记录。
"""
subscribe = self._build_subscribe(best_version=0, note=[], lack_episode=5)
progress_calls = []
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
chain = self.SubscribeChain()
chain.subscription_repository = _SubscribeOper()
with (
patch.object(
chain,
"refresh_subscribe_progress",
return_value={
"scene": "unit",
"updated": True,
"fields": ["lack_episode"],
"lack_episode": 4,
"reason": "updated",
},
) as refresh_progress,
):
summary = chain.backfill_existing_episodes(
subscribe,
[1],
priority=None,
scene="unit",
)
progress_calls.append(refresh_progress.call_args)
refresh_progress.assert_called_once()
assert refresh_progress.call_args.args[0].note == [1]
assert refresh_progress.call_args.kwargs == {"scene": "unit"}
assert summary["accepted"] == [1]
assert summary["progress"]["fields"] == ["lack_episode"]
assert "lack_episode" not in updates[0]
assert summary["subscribe"].note == [1]
assert progress_calls
def test_refresh_subscribe_progress_lowers_current_priority_for_partial_historical_episode_priority(self):
"""
历史分集优先级只覆盖部分目标集时,刷新进度须撤销整体完成状态。
"""
subscribe = self._build_subscribe(
total_episode=3,
current_priority=80,
episode_priority={"1": 100},
lack_episode=0,
)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
summary = self.SubscribeChain()._SubscribeChain__refresh_subscribe_progress_with_no_exists(
subscribe=subscribe,
no_exists={},
scene="unit",
)
assert summary["updated"]
assert summary["subscribe"].current_priority == 0
assert updates[-1]["current_priority"] == 0
def test_refresh_full_best_version_progress_preserves_baseline_priority(self):
"""
全集洗版刷新缺集进度时,不从按集事实覆盖整包准入基线。
"""
subscribe = self._build_subscribe(
best_version_full=1,
total_episode=3,
current_priority=82,
episode_priority={"1": 100},
lack_episode=0,
)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
self.SubscribeChain()._SubscribeChain__refresh_subscribe_progress_with_no_exists(
subscribe=subscribe,
no_exists={},
scene="unit",
)
assert subscribe.current_priority == 82
assert "current_priority" not in updates[-1]
def test_refresh_subscribe_progress_normal_tv_uses_resolve_missing_successfully(self):
"""
普通订阅刷新进度时传入识别信息和规范媒体标识,并保存解析出的缺集数。
"""
subscribe = self._build_subscribe(best_version=0, lack_episode=5)
mediainfo = SimpleNamespace(
type=MediaType.TV,
media_source="themoviedb",
media_id="10001",
title_year="测试剧 (2026)",
seasons={1: [1, 2, 3, 4, 5]},
)
no_exists = {"tmdb:10001": {1: NotExistMediaInfo(season=1, episodes=[2, 4], total_episode=5, start_episode=1)}}
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with (
patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
_patch_media_recognize(self.module, mediainfo),
patch.object(
self.SubscribeChain, "resolve_subscribe_missing", return_value=(False, no_exists)
) as resolve_missing,
):
summary = self.SubscribeChain().refresh_subscribe_progress(subscribe, scene="unit")
resolve_missing.assert_called_once()
_, kwargs = resolve_missing.call_args
assert kwargs["subscribe"] is subscribe
assert kwargs["meta"] is not None
assert kwargs["meta"].type == MediaType.TV
assert kwargs["meta"].name == subscribe.name
assert kwargs["meta"].season_seq == "1"
assert kwargs["mediainfo"] is mediainfo
assert kwargs["mediakey"] == "tmdb:10001"
assert summary["updated"]
assert summary["lack_episode"] == 2
assert summary["subscribe"].lack_episode == 2
assert updates[-1]["lack_episode"] == 2
def test_refresh_subscribe_progress_normal_tv_resolve_failure_does_not_write_zero(self):
"""
缺集解析失败时保留原有缺集数,不将失败误写为零缺集。
"""
subscribe = self._build_subscribe(best_version=0, lack_episode=5)
mediainfo = SimpleNamespace(
type=MediaType.TV,
tmdb_id=10001,
douban_id=None,
title_year="测试剧 (2026)",
seasons={1: [1, 2, 3, 4, 5]},
)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
阻止缺集解析失败后写入进度。
:raises AssertionError: 失败路径意外更新订阅
"""
raise AssertionError("resolve failure must not write progress")
with (
patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
_patch_media_recognize(self.module, mediainfo),
patch.object(self.SubscribeChain, "resolve_subscribe_missing", return_value=(False, {})),
):
summary = self.SubscribeChain().refresh_subscribe_progress(subscribe, scene="unit")
assert not (summary["updated"])
assert "reason" in summary
assert subscribe.lack_episode == 5
def test_refresh_subscribe_progress_normal_tv_recognition_failure_does_not_write_zero(self):
"""
媒体识别失败时保留原有缺集数,不继续写入进度。
"""
subscribe = self._build_subscribe(best_version=0, lack_episode=5)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
阻止媒体识别失败后写入进度。
:raises AssertionError: 失败路径意外更新订阅
"""
raise AssertionError("recognition failure must not write progress")
with (
patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
_patch_media_recognize(self.module, None),
):
summary = self.SubscribeChain().refresh_subscribe_progress(subscribe, scene="unit")
assert not (summary["updated"])
assert "reason" in summary
assert subscribe.lack_episode == 5
def test_refresh_subscribe_progress_rejects_raw_no_exists_for_public_signature(self):
"""
公开进度入口拒绝直接传入底层缺集结构,避免绕过解析流程。
"""
subscribe = self._build_subscribe(best_version=0, lack_episode=5)
with pytest.raises(TypeError):
self.SubscribeChain().refresh_subscribe_progress(subscribe, no_exists={})
def test_finish_subscribe_progress_writer_keeps_empty_lefts_as_zero_for_normal_tv(self):
"""
普通订阅下载后没有剩余缺集时,将缺集数写为零。
"""
subscribe = self._build_subscribe(best_version=0, lack_episode=5)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with (
patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(self.SubscribeChain, "_SubscribeChain__finish_subscribe"),
):
self.SubscribeChain().finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.TV),
mediainfo=SimpleNamespace(title_year="测试剧 (2026)"),
downloads=None,
lefts=None,
)
assert updates[-1]["lack_episode"] == 0
class TestSubscribeProgressConsolidation:
"""
验证订阅创建、季集刷新及下载进度汇总的字段一致性。
"""
def _mediainfo(self, total_episode=5):
"""
构造具有统一影视身份和季集信息的识别结果。
"""
return SimpleNamespace(
type=MediaType.TV,
seasons={1: [object() for _ in range(total_episode)]},
title="总集增长剧",
title_year="总集增长剧 (2026)",
year="2026",
media_source="themoviedb",
media_id="31000",
vote_average=9.5,
overview="overview",
imdb_id="tt1234567",
tvdb_id=99,
get_poster_image=lambda: "poster",
get_backdrop_image=lambda: "backdrop",
get_message_image=lambda: "message-image",
to_dict=lambda: {},
)
@staticmethod
def _event_manager(total_episode=None, *, updated=True):
"""
记录订阅事件,并模拟外部集数刷新结果。
"""
captured = []
def _apply(event_type, event_data):
"""
仅在集数刷新事件中填充扩展结果。
"""
captured.append((event_type, event_data))
if hasattr(event_data, "current_total_episode"):
event_data.updated = updated
event_data.total_episode = total_episode
event_data.source = "unit"
event_data.reason = "unit"
return SimpleNamespace(event_data=event_data)
class _EventManager:
"""
提供同步和异步一致的隔离事件入口。
"""
def send_event(self, event_type, event_data):
"""
记录同步事件并返回刷新结果。
"""
return _apply(event_type, event_data)
async def async_send_event(self, event_type, event_data):
"""
记录异步事件并返回刷新结果。
"""
return _apply(event_type, event_data)
return _EventManager(), captured
def test_apply_episodes_refresh_clamps_external_total_to_current_total(self):
"""
同步集数刷新不接受低于当前识别总集数的外部结果。
"""
module, SubscribeChain = _load_subscribe_chain_class()
eventmanager, captured = self._event_manager(5)
with patch.object(module, "eventmanager", eventmanager):
result = SubscribeChain._SubscribeChain__apply_episodes_refresh(
10,
season=1,
mediainfo=self._mediainfo(total_episode=10),
media_source="themoviedb",
media_id="31030",
subscribe_id=31,
scene="precheck",
)
assert result == 10
assert captured[0][1].current_total_episode == 10
assert captured[0][1].total_episode == 10
def test_async_apply_episodes_refresh_clamps_external_total_to_current_total(self):
"""
异步集数刷新同样不接受低于当前识别总集数的外部结果。
"""
module, SubscribeChain = _load_subscribe_chain_class()
eventmanager, captured = self._event_manager(5)
with patch.object(module, "eventmanager", eventmanager):
result = asyncio.run(
SubscribeChain._SubscribeChain__async_apply_episodes_refresh(
10,
season=1,
mediainfo=self._mediainfo(total_episode=10),
media_source="themoviedb",
media_id="31030",
subscribe_id=31,
scene="precheck",
)
)
assert result == 10
assert captured[0][1].current_total_episode == 10
assert captured[0][1].total_episode == 10
def test_refresh_total_episode_before_completion_reuses_progress_priority_snapshot(self):
"""
完成前总集数增长时沿用已有分集质量快照,为新增集保留缺口。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=31,
name="总集增长剧",
type=MediaType.TV.value,
season=1,
total_episode=3,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=0,
current_priority=80,
episode_priority=None,
note=[],
media_source="themoviedb",
media_id="31031",
manual_total_episode=0,
)
mediainfo = self._mediainfo(total_episode=5)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
with patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
mediainfo,
)
assert updated.total_episode == 5
assert updated.episode_priority == {"1": 80, "2": 80, "3": 80}
assert updated.lack_episode == 2
assert updated.current_priority == 0
assert updates[-1][1]["lack_episode"] == 2
assert updates[-1][1]["current_priority"] == 0
def test_refresh_total_episode_before_completion_keeps_downloaded_best_version_floor(self):
"""
总集数回落不能丢弃已确认下载的洗版集数,必要时保持原总集数且不写库。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=34,
name="总集回落剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=1,
best_version_full=0,
current_priority=None,
episode_priority={str(episode): 80 for episode in range(1, 101)},
note=[],
media_source="themoviedb",
media_id="31034",
manual_total_episode=0,
)
updates = []
eventmanager, captured = self._event_manager(updated=False)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
chain = SubscribeChain()
resolve_calls = []
def _resolve_missing(**kwargs):
resolve_calls.append(kwargs)
return True, {}
chain.resolve_subscribe_missing = _resolve_missing
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
eventmanager,
),
):
updated = chain._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=1),
meta=SimpleNamespace(type=MediaType.TV, begin_season=1, season=1),
mediakey=31034,
)
assert captured[0][1].current_total_episode == 1
assert updated.total_episode == 100
assert updates == []
assert resolve_calls[0]["best_version_accept_downloaded"]
def test_refresh_total_episode_before_completion_filters_best_version_priority_on_decrease(self):
"""
总集数缩减时移除目标范围外的分集优先级,并按剩余目标重算完成状态。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=40,
name="洗版回落剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=0,
current_priority=100,
episode_priority={str(episode): 100 for episode in range(1, 101)},
note=[],
media_source="themoviedb",
media_id="31040",
manual_total_episode=0,
)
updates = []
eventmanager, _ = self._event_manager(updated=False)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
eventmanager,
),
):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=10),
)
expected_priority = {str(episode): 100 for episode in range(1, 11)}
payload = updates[-1][1]
assert updated.total_episode == 10
assert updated.episode_priority == expected_priority
assert payload["episode_priority"] == expected_priority
assert updated.lack_episode == 0
assert updated.current_priority == 100
assert SubscribeChain._SubscribeChain__get_best_version_completed_episodes(updated) == list(range(1, 11))
def test_full_best_version_total_expansion_resets_baseline_priority(self):
"""
整季洗版扩展目标集数时清除整体质量基准,同时保留已有分集记录。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=41,
name="全集洗版扩展剧",
type=MediaType.TV.value,
season=1,
total_episode=3,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=1,
current_priority=82,
episode_priority={"1": 90, "2": 80, "3": 82},
note=[1, 2, 3],
media_source="themoviedb",
media_id="31041",
manual_total_episode=0,
)
payload = SubscribeChain._SubscribeChain__prepare_best_version_total_change_fields(
subscribe=subscribe,
total_episode=5,
old_total_episode=3,
)
assert payload["current_priority"] == 0
assert payload["episode_priority"] == {"1": 90, "2": 80, "3": 82}
def test_full_best_version_total_expansion_does_not_derive_episode_map_from_baseline(self):
"""
整季洗版扩集时不能把旧整体质量基准展开成分集质量记录。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=45,
name="全集洗版无按集事实扩展剧",
type=MediaType.TV.value,
season=1,
total_episode=3,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=1,
current_priority=82,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="31045",
manual_total_episode=0,
)
payload = SubscribeChain._SubscribeChain__prepare_best_version_total_expansion_fields(
subscribe=subscribe,
total_episode=5,
)
assert payload["current_priority"] == 0
assert payload["episode_priority"] == {}
def test_full_best_version_total_shrink_preserves_baseline_priority(self):
"""
整季洗版缩减目标集数时保留仍有效的整体质量基准和分集记录。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=43,
name="全集洗版缩小剧",
type=MediaType.TV.value,
season=1,
total_episode=5,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=1,
current_priority=82,
episode_priority={"1": 90, "2": 80, "3": 82, "4": 70, "5": 60},
note=[1, 2, 3, 4, 5],
media_source="themoviedb",
media_id="31043",
manual_total_episode=0,
)
payload = SubscribeChain._SubscribeChain__prepare_best_version_total_change_fields(
subscribe=subscribe,
total_episode=3,
old_total_episode=5,
)
assert payload["current_priority"] == 82
assert payload["episode_priority"] == {"1": 90, "2": 80, "3": 82}
def test_refresh_total_episode_before_completion_resets_legacy_current_priority_when_filtered_empty(self):
"""
缩集后分集记录全部被过滤时,清除旧整体优先级并将目标集重新计为缺失。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=42,
name="洗版空优先级回落剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=0,
current_priority=100,
episode_priority={str(episode): 100 for episode in range(11, 101)},
note=[],
media_source="themoviedb",
media_id="31042",
manual_total_episode=0,
)
updates = []
eventmanager, _ = self._event_manager(updated=False)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
eventmanager,
),
):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=10),
)
payload = updates[-1][1]
assert updated.total_episode == 10
assert updated.episode_priority == {}
assert updated.current_priority == 0
assert updated.lack_episode == 10
assert payload["episode_priority"] == {}
assert payload["current_priority"] == 0
assert payload["lack_episode"] == 10
assert SubscribeChain._SubscribeChain__get_best_version_completed_episodes(updated) == []
def test_refresh_total_episode_before_completion_resets_priority_when_target_range_empty(self):
"""
缩集后目标区间为空时,清除优先级记录且缺集数归零。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=44,
name="洗版目标范围为空回落剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=11,
lack_episode=0,
best_version=1,
best_version_full=0,
current_priority=100,
episode_priority={str(episode): 100 for episode in range(11, 101)},
note=[],
media_source="themoviedb",
media_id="31044",
manual_total_episode=0,
)
updates = []
eventmanager, _ = self._event_manager(updated=False)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
eventmanager,
),
):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=10),
)
payload = updates[-1][1]
assert updated.total_episode == 10
assert updated.episode_priority == {}
assert updated.current_priority == 0
assert updated.lack_episode == 0
assert payload["episode_priority"] == {}
assert payload["current_priority"] == 0
assert payload["lack_episode"] == 0
assert SubscribeChain._SubscribeChain__get_best_version_completed_episodes(updated) == []
def test_refresh_total_episode_before_completion_clamps_lower_event_total_to_recognized_total(self):
"""
完成前刷新收到偏低的外部总集数时,以识别出的总集数为下限。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=35,
name="总集事件压低剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="31035",
manual_total_episode=0,
)
updates = []
eventmanager, _ = self._event_manager(9)
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
eventmanager,
),
):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=10),
)
assert updated.total_episode == 10
assert updated.lack_episode == 10
assert updates[-1][1]["total_episode"] == 10
assert updates[-1][1]["lack_episode"] == 10
def test_refresh_total_episode_before_completion_rejects_manual_total_decrease(self):
"""
手工指定的总集数不参与自动缩集,也不触发外部集数刷新。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=37,
name="手动总集数剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="31037",
manual_total_episode=1,
)
class _EventManager:
def send_event(self, *_args, **_kwargs):
"""
阻止手工指定集数的订阅触发自动集数刷新。
:raises AssertionError: 手工集数路径意外发送事件
"""
raise AssertionError("manual total episode must not ask external refresh")
class _SubscribeOper:
def update(self, *_args, **_kwargs):
"""
阻止自动刷新覆盖手工指定的总集数。
:raises AssertionError: 手工集数路径意外更新订阅
"""
raise AssertionError("manual total episode must not be updated")
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
_EventManager(),
),
):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=10),
)
assert updated.total_episode == 100
assert updated.lack_episode == 100
def test_refresh_total_episode_before_completion_rejects_non_tv_decrease(self):
"""
非电视剧订阅不参与自动缩集,也不修改原有缺集数。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=38,
name="非电视剧",
type=MediaType.MOVIE.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="31038",
manual_total_episode=0,
)
class _EventManager:
def send_event(self, *_args, **_kwargs):
"""
阻止非电视剧订阅触发电视剧集数刷新。
:raises AssertionError: 非电视剧路径意外发送事件
"""
raise AssertionError("non-tv subscribe must not ask external refresh")
class _SubscribeOper:
def update(self, *_args, **_kwargs):
"""
阻止非电视剧订阅进入自动缩集写入。
:raises AssertionError: 非电视剧路径意外更新订阅
"""
raise AssertionError("non-tv subscribe must not be updated")
with (
patch.object(SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(
module,
"eventmanager",
_EventManager(),
),
):
updated = SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
subscribe,
self._mediainfo(total_episode=10),
)
assert updated.total_episode == 100
assert updated.lack_episode == 100
def test_check_total_growth_reuses_progress_priority_snapshot(self):
"""
巡检扩集时沿用已有质量快照,新增集数形成缺口并撤销整体完成状态。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=33,
name="总集增长剧",
type=MediaType.TV.value,
season=1,
total_episode=3,
start_episode=1,
lack_episode=0,
best_version=1,
best_version_full=0,
current_priority=80,
episode_priority=None,
note=[],
year="2026",
episode_group=None,
media_source="themoviedb",
media_id="31033",
manual_total_episode=0,
)
updates = []
class _SubscribeOper:
def list(self):
"""
仅提供当前用例的订阅,隔离真实订阅列表读取。
:return: 包含预设订阅的单项列表
"""
return [subscribe]
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
chain = SubscribeChain()
chain.subscription_repository = _SubscribeOper()
with (
_patch_media_recognize(module, lambda **_kwargs: self._mediainfo(total_episode=5)),
):
chain.check()
payload = updates[-1][1]
assert payload["total_episode"] == 5
assert payload["episode_priority"] == {"1": 80, "2": 80, "3": 80}
assert payload["lack_episode"] == 2
assert payload["current_priority"] == 0
def test_check_total_growth_still_uses_larger_event_total(self):
"""
巡检允许外部事件提供更大的总集数,并按扩展后的范围刷新缺集数。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=39,
name="总集事件增长剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
year="2026",
episode_group=None,
media_source="themoviedb",
media_id="31039",
manual_total_episode=0,
)
updates = []
eventmanager, captured = self._event_manager(120)
class _SubscribeOper:
def list(self):
"""
仅提供当前用例的订阅,隔离真实订阅列表读取。
:return: 包含预设订阅的单项列表
"""
return [subscribe]
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
chain = SubscribeChain()
chain.subscription_repository = _SubscribeOper()
with (
patch.object(
module,
"eventmanager",
eventmanager,
),
_patch_media_recognize(module, lambda **_kwargs: self._mediainfo(total_episode=10)),
):
chain.check()
payload = updates[-1][1]
assert captured[0][1].current_total_episode == 10
assert payload["total_episode"] == 120
assert payload["lack_episode"] == 120
def test_check_total_refresh_uses_confirmed_episode_floor(self):
"""
巡检缩集以已确认存在的最大集数为下限,避免被偏低的识别总集数截断。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=43,
name="总集巡检回落剧",
type=MediaType.TV.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
year="2026",
episode_group=None,
media_source="themoviedb",
media_id="31043",
manual_total_episode=0,
)
updates = []
eventmanager, captured = self._event_manager(updated=False)
class _SubscribeOper:
def list(self):
"""
仅提供当前用例的订阅,隔离真实订阅列表读取。
:return: 包含预设订阅的单项列表
"""
return [subscribe]
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
chain = SubscribeChain()
chain.subscription_repository = _SubscribeOper()
chain.resolve_subscribe_missing = lambda **kwargs: (
False,
{
"tmdb:31043": {
1: SimpleNamespace(
season=1,
episodes=list(range(91, 101)),
total_episode=100,
start_episode=1,
require_complete_coverage=False,
)
}
},
)
with (
patch.object(
module,
"eventmanager",
eventmanager,
),
_patch_media_recognize(module, lambda **_kwargs: self._mediainfo(total_episode=1)),
):
chain.check()
payload = updates[-1][1]
assert captured[0][1].current_total_episode == 1
assert payload["total_episode"] == 90
assert payload["lack_episode"] == 90
def test_check_total_refresh_skips_non_tv_even_when_mediainfo_has_seasons(self):
"""
非电视剧即使带有季信息,也不能进入电视剧的集数刷新分支。
"""
module, SubscribeChain = _load_subscribe_chain_class()
subscribe = module.SubscriptionSnapshot(
id=45,
name="电影误带季集",
type=MediaType.MOVIE.value,
season=1,
total_episode=100,
start_episode=1,
lack_episode=100,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
year="2026",
episode_group=None,
media_source="themoviedb",
media_id="31045",
manual_total_episode=0,
)
updates = []
mediainfo = self._mediainfo(total_episode=10)
mediainfo.type = MediaType.MOVIE
class _SubscribeOper:
def list(self):
"""
仅提供当前用例的订阅,隔离真实订阅列表读取。
:return: 包含预设订阅的单项列表
"""
return [subscribe]
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append((subscribe_id, payload.to_payload()))
return replace(subscribe, **payload.to_payload())
class _EventManager:
def send_event(self, *_args, **_kwargs):
"""
阻止带季信息的非电视剧订阅触发集数刷新。
:raises AssertionError: 非电视剧路径意外发送事件
"""
raise AssertionError("non-tv subscribe must not ask external refresh")
chain = SubscribeChain()
chain.subscription_repository = _SubscribeOper()
with (
patch.object(
module,
"eventmanager",
_EventManager(),
),
_patch_media_recognize(module, mediainfo),
):
chain.check()
assert updates[-1][1]["total_episode"] == 100
assert updates[-1][1]["lack_episode"] == 100
def test_add_create_clamps_event_decrease_to_recognized_total(self):
"""
创建订阅时外部事件给出的较小集数不能低于识别总集数。
"""
module, SubscribeChain = _load_subscribe_chain_class()
added = []
eventmanager, captured = self._event_manager(5)
mediainfo = self._mediainfo(total_episode=10)
chain = SubscribeChain()
chain.obtain_images = lambda **_kwargs: None
# 落库入口已迁到 application/subscription/write.py,链路层的接缝是 add_subscribe;
# 截在这里拿到的就是链路交给写入路径的原始字段,正是本用例要断言的总集数
def _add_subscribe(**kwargs):
added.append(kwargs)
return 41, None
with (
patch.object(module, "add_subscribe", _add_subscribe),
patch.object(
module,
"eventmanager",
eventmanager,
),
_patch_media_recognize(module, mediainfo),
):
sid, err_msg = chain.add(
title="总集创建剧",
year="2026",
mtype=MediaType.TV,
media_source="themoviedb",
media_id="31041",
season=1,
message=False,
)
assert sid == 41
assert err_msg is None
assert captured[0][1].scene == "create"
assert captured[0][1].current_total_episode == 10
assert added[-1]["total_episode"] == 10
assert added[-1]["lack_episode"] == 10
def _identity_mediainfo(self, media_source, media_id, title, year, seasons=(1,), total_episode=9):
"""
按来源构造识别结果,季集结构与 TMDB 整剧或豆瓣季条目保持一致。
"""
mediainfo = self._mediainfo(total_episode=total_episode)
mediainfo.media_source = media_source
mediainfo.media_id = media_id
mediainfo.title = title
mediainfo.year = year
mediainfo.title_year = f"{title} ({year})"
mediainfo.seasons = {number: [object() for _ in range(total_episode)] for number in seasons}
return mediainfo
def _conversion_media_chain(self, module, tmdbinfo, *, allow_convert=True, tmdb_available=True):
"""
按请求来源分发识别结果,并记录身份转换调用,供订阅创建的转换用例复用。
"""
douban = self._identity_mediainfo("douban", "35932853", "绝望写手 第三季", "2024", seasons=(1,))
tmdb = self._identity_mediainfo("themoviedb", "124101", "绝望写手", "2021", seasons=(1, 2, 3, 4, 5))
calls = {"convert": [], "recognize": []}
def _recognize(**kwargs):
"""
根据目标来源返回原身份或 TMDB 二次识别结果。
"""
calls["recognize"].append(kwargs)
if kwargs.get("media_source") == MediaSource.TMDB:
return tmdb if tmdb_available else None
return douban
def _convert(**kwargs):
"""
记录身份投影参数,禁止无需转换的目标触发调用。
"""
assert allow_convert, "recognize source is not TMDB, identity must not be converted"
calls["convert"].append(kwargs)
return tmdbinfo
async def _async_recognize(**kwargs):
"""
以相同规则模拟异步识别。
"""
return _recognize(**kwargs)
async def _async_convert(**kwargs):
"""
以相同规则模拟异步身份投影。
"""
return _convert(**kwargs)
media_chain = SimpleNamespace(
recognize_media=_recognize,
async_recognize_media=_async_recognize,
convert_media_identity=_convert,
async_convert_media_identity=_async_convert,
supplement_media_info=lambda mediainfo: mediainfo,
)
return patch.object(module, "MediaChain", return_value=media_chain), calls
def _add_douban_season(
self, module, SubscribeChain, tmdbinfo, recognize_source="themoviedb", allow_convert=True,
*, tmdb_available=True, season=None, async_mode=False,
):
"""
以豆瓣季条目创建电视剧订阅,返回落库字段和媒体链调用记录。
"""
added = []
eventmanager, _ = self._event_manager(9)
chain = SubscribeChain()
chain.obtain_images = lambda **_kwargs: None
chain.async_obtain_images = AsyncMock(return_value=None)
def _add_subscribe(**kwargs):
"""
记录最终订阅写入字段。
"""
added.append(kwargs)
return 51, None
async_add_subscribe = AsyncMock(side_effect=_add_subscribe)
media_patch, calls = self._conversion_media_chain(
module, tmdbinfo, allow_convert=allow_convert, tmdb_available=tmdb_available,
)
with (
patch.object(module, "add_subscribe", _add_subscribe),
patch.object(module, "async_add_subscribe", async_add_subscribe),
patch.object(module, "eventmanager", eventmanager),
patch.object(module, "get_runtime_setting", lambda _key, _default=None: recognize_source),
media_patch,
):
kwargs = dict(
title="绝望写手 第三季",
year="2024",
mtype=MediaType.TV,
media_source="douban",
media_id="35932853",
season=season,
message=False,
)
sid, err_msg = asyncio.run(chain.async_add(**kwargs)) if async_mode else chain.add(**kwargs)
assert sid == 51
assert err_msg is None
return added[-1], calls
def test_add_converts_douban_season_identity_to_tmdb_series(self):
"""
识别源为 TMDB 时,豆瓣季条目订阅必须落为 TMDB 整剧身份并保留季号。
"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(module, SubscribeChain, {"id": 124101, "media_type": "tv", "season": 3})
assert added["media_source"] == "themoviedb"
assert added["media_id"] == "124101"
assert added["season"] == 3
assert added["mediainfo"].title == "绝望写手"
assert added["total_episode"] == 9
assert calls["convert"][0]["media_source"] == MediaSource.Douban
assert calls["convert"][0]["media_id"] == "35932853"
assert calls["convert"][0]["season"] == 3
assert calls["recognize"][-1]["media_source"] == MediaSource.TMDB
assert calls["recognize"][-1]["media_id"] == "124101"
def test_add_keeps_douban_identity_when_tmdb_conversion_fails(self):
"""
转换失败时沿用原身份,保持历史行为而不是拒绝订阅。
"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(module, SubscribeChain, None)
assert added["media_source"] == "douban"
assert added["media_id"] == "35932853"
assert added["season"] == 3
assert added["mediainfo"].title == "绝望写手"
assert len(calls["convert"]) == 1
assert all(call.get("media_source") != MediaSource.TMDB for call in calls["recognize"])
def test_add_skips_tmdb_conversion_when_recognize_source_is_not_tmdb(self):
"""
识别源不是 TMDB 时沿用用户选定的来源身份,不触发转换。
"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(
module,
SubscribeChain,
{"id": 124101},
recognize_source="douban",
allow_convert=False,
)
assert added["media_source"] == "douban"
assert added["media_id"] == "35932853"
assert calls["convert"] == []
@pytest.mark.parametrize("async_mode", [False, True])
def test_add_keeps_original_identity_when_converted_tmdb_cannot_be_recognized(self, async_mode):
"""
TMDB 投影命中但二次识别失败时,同步和异步创建都保留原身份及季号。
"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(
module, SubscribeChain, {"id": 124101, "season": 3},
tmdb_available=False, async_mode=async_mode,
)
assert added["media_source"] == "douban"
assert added["media_id"] == "35932853"
assert added["season"] == 3
assert len(calls["convert"]) == 1
assert calls["recognize"][-1]["media_source"] == MediaSource.TMDB
@pytest.mark.parametrize("async_mode", [False, True])
def test_add_preserves_explicit_season_during_tmdb_conversion(self, async_mode):
"""
显式季号优先于标题和投影季号,配置的 TMDB 别名也应触发转换。
"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(
module, SubscribeChain, {"id": 124101, "season": 3},
recognize_source="tmdb", season=4, async_mode=async_mode,
)
assert added["media_source"] == "themoviedb"
assert added["media_id"] == "124101"
assert added["season"] == 4
assert calls["convert"][0]["season"] == 4
assert calls["recognize"][-1]["mtype"] == MediaType.TV
assert calls["recognize"][-1]["cache"] is False
@pytest.mark.parametrize("async_mode", [False, True])
@pytest.mark.parametrize("target", ["missing", "tmdb", "music"])
def test_tmdb_conversion_skips_missing_tmdb_and_music_targets(self, target, async_mode):
"""
无识别结果、已有 TMDB 身份及音乐目标均不得调用影视身份转换。
"""
module, SubscribeChain = _load_subscribe_chain_class()
chain = SubscribeChain()
mediainfo = None
if target != "missing":
mediainfo = self._identity_mediainfo(
"themoviedb" if target == "tmdb" else "musicbrainz", "124101", "标题", "2024",
)
if target == "music":
mediainfo.type = MediaType.MUSIC
context = SimpleNamespace(mediainfo=mediainfo)
media_patch, calls = self._conversion_media_chain(module, {"id": 124101}, allow_convert=False)
with (
patch.object(module, "get_runtime_setting", lambda _key, _default=None: "themoviedb"),
media_patch,
):
if async_mode:
asyncio.run(chain._SubscribeChain__async_convert_subscribe_media_to_tmdb(context))
else:
chain._SubscribeChain__convert_subscribe_media_to_tmdb(context)
assert context.mediainfo is mediainfo
assert calls == {"convert": [], "recognize": []}
def test_async_convert_subscribe_media_to_tmdb_replaces_identity_and_fills_season(self):
"""
异步入口同样换成 TMDB 身份;季号缺失时采用投影结果给出的季。
"""
module, SubscribeChain = _load_subscribe_chain_class()
chain = SubscribeChain()
context, error = chain._SubscribeChain__build_subscribe_create_context(
"绝望写手",
"2024",
MediaType.TV,
None,
None,
None,
None,
None,
None,
False,
False,
"douban",
"35932853",
{},
)
assert error is None
context.mediainfo = self._identity_mediainfo("douban", "35932853", "绝望写手", "2024")
media_patch, calls = self._conversion_media_chain(module, {"id": 124101, "season": 3})
with (
patch.object(module, "get_runtime_setting", lambda _key, _default=None: "themoviedb"),
media_patch,
):
asyncio.run(chain._SubscribeChain__async_convert_subscribe_media_to_tmdb(context))
assert context.mediainfo.media_source == "themoviedb"
assert context.mediainfo.media_id == "124101"
assert context.season == 3
assert calls["convert"][0]["season"] is None
def test_completed_episode_uses_schema_function_directly_for_best_version(self):
"""
洗版完成集数直接复用模型层计算入口,避免链层维护重复算法。
"""
module, SubscribeChain = _load_subscribe_chain_class()
values = {
"id": 32,
"name": "完成集数剧",
"type": MediaType.TV.value,
"season": 1,
"total_episode": 8,
"start_episode": 3,
"lack_episode": 2,
"best_version": 1,
"episode_priority": {"3": 100, "4": 80, "5": 100, "8": 100},
}
chain_subscribe = module.SubscriptionSnapshot(**values)
schema_subscribe = schemas.Subscribe(**values)
assert not (hasattr(SubscribeChain, "compute_completed_episode"))
assert schema_subscribe.completed_episode == schemas.compute_subscribe_completed_episode(chain_subscribe)
def test_completed_episode_uses_current_priority_when_episode_priority_empty(self):
"""
历史洗版订阅没有分集记录时,完成集数按整体优先级兼容计算。
"""
module, _ = _load_subscribe_chain_class()
values = {
"id": 33,
"name": "完成集数旧快照剧",
"type": MediaType.TV.value,
"season": 1,
"total_episode": 3,
"start_episode": 1,
"lack_episode": 0,
"best_version": 1,
"current_priority": 100,
"episode_priority": None,
}
chain_subscribe = module.SubscriptionSnapshot(**values)
schema_subscribe = schemas.Subscribe(**values)
assert schema_subscribe.completed_episode == 3
assert schemas.compute_subscribe_completed_episode(chain_subscribe) == 3
def test_full_best_version_completed_episode_uses_baseline_not_episode_map(self):
"""
整季洗版按整体质量基准计算完成集数,不以分集优先级代替该基准。
"""
data = {
"name": "全集洗版响应剧",
"type": MediaType.TV.value,
"best_version": 1,
"best_version_full": 1,
"start_episode": 1,
"total_episode": 3,
"current_priority": 82,
"episode_priority": {"1": 100, "2": 100, "3": 100},
}
assert schemas.compute_subscribe_completed_episode(SimpleNamespace(**data)) == 0
data["current_priority"] = 100
data["episode_priority"] = {"1": 80}
assert schemas.compute_subscribe_completed_episode(SimpleNamespace(**data)) == 3
class TestSubscribeDownloadFacts:
"""
验证成功下载事实写入的季集范围与优先级。
"""
def setup_method(self):
"""
为每个下载事实用例加载隔离的订阅链模块。
"""
self.module, self.SubscribeChain = _load_subscribe_chain_class()
def _build_subscribe(self, **overrides):
values = {
"id": 3,
"name": "下载事实剧",
"type": MediaType.TV.value,
"season": 1,
"start_episode": 1,
"total_episode": 4,
"lack_episode": 4,
"note": [],
"best_version": 0,
"best_version_full": 0,
"current_priority": None,
"episode_priority": {},
"media_source": "themoviedb",
"media_id": "30003",
"manual_total_episode": 0,
}
values.update(overrides)
return self.module.SubscriptionSnapshot(**values)
def _download(self, episodes=None, pri_order=80, selected_episodes=None, confirmed_full_coverage=False):
return SimpleNamespace(
selected_episodes=selected_episodes,
confirmed_full_coverage=confirmed_full_coverage,
torrent_info=SimpleNamespace(pri_order=pri_order),
meta_info=SimpleNamespace(episode_list=episodes or [], season_list=[1]),
media_info=SimpleNamespace(
type=MediaType.TV,
media_source="themoviedb",
media_id="30003",
),
)
def test_normal_tv_download_records_note_and_episode_priority_without_current_priority(self):
"""
普通电视剧下载记录已有集数和分集质量,不写洗版整体优先级。
"""
subscribe = self._build_subscribe(best_version=0)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
snapshot = self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts(
subscribe,
mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"),
downloads=[self._download(episodes=[1, 2], pri_order=70)],
)
assert snapshot["episodes"] == [1, 2]
assert snapshot["subscribe"].note == [1, 2]
assert snapshot["subscribe"].episode_priority == {"1": 70, "2": 70}
assert snapshot["subscribe"].current_priority is None
assert "current_priority" not in updates[-1]
def test_normal_tv_download_records_full_pack_confirmed_coverage_episode_priority(self):
"""
普通电视剧整包确认完整覆盖后,为全部目标集记录下载质量。
"""
subscribe = self._build_subscribe(best_version=0, best_version_full=0, total_episode=3, episode_priority={})
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
snapshot = self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts(
subscribe,
mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"),
downloads=[
self._download(
episodes=[],
pri_order=80,
selected_episodes=[],
confirmed_full_coverage=True,
)
],
)
assert snapshot["episodes"] == [1, 2, 3]
assert snapshot["subscribe"].note == [1, 2, 3]
assert snapshot["subscribe"].episode_priority == {"1": 80, "2": 80, "3": 80}
assert "current_priority" not in updates[-1]
def test_full_resource_without_episode_list_does_not_fallback_without_download_confirmation(self):
"""
无分集列表的整包尚未确认覆盖时,不补写已有集数或质量记录。
"""
subscribe = self._build_subscribe(best_version=1, best_version_full=1, episode_priority={"1": 60})
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
snapshot = self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts(
subscribe,
mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"),
downloads=[self._download(episodes=[], pri_order=90, selected_episodes=[])],
)
assert snapshot["episodes"] == []
assert subscribe.note == []
assert subscribe.episode_priority == {"1": 60}
assert updates == []
def test_full_resource_without_episode_list_uses_target_range_only_when_confirmed(self):
"""
整包确认覆盖后才使用完整目标区间补写集数,并更新洗版整体优先级。
"""
subscribe = self._build_subscribe(best_version=1, best_version_full=1, episode_priority={"1": 60})
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
snapshot = self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts(
subscribe,
mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"),
downloads=[
self._download(
episodes=[],
pri_order=90,
selected_episodes=[],
confirmed_full_coverage=True,
)
],
)
assert snapshot["episodes"] == [1, 2, 3, 4]
assert snapshot["subscribe"].note == [1, 2, 3, 4]
assert snapshot["subscribe"].episode_priority == {"1": 90, "2": 90, "3": 90, "4": 90}
assert snapshot["subscribe"].current_priority == 90
assert updates[-1]["current_priority"] == 90
def test_full_best_version_does_not_write_baseline_without_confirmed_full_coverage(self):
"""
整季洗版未确认完整覆盖时保留原整体质量基准。
"""
subscribe = self._build_subscribe(
best_version=1,
best_version_full=1,
current_priority=82,
episode_priority={"1": 60},
)
with patch.object(self.SubscribeChain, "subscription_repository", create=True) as subscribe_oper:
self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts(
subscribe,
mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"),
downloads=[self._download(episodes=[1], pri_order=90, confirmed_full_coverage=False)],
)
payload = subscribe_oper.update.call_args.args[1].to_payload()
assert subscribe.current_priority == 82
assert "current_priority" not in payload
def test_normal_subscription_without_episode_list_does_not_use_target_range_without_download_confirmation(self):
"""
普通订阅的资源没有分集列表且未确认覆盖时,不用目标区间推断下载事实。
"""
subscribe = self._build_subscribe(best_version=0, best_version_full=0)
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True):
snapshot = self.SubscribeChain()._SubscribeChain__record_subscribe_download_facts(
subscribe,
mediainfo=SimpleNamespace(title_year="下载事实剧 (2026)"),
downloads=[
self._download(
episodes=[],
pri_order=90,
selected_episodes=[],
confirmed_full_coverage=False,
)
],
)
assert snapshot["episodes"] == []
assert subscribe.note == []
assert subscribe.episode_priority == {}
assert updates == []
def test_movie_best_version_download_keeps_current_priority_without_episode_priority(self):
"""
电影洗版记录整体优先级和更新时间,不生成电视剧的分集优先级。
"""
subscribe = self._build_subscribe(
type=MediaType.MOVIE.value,
best_version=1,
best_version_full=0,
current_priority=60,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="30003",
total_episode=1,
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE,
tmdb_id=30003,
douban_id=None,
bangumi_id=None,
anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
with (
patch.object(self.SubscribeChain, "subscription_repository", _SubscribeOper(), create=True),
patch.object(self.SubscribeChain, "_SubscribeChain__finish_subscribe"),
):
self.SubscribeChain().finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.MOVIE),
mediainfo=SimpleNamespace(title_year="下载事实电影 (2026)"),
downloads=[download],
lefts={},
)
assert updates[-1]["current_priority"] == 90
assert updates[-1]["last_update"]
assert subscribe.last_update is None
assert subscribe.episode_priority == {}
def test_movie_best_version_download_does_not_call_tv_progress_writer(self):
"""
电影洗版完成处理跳过电视剧专用的缺集进度写入入口。
"""
subscribe = self._build_subscribe(
type=MediaType.MOVIE.value,
best_version=1,
best_version_full=0,
current_priority=60,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="30003",
total_episode=1,
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE,
tmdb_id=30003,
douban_id=None,
bangumi_id=None,
anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
chain = self.SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
with (
patch.object(chain, "_SubscribeChain__refresh_subscribe_progress_with_no_exists") as refresh_mock,
patch.object(chain, "_SubscribeChain__finish_subscribe"),
):
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.MOVIE),
mediainfo=SimpleNamespace(title_year="下载事实电影 (2026)"),
downloads=[download],
lefts={},
)
refresh_mock.assert_not_called()
def test_movie_normal_download_does_not_call_tv_progress_writer(self):
"""
普通电影完成处理同样跳过电视剧专用的缺集进度写入入口。
"""
subscribe = self._build_subscribe(
type=MediaType.MOVIE.value,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="30003",
total_episode=1,
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE,
tmdb_id=30003,
douban_id=None,
bangumi_id=None,
anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
chain = self.SubscribeChain()
subscribe_oper = MagicMock()
chain.subscription_repository = subscribe_oper
with (
patch.object(chain, "_SubscribeChain__refresh_subscribe_progress_with_no_exists") as refresh_mock,
patch.object(chain, "_SubscribeChain__finish_subscribe"),
):
subscribe_oper.update.side_effect = lambda _sid, payload: replace(subscribe, **payload.to_payload())
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.MOVIE),
mediainfo=SimpleNamespace(title_year="下载事实电影 (2026)"),
downloads=[download],
lefts={},
)
refresh_mock.assert_not_called()
def test_movie_normal_download_records_current_priority_before_completion(self):
"""
普通电影须先保存本次下载优先级和时间,再执行订阅完成处理。
"""
subscribe = self._build_subscribe(
type=MediaType.MOVIE.value,
best_version=0,
best_version_full=0,
current_priority=None,
episode_priority={},
note=[],
media_source="themoviedb",
media_id="30003",
total_episode=1,
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE,
tmdb_id=30003,
douban_id=None,
bangumi_id=None,
anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
updates = []
finished = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
"""
记录订阅更新载荷,并返回应用这些字段的新订阅快照。
:param subscribe_id: 本次更新的订阅标识
:param payload: 待应用的订阅字段变更
:return: 合并字段变更后的订阅快照
"""
updates.append(payload.to_payload())
return replace(subscribe, **payload.to_payload())
chain = self.SubscribeChain()
chain.subscription_repository = _SubscribeOper()
def finish_probe(subscribe, **_kwargs):
finished.append(subscribe.current_priority)
with (
patch.object(chain, "_SubscribeChain__finish_subscribe", side_effect=finish_probe),
):
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.MOVIE),
mediainfo=SimpleNamespace(title_year="下载事实电影 (2026)"),
downloads=[download],
lefts={},
)
assert finished == [90]
assert updates[-1]["current_priority"] == 90
assert updates[-1]["last_update"]
assert subscribe.last_update is None
def test_async_add_batch_reuses_prepared_defaults_notifications_and_effects():
"""
批量新增把 Chain 准备好的默认字段、通知和原提交后回调逐季交给 writer。
"""
module, subscribe_chain = _load_subscribe_chain_class()
chain = subscribe_chain()
def context(season: int):
"""
构造已完成识别、季集补齐和默认参数处理的一季上下文。
"""
mediainfo = SimpleNamespace(
title="批量剧集",
year="2026",
type=MediaType.TV,
media_source=MediaSource.TMDB,
media_id="batch-chain-1",
episode_group=None,
seasons={1: [1], 2: [1, 2]},
vote_average=8.5,
overview="批量准备",
get_poster_image=lambda: "poster.jpg",
get_backdrop_image=lambda: "backdrop.jpg",
)
return module._SubscribeCreateContext(
title="批量剧集",
year="2026",
mtype=MediaType.TV,
episode_group=None,
season=season,
channel=None,
source=None,
userid=None,
username="Seerr",
message=True,
exist_ok=False,
options={
"media_source": MediaSource.TMDB,
"media_id": "batch-chain-1",
"filter": "default-filter",
"total_episode": season,
"lack_episode": season,
},
explicit_identity=True,
media_source=MediaSource.TMDB,
media_id="batch-chain-1",
requested_music_type=None,
metainfo=SimpleNamespace(type=MediaType.TV),
mediainfo=mediainfo,
)
prepared = AsyncMock(side_effect=[(context(1), None), (context(2), None)])
notification = MagicMock(
side_effect=[{"title": "第 1 季"}, {"title": "第 2 季"}]
)
post_commit = AsyncMock(return_value=True)
setattr(chain, "_SubscribeChain__async_prepare_subscribe_create", prepared)
setattr(chain, "_SubscribeChain__build_subscribe_notification", notification)
setattr(chain, "_SubscribeChain__async_post_subscribe_added", post_commit)
batch_writer = MagicMock()
async def persist(requests):
"""
模拟数据库批量 writer,并在提交点后调用冻结的逐季副作用。
"""
for subscribe_id, request in zip((31, 32), requests):
assert request.after_commit is not None
await request.after_commit(subscribe_id)
return (
SubscriptionWriteResult(31, "新增订阅成功", True),
SubscriptionWriteResult(32, "新增订阅成功", True),
)
batch_writer.async_add = AsyncMock(side_effect=persist)
result = asyncio.run(
chain.async_add_batch(
title="批量剧集",
year="2026",
seasons=[1, 2],
batch_writer=batch_writer,
mtype=MediaType.TV,
media_source=MediaSource.TMDB,
media_id="batch-chain-1",
username="Seerr",
)
)
assert result == (32, "新增订阅成功")
requests = batch_writer.async_add.await_args.args[0]
assert [request.identity.season for request in requests] == [1, 2]
assert [request.payload.to_payload()["filter"] for request in requests] == [
"default-filter",
"default-filter",
]
assert [dict(request.notification or {}) for request in requests] == [
{"title": "第 1 季"},
{"title": "第 2 季"},
]
assert [call.args[0] for call in post_commit.await_args_list] == [31, 32]