feat(subscribe): add episode priority tracking for subscription updates

This commit is contained in:
jxxghp
2026-05-12 17:22:50 +08:00
parent 9068280f6d
commit 2c45831714
10 changed files with 943 additions and 219 deletions

View File

@@ -33,6 +33,8 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
"sites",
"downloader",
"best_version",
"current_priority",
"episode_priority",
"save_path",
"custom_words",
"media_category",

View File

@@ -107,6 +107,8 @@ async def update_subscribe(
# 避免更新缺失集数
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.model_dump()
if subscribe_in.episode_priority is None:
subscribe_dict.pop("episode_priority", None)
if not subscribe_in.lack_episode:
# 没有缺失集数时缺失集数清空避免更新为0
subscribe_dict.pop("lack_episode")
@@ -232,6 +234,8 @@ async def reset_subscribes(
await subscribe.async_update(db, {
"note": [],
"lack_episode": subscribe.total_episode,
"current_priority": None,
"episode_priority": {},
"state": "R"
})
# 重新获取更新后的订阅数据

View File

@@ -213,6 +213,12 @@ class DownloadChain(ChainBase):
# 实际下载的集数
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
if episodes is not None:
context.selected_episodes = sorted(set(episodes))
elif _meta and _meta.episode_list:
context.selected_episodes = sorted(set(_meta.episode_list))
else:
context.selected_episodes = []
_folder_name = ""
if not torrent_file and not torrent_content:
# 下载种子文件,得到的可能是文件也可能是磁力链

View File

@@ -9,7 +9,6 @@ from datetime import datetime
from typing import AsyncIterator, Any, Dict, Tuple
from typing import List, Optional
from app.helper.sites import SitesHelper # noqa
from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase
@@ -20,6 +19,7 @@ from app.core.event import eventmanager, Event
from app.core.metainfo import MetaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.progress import ProgressHelper
from app.helper.sites import SitesHelper # noqa
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo
@@ -206,7 +206,8 @@ class SearchChain(ChainBase):
return original_indices
async def _invoke_recommend_llm(self, search_results_text: str) -> str:
@staticmethod
async def _invoke_recommend_llm(search_results_text: str) -> str:
"""
通过统一后台提示词机制执行资源推荐。
"""
@@ -614,10 +615,10 @@ class SearchChain(ChainBase):
filtered_torrents = torrent_list
if filter_params:
torrenthelper = TorrentHelper()
handler = TorrentHelper()
filtered_torrents = [
torrent for torrent in filtered_torrents
if torrenthelper.filter_torrent(torrent, filter_params)
t for t in filtered_torrents
if handler.filter_torrent(t, filter_params)
]
if rule_groups and filtered_torrents:
@@ -633,11 +634,11 @@ class SearchChain(ChainBase):
return torrent_list
site_torrents: Dict[Tuple[Optional[int], Optional[str]], List[TorrentInfo]] = {}
for torrent in torrent_list:
site_key = (torrent.site, torrent.site_name)
for t in torrent_list:
site_key = (t.site, t.site_name)
if site_key not in site_torrents:
site_torrents[site_key] = []
site_torrents[site_key].append(torrent)
site_torrents[site_key].append(t)
if len(site_torrents) <= 1:
return __do_site_filter(torrent_list)
@@ -659,11 +660,11 @@ class SearchChain(ChainBase):
)
filtered_ids = {
id(torrent)
id(t)
for filtered_torrents in filtered_by_site.values()
for torrent in filtered_torrents
for t in filtered_torrents
}
return [torrent for torrent in torrent_list if id(torrent) in filtered_ids]
return [t for t in torrent_list if id(t) in filtered_ids]
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
@@ -1286,16 +1287,16 @@ class SearchChain(ChainBase):
async def search_site(site: dict) -> Tuple[dict, List[TorrentInfo]]:
if area == "imdbid":
result = await self.async_search_torrents(site=site,
site_result = await self.async_search_torrents(site=site,
keyword=mediainfo.imdb_id if mediainfo else None,
mtype=mediainfo.type if mediainfo else None,
page=page)
else:
result = await self.async_search_torrents(site=site,
site_result = await self.async_search_torrents(site=site,
keyword=keyword,
mtype=mediainfo.type if mediainfo else None,
page=page)
return site, result or []
return site, site_result or []
tasks = [asyncio.create_task(search_site(site)) for site in indexer_sites]
results_count = 0

View File

@@ -5,7 +5,7 @@ import re
import threading
import time
from datetime import datetime
from typing import Dict, List, Optional, Union, Tuple
from typing import Any, Dict, List, Optional, Union, Tuple
from app import schemas
from app.chain import ChainBase
@@ -56,6 +56,242 @@ class SubscribeChain(ChainBase):
_button_page_size = 6
_text_page_size = 10
@staticmethod
def __normalize_episode_priority(episode_priority: Optional[dict]) -> Dict[str, int]:
"""
归一化按集洗版优先级状态。
"""
if not isinstance(episode_priority, dict):
return {}
normalized = {}
for episode, priority in episode_priority.items():
if episode is None or priority is None:
continue
try:
normalized[str(int(episode))] = int(priority)
except (TypeError, ValueError):
continue
return normalized
@classmethod
def __get_episode_priority(cls, subscribe: Subscribe) -> Dict[str, int]:
"""
获取订阅按集洗版优先级状态。
"""
episode_priority = cls.__normalize_episode_priority(getattr(subscribe, "episode_priority", None))
if episode_priority:
return episode_priority
if subscribe.best_version and subscribe.type == MediaType.TV.value and subscribe.current_priority is not None:
target_episodes = cls.__get_best_version_target_episodes(subscribe)
return {
str(episode): int(subscribe.current_priority)
for episode in target_episodes
}
return {}
@classmethod
def get_episode_priority(cls, subscribe: Subscribe) -> Dict[str, int]:
"""
对外暴露按集洗版优先级状态。
"""
return cls.__get_episode_priority(subscribe)
@classmethod
def __get_best_version_target_episodes(cls, subscribe: Subscribe) -> List[int]:
"""
获取洗版订阅目标剧集范围。
"""
if subscribe.type != MediaType.TV.value:
return []
start_episode = subscribe.start_episode or 1
total_episode = subscribe.total_episode or 0
if total_episode < start_episode:
return []
return list(range(start_episode, total_episode + 1))
@classmethod
def __get_pending_best_version_episodes_with_priority(
cls,
subscribe: Subscribe,
episode_priority: Optional[dict] = None,
) -> List[int]:
"""
使用指定按集优先级状态获取当前仍需继续洗版的剧集。
"""
target_episodes = cls.__get_best_version_target_episodes(subscribe)
if not target_episodes:
return []
if episode_priority is None:
normalized = cls.__get_episode_priority(subscribe)
else:
normalized = cls.__normalize_episode_priority(episode_priority)
return [episode for episode in target_episodes if normalized.get(str(episode)) != 100]
@classmethod
def _get_pending_best_version_episodes(cls, subscribe: Subscribe) -> List[int]:
"""
获取当前仍需继续洗版的剧集。
"""
return cls.__get_pending_best_version_episodes_with_priority(subscribe)
@classmethod
def get_best_version_lack_episode(
cls,
subscribe: Subscribe,
episode_priority: Optional[dict] = None,
) -> int:
"""
获取洗版订阅当前剩余待洗剧集数。
"""
if not subscribe.best_version or subscribe.type != MediaType.TV.value:
return subscribe.lack_episode or 0
return len(cls.__get_pending_best_version_episodes_with_priority(subscribe, episode_priority))
@classmethod
def get_best_version_current_priority(
cls,
subscribe: Subscribe,
episode_priority: Optional[dict] = None,
) -> int:
"""
获取洗版订阅当前优先级状态。
"""
if not subscribe.best_version or subscribe.type != MediaType.TV.value:
return subscribe.current_priority or 0
pending_episodes = cls.__get_pending_best_version_episodes_with_priority(subscribe, episode_priority)
if not pending_episodes:
return 100
if episode_priority is None:
normalized = cls.__get_episode_priority(subscribe)
else:
normalized = cls.__normalize_episode_priority(episode_priority)
return max(
(normalized.get(str(episode), 0) for episode in pending_episodes),
default=0,
)
@classmethod
def __is_best_version_complete(cls, subscribe: Subscribe) -> bool:
"""
判断洗版订阅是否已完成。
"""
if not subscribe.best_version:
return False
if subscribe.type != MediaType.TV.value:
return subscribe.current_priority == 100
target_episodes = cls.__get_best_version_target_episodes(subscribe)
if not target_episodes:
return subscribe.current_priority == 100
episode_priority = cls.__get_episode_priority(subscribe)
return all(episode_priority.get(str(episode)) == 100 for episode in target_episodes)
@classmethod
def is_best_version_complete(cls, subscribe: Subscribe) -> bool:
"""
对外暴露洗版完成判断。
"""
return cls.__is_best_version_complete(subscribe)
@classmethod
def __is_best_version_complete_with_priority(
cls,
subscribe: Subscribe,
episode_priority: Optional[dict] = None,
) -> bool:
"""
使用指定按集优先级状态判断洗版是否已完成。
"""
if not subscribe.best_version:
return False
if subscribe.type != MediaType.TV.value:
return subscribe.current_priority == 100
target_episodes = cls.__get_best_version_target_episodes(subscribe)
if not target_episodes:
return subscribe.current_priority == 100
return not cls.__get_pending_best_version_episodes_with_priority(subscribe, episode_priority)
@staticmethod
def __get_downloaded_episodes(downloads: Optional[List[Context]]) -> List[int]:
"""
获取本次下载实际涉及的剧集。
"""
if not downloads:
return []
downloaded_episodes = set()
for context in downloads:
selected_episodes = getattr(context, "selected_episodes", None)
if selected_episodes is None:
selected_episodes = context.meta_info.episode_list if context.meta_info else []
for episode in selected_episodes or []:
try:
downloaded_episodes.add(int(episode))
except (TypeError, ValueError):
continue
return sorted(downloaded_episodes)
@classmethod
def __get_best_version_completed_episodes(cls, subscribe: Subscribe) -> List[int]:
"""
获取已完成洗版的剧集。
"""
episode_priority = cls.__get_episode_priority(subscribe)
return sorted(
int(episode) for episode, priority in episode_priority.items()
if str(episode).isdigit() and priority == 100
)
@classmethod
def __get_best_version_interested_episodes(
cls,
subscribe: Subscribe,
context: Context,
priority: int,
) -> List[int]:
"""
获取当前资源中仍值得继续洗版的剧集。
"""
if subscribe.type != MediaType.TV.value:
return []
target_episodes = set(cls.__get_best_version_target_episodes(subscribe))
if not target_episodes:
return []
selected_episodes = getattr(context, "selected_episodes", None)
if selected_episodes is None:
selected_episodes = context.meta_info.episode_list if context.meta_info else []
if not selected_episodes:
episode_priority = cls.__get_episode_priority(subscribe)
return sorted([
episode for episode in target_episodes
if episode_priority.get(str(episode)) is None or priority > episode_priority.get(str(episode))
])
episode_priority = cls.__get_episode_priority(subscribe)
interested = []
for episode in selected_episodes:
try:
episode_num = int(episode)
except (TypeError, ValueError):
continue
if episode_num not in target_episodes:
continue
current_priority = episode_priority.get(str(episode_num))
if current_priority is None or priority > current_priority:
interested.append(episode_num)
return sorted(set(interested))
@staticmethod
def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
"""
@@ -627,9 +863,23 @@ class SubscribeChain(ChainBase):
f"{subscribe.name} 正在洗版,{torrent_info.title} 不符合订阅集数范围"
)
continue
# 洗版时,优先级小于等于已下载优先级的不要
if subscribe.current_priority \
and torrent_info.pri_order <= subscribe.current_priority:
# 洗版时,只保留至少能提升一集优先级的资源
if (
torrent_mediainfo.type == MediaType.TV
and not self.__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=torrent_info.pri_order,
)
):
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集')
continue
if (
torrent_mediainfo.type != MediaType.TV
and subscribe.current_priority
and torrent_info.pri_order <= subscribe.current_priority
):
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue
@@ -700,11 +950,58 @@ class SubscribeChain(ChainBase):
return
# 当前下载资源的优先级
priority = max([item.torrent_info.pri_order for item in downloads])
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if subscribe.type == MediaType.TV.value:
episode_priority = self.__get_episode_priority(subscribe)
updated = False
for download in downloads:
download_priority = download.torrent_info.pri_order
downloaded_episodes = self.__get_downloaded_episodes([download])
if not downloaded_episodes:
continue
for episode in downloaded_episodes:
episode_key = str(episode)
old_priority = episode_priority.get(episode_key)
if old_priority is None or download_priority > old_priority:
episode_priority[episode_key] = download_priority
updated = True
if not updated and not episode_priority:
return
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
lack_episode = self.get_best_version_lack_episode(subscribe, episode_priority)
update_data: Dict[str, Any] = {
"episode_priority": episode_priority,
"last_update": now,
"current_priority": current_priority,
"lack_episode": lack_episode,
}
SubscribeOper().update(subscribe.id, update_data)
subscribe.episode_priority = episode_priority
subscribe.current_priority = current_priority
subscribe.lack_episode = lack_episode
subscribe.last_update = now
completed_episodes = self.__get_best_version_completed_episodes(subscribe)
if self.__is_best_version_complete(subscribe):
logger.info(f'{mediainfo.title_year} 洗版完成,已完成剧集:{completed_episodes}')
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
else:
logger.info(
f'{mediainfo.title_year} 正在洗版,更新剧集优先级为 {priority},已完成剧集:{completed_episodes}'
)
return
# 订阅存在待定策略,不管是否已完成,均需更新订阅信息
SubscribeOper().update(subscribe.id, {
"current_priority": priority,
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
"last_update": now
})
subscribe.current_priority = priority
subscribe.last_update = now
if priority == 100:
# 洗版完成
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
@@ -742,7 +1039,7 @@ class SubscribeChain(ChainBase):
# 洗版下载到了内容,更新资源优先级
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, downloads=downloads)
elif subscribe.current_priority == 100:
elif self.__is_best_version_complete(subscribe):
# 洗版完成
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
else:
@@ -1081,8 +1378,22 @@ class SubscribeChain(ChainBase):
# 洗版时,优先级小于已下载优先级的不要
if subscribe.best_version:
if subscribe.current_priority \
and torrent_info.pri_order <= subscribe.current_priority:
if (
meta.type == MediaType.TV
and not self.__get_best_version_interested_episodes(
subscribe=subscribe,
context=_context,
priority=torrent_info.pri_order,
)
):
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集')
continue
if (
meta.type != MediaType.TV
and subscribe.current_priority
and torrent_info.pri_order <= subscribe.current_priority
):
logger.info(
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
continue
@@ -1163,16 +1474,31 @@ class SubscribeChain(ChainBase):
continue
# 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or []
current_priority = None
if not subscribe.manual_total_episode and len(episodes):
total_episode = len(episodes)
if subscribe.best_version and subscribe.type == MediaType.TV.value:
old_total_episode = subscribe.total_episode or 0
episode_priority = self.__get_episode_priority(subscribe)
for episode in range(old_total_episode + 1, total_episode + 1):
episode_priority.setdefault(str(episode), 0)
subscribe.total_episode = total_episode
subscribe.episode_priority = episode_priority
lack_episode = self.get_best_version_lack_episode(subscribe, episode_priority)
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
else:
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
logger.info(
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
else:
total_episode = subscribe.total_episode
if subscribe.best_version and subscribe.type == MediaType.TV.value:
lack_episode = self.get_best_version_lack_episode(subscribe)
current_priority = self.get_best_version_current_priority(subscribe)
else:
lack_episode = subscribe.lack_episode
# 更新TMDB信息
subscribeoper.update(subscribe.id, {
update_data = {
"name": mediainfo.title,
"year": mediainfo.year,
"vote": mediainfo.vote_average,
@@ -1183,7 +1509,15 @@ class SubscribeChain(ChainBase):
"tvdbid": mediainfo.tvdb_id,
"total_episode": total_episode,
"lack_episode": lack_episode
})
}
if subscribe.best_version and subscribe.type == MediaType.TV.value:
update_data["current_priority"] = current_priority
if not subscribe.manual_total_episode and len(episodes):
update_data["episode_priority"] = subscribe.episode_priority
subscribe.current_priority = current_priority
subscribe.total_episode = total_episode
subscribe.lack_episode = lack_episode
subscribeoper.update(subscribe.id, update_data)
logger.info(f'{subscribe.name} 订阅元数据更新完成')
def get_subscribe_by_source(self, source: str) -> Optional[Subscribe]:
@@ -1343,6 +1677,11 @@ class SubscribeChain(ChainBase):
获取已下载过的集数或电影
"""
if subscribe.best_version:
if subscribe.type == MediaType.TV.value:
completed = SubscribeChain.__get_best_version_completed_episodes(subscribe)
if completed:
logger.info(f'订阅 {subscribe.name}{subscribe.season}季 已完成洗版剧集:{completed}')
return completed
return []
note = subscribe.note or []
if not note:
@@ -1368,6 +1707,13 @@ class SubscribeChain(ChainBase):
update_data = {}
if update_date:
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if subscribe.best_version and subscribe.type == MediaType.TV.value:
lack_episode = len(SubscribeChain._get_pending_best_version_episodes(subscribe))
logger.info(f"{mediainfo.title_year}{subscribe.season} 剩余待洗剧集数为{lack_episode} ...")
update_data["lack_episode"] = lack_episode
if update_data:
SubscribeOper().update(subscribe.id, update_data)
return
if subscribe.type == MediaType.TV.value:
if not lefts:
# 如果 lefts 为空,表示没有缺失集数,直接设置 lack_episode 为 0
@@ -2475,18 +2821,19 @@ class SubscribeChain(ChainBase):
)
else:
# 洗版,如果已经满足了优先级,则认为已经洗版完成
if subscribe.current_priority == 100:
if self.__is_best_version_complete(subscribe):
exist_flag = True
no_exists = {}
else:
exist_flag = False
if meta.type == MediaType.TV:
pending_episodes = self._get_pending_best_version_episodes(subscribe)
# 对于电视剧,构造缺失的媒体信息
no_exists = {
mediakey: {
subscribe.season: schemas.NotExistMediaInfo(
season=subscribe.season,
episodes=[],
episodes=pending_episodes,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode or 1)
}
@@ -2559,22 +2906,21 @@ class SubscribeChain(ChainBase):
f"订阅 {subscribe.name}{subscribe.season}季 总集数更新为 {new_total_episode},缺失集数更新为 {new_lack_episode}"
)
@staticmethod
def _is_episode_range_covered(meta: MetaBase, subscribe: Subscribe) -> bool:
@classmethod
def _is_episode_range_covered(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
"""
判断种子是否包含指定订阅的剧集范围
判断种子是否覆盖当前仍需洗版的剧集范围
"""
episodes = meta.episode_list
if not episodes:
# 没有剧集信息,表示该种子为合集
return True
min_ep = min(episodes)
max_ep = max(episodes)
start_ep = subscribe.start_episode or 1
end_ep = subscribe.total_episode
pending_episodes = cls._get_pending_best_version_episodes(subscribe)
if not pending_episodes:
return True
return min_ep <= start_ep and max_ep >= end_ep
return bool(set(episodes).intersection(set(pending_episodes)))
@staticmethod
def __get_media_id_match_source(mediainfo: Optional[MediaInfo]) -> str:

View File

@@ -73,6 +73,8 @@ class Subscribe(Base):
best_version = Column(Integer, default=0)
# 当前优先级
current_priority = Column(Integer)
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
episode_priority = Column(JSON)
# 保存路径
save_path = Column(String)
# 是否使用 imdbid 搜索

View File

@@ -60,6 +60,8 @@ class SubscribeHistory(Base):
sites = Column(JSON)
# 是否洗版
best_version = Column(Integer, default=0)
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
episode_priority = Column(JSON)
# 保存路径
save_path = Column(String)
# 是否使用 imdbid 搜索

View File

@@ -61,6 +61,8 @@ class Subscribe(BaseModel):
best_version: Optional[int] = 0
# 当前优先级
current_priority: Optional[int] = None
# 洗版时已下载剧集的优先级状态
episode_priority: Optional[Dict[str, int]] = None
# 保存路径
save_path: Optional[str] = None
# 是否使用 imdbid 搜索

View File

@@ -0,0 +1,42 @@
"""2.2.6
为订阅洗版增加按集优先级状态
Revision ID: 9caa49cb3e10
Revises: b8f6e3a1c2d4
Create Date: 2026-05-12
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "9caa49cb3e10"
down_revision = "b8f6e3a1c2d4"
branch_labels = None
depends_on = None
def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "subscribe", "episode_priority") is False:
op.add_column("subscribe", sa.Column("episode_priority", sa.JSON(), nullable=True))
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "subscribehistory", "episode_priority") is False:
op.add_column("subscribehistory", sa.Column("episode_priority", sa.JSON(), nullable=True))
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "subscribehistory", "episode_priority"):
op.drop_column("subscribehistory", "episode_priority")
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "subscribe", "episode_priority"):
op.drop_column("subscribe", "episode_priority")

View File

@@ -1,175 +1,492 @@
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import patch
from app.chain.subscribe import SubscribeChain
from app.core.metainfo import MetaInfo
from app.schemas.types import 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
injected_modules = {}
def ensure_module(name: str, module: types.ModuleType):
if name in sys.modules:
return sys.modules[name]
sys.modules[name] = module
injected_modules[name] = module
return module
chain_module = ensure_module("app.chain", types.ModuleType("app.chain"))
class _ChainBase:
def __init__(self):
self.messagehelper = SimpleNamespace(put=lambda *args, **kwargs: None)
def post_message(self, *args, **kwargs):
return None
async def async_post_message(self, *args, **kwargs):
return None
chain_module.ChainBase = _ChainBase
interaction_module = ensure_module("app.helper.interaction", types.ModuleType("app.helper.interaction"))
class _SlashInteractionManager:
def create_or_replace(self, *args, **kwargs):
return SimpleNamespace(request_id="request-id")
def get_by_id(self, *args, **kwargs):
return None
def get_by_user(self, *args, **kwargs):
return None
def remove(self, *args, **kwargs):
return None
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.core.config", types.ModuleType("app.core.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.core.context", types.ModuleType("app.core.context"))
context_module.TorrentInfo = SimpleNamespace
context_module.Context = SimpleNamespace
context_module.MediaInfo = SimpleNamespace
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
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):
def decorator(func):
return func
return decorator
event_module.eventmanager = _EventManager()
event_module.Event = SimpleNamespace
meta_module = ensure_module("app.core.meta", types.ModuleType("app.core.meta"))
meta_module.MetaBase = SimpleNamespace
metainfo_module = ensure_module("app.core.metainfo", types.ModuleType("app.core.metainfo"))
metainfo_module.MetaInfo = lambda *args, **kwargs: SimpleNamespace(episode_list=[])
words_module = ensure_module("app.core.meta.words", types.ModuleType("app.core.meta.words"))
class _WordsMatcher:
def prepare(self, title, custom_words=None):
return title, []
words_module.WordsMatcher = _WordsMatcher
schemas_module = ensure_module("app.schemas", types.ModuleType("app.schemas"))
class _Notification:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
class _SubscribeSchema:
def __init__(self, **kwargs):
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):
self.season = season
self.episodes = episodes or []
self.total_episode = total_episode
self.start_episode = start_episode
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")
schemas_module.Notification = _Notification
schemas_module.Subscribe = _SubscribeSchema
schemas_module.NotExistMediaInfo = _NotExistMediaInfo
schemas_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo
schemas_module.SubscrbieInfo = _SubscrbieInfo
schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData
logger_module = ensure_module("app.log", types.ModuleType("app.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_subscribe_module = ensure_module("app.helper.subscribe", types.ModuleType("app.helper.subscribe"))
class _SubscribeHelper:
def sub_done_async(self, *args, **kwargs):
return None
@staticmethod
def get_shares():
return []
helper_subscribe_module.SubscribeHelper = _SubscribeHelper
helper_torrent_module = ensure_module("app.helper.torrent", types.ModuleType("app.helper.torrent"))
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):
for key, value in kwargs.items():
setattr(self, key, value)
def to_dict(self):
return dict(self.__dict__)
db_model_module.Subscribe = _SubscribeModel
subscribe_oper_module = ensure_module("app.db.subscribe_oper", types.ModuleType("app.db.subscribe_oper"))
class _SubscribeOper:
def update(self, *args, **kwargs):
return None
def get(self, *args, **kwargs):
return None
def list(self, *args, **kwargs):
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.downloadhistory_oper": "DownloadHistoryOper",
"app.db.site_oper": "SiteOper",
"app.db.systemconfig_oper": "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
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.media": "MediaChain",
"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, (), {}))
subscribe_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "subscribe.py"
spec = importlib.util.spec_from_file_location(module_name, subscribe_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
module._injected_modules = injected_modules
return module, module.SubscribeChain
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
class SubscribeChainTest(TestCase):
def test_is_episode_range_covered(self):
cases = [
{
"title": "Cherry Season S01 2014 2160p 60fps WEB-DL H265 AAC-XXX",
"subtitle": "",
"subscribe": {"start_episode": None, "total_episode": 51},
"expected": True,
},
{
"title": "【爪爪字幕组】★7月新番[欢迎来到实力至上主义的教室 第二季/Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2][11][1080p][HEVC][GB][MP4][招募翻译校对]",
"subtitle": "",
"subscribe": {"start_episode": None, "total_episode": 13},
"expected": False,
},
{
"title": "[秋叶原冥途战争][Akiba Maid Sensou][2022][WEB-DL][1080][TV Series][第01话][LeagueWEB]",
"subtitle": "",
"subscribe": {"start_episode": None, "total_episode": 12},
"expected": False,
},
{
"title": "Qi Refining for 3000 Years S01E06 2022 1080p B-Blobal WEB-DL X264 AAC-AnimeS@AdWeb",
"subtitle": "",
"subscribe": {"start_episode": None, "total_episode": 16},
"expected": False,
},
{
"title": "The Heart of Genius S01 13-14 2022 1080p WEB-DL H264 AAC",
"subtitle": "",
"subscribe": {"start_episode": None, "total_episode": 34},
"expected": False,
},
{
"title": "[xyx98]传颂之物/Utawarerumono/うたわれるもの[BDrip][1920x1080][TV 01-26 Fin][hevc-yuv420p10 flac_ac3][ENG PGS]",
"subtitle": "",
"subscribe": {"start_episode": None, "total_episode": 26},
"expected": True,
},
{
"title": "I Woke Up a Vampire S02 2023 2160p NF WEB-DL DDP5.1 Atmos H 265-HHWEB",
"subtitle": "醒来变成吸血鬼 第二季 | 全8集 | 4K | 类型: 喜剧/家庭/奇幻 | 导演: TommyLynch | 主演: NikoCeci/ZebastinBorjeau/安娜·阿劳约/KaileenAngelicChang/KrisSiddiqi",
"subscribe": {"start_episode": None, "total_episode": 8},
"expected": True,
},
{
"title": "Shadows of the Void S01 2024 1080p WEB-DL H264 AAC-HHWEB",
"subtitle": "虚无边境 | 第01-02集 | 1080p | 类型: 动画 | 导演: 巴西 | 主演: 山新/周一菡/皇贞季/Kenz/李佳怡 [内嵌中字]",
"subscribe": {"start_episode": None, "total_episode": 13},
"expected": False,
},
{
"title": "Mai Xiang S01 2019 2160p WEB-DL H.265 DDP2.0-HHWEB",
"subtitle": "麦香 | 全36集 | 4K | 类型:剧情/爱情/家庭 | 主演:傅晶/章呈赫/王伟/沙景昌/何音",
"subscribe": {"start_episode": None, "total_episode": 36},
"expected": True,
},
{
"title": "Jigokuraku S01E14-E25 2023 1080p CR WEB-DL x264 AAC-Nest@ADWeb",
"subtitle": "地狱乐 / 地獄楽 / Hells Paradise [14-25Fin] [中日双语字幕]",
"subscribe": {"start_episode": 14, "total_episode": 25},
"expected": True,
},
{
"title": "Jigokuraku S01 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE",
"subtitle": "地狱乐/Hell's Paradise: Jigokuraku [01-13Fin] [中日双语字幕]",
"subscribe": {"start_episode": None, "total_episode": 13},
"expected": True,
},
{
"title": "Jigokuraku S02E12 2026 1080p NF WEB-DL x264 AAC-ADWeb",
"subtitle": "地狱乐 第二季 地獄楽 第二期 第12集 | 类型: 动画",
"subscribe": {"start_episode": None, "total_episode": 12},
"expected": False,
},
{
"title": "Jigokuraku S02E05-E07 2026 1080p NF WEB-DL x264 AAC-ADWeb",
"subtitle": "地狱乐 第二季 地獄楽 第二期 第05-07集 | 类型: 动画",
"subscribe": {"start_episode": None, "total_episode": 12},
"expected": False,
},
{
"title": "Bungo Stray Dogs S01 2016 1080p KKTV WEB-DL x264 AAC-ADWeb",
"subtitle": "文豪野犬 文豪ストレイドッグス 又名: 文豪Stray Dogs 第一季 全12集 | 类型: 剧情 / 动作 / 动画 主演: 上村祐翔 / 宫野真守 / 细谷佳正 *内嵌繁体字幕*",
"subscribe": {"start_episode": None, "total_episode": 12},
"expected": True,
},
{
"title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws",
"subtitle": "文豪野犬 第1-3季",
"subscribe": {"start_episode": None, "total_episode": 36},
"expected": True,
},
{
"title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws",
"subtitle": "文豪野犬 第1-3季",
"subscribe": {"start_episode": None, "total_episode": 60},
"expected": True, # 识别不到集数全匹配
},
{
"title": "Fu Gui S01 2005 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "福贵 | 全33集 | 4K | 类型: 剧情/家庭 | 导演: 朱正/袁进 | 主演: 陈创/刘敏涛/李丁/张鹰/温玉娟",
"subscribe": {"start_episode": None, "total_episode": 33},
"expected": True,
},
{
"title": "The Story of Ming Lan S01 2018 2160p WEB-DL CHDWEB",
"subtitle": "知否知否应是绿肥红瘦 全78集 | 2160p | 国语/中字 | 60帧高码TV版 | 类型:剧情/爱情/古装 | 主演:赵丽颖/冯绍峰/朱一龙/施诗/张佳宁",
"subscribe": {"start_episode": None, "total_episode": 78},
"expected": True,
},
{
"title": "Love Beyond the Grave S01 2026 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "白日提灯 / 慕胥辞 | 第18集 | 4K | 类型: 剧情 | 导演: 秦榛 | 主演: 迪丽热巴/陈飞宇/魏哲鸣/张俪/高鹤元",
"subscribe": {"start_episode": None, "total_episode": 40},
"expected": False,
},
{
"title": "The Long Ballad S01 2021 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "长歌行 | 全49集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦",
"subscribe": {"start_episode": None, "total_episode": 49},
"expected": True,
},
{
"title": "The Long Ballad S01E01-E04 2021 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "长歌行 | 第01-04集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦",
"subscribe": {"start_episode": None, "total_episode": 49},
"expected": False,
},
{
"title": "Spy x Family S02 2023 1080p Baha WEB-DL x264 AAC-ADWeb",
"subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 [01-12Fin] [简繁内封字幕]",
"subscribe": {"start_episode": None, "total_episode": 12},
"expected": True,
},
{
"title": "Spy x Family S02E03-E07 2023 1080p Baha WEB-DL x264 AAC-ADWeb",
"subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 第03-07集 [简繁内封字幕]",
"subscribe": {"start_episode": None, "total_episode": 12},
"expected": False,
},
{
"title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb",
"subtitle": "火影忍者 疾风传 全500集 [1080p][简中字幕]",
"subscribe": {"start_episode": None, "total_episode": 500},
"expected": True,
},
{
"title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb",
"subtitle": "火影忍者 疾风传 第01-500集 [1080p][简中字幕]",
"subscribe": {"start_episode": 201, "total_episode": 500},
"expected": True,
},
]
def _build_subscribe(self, **overrides):
data = {
"id": 1,
"name": "Test Show",
"season": 1,
"best_version": 1,
"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,
"tmdbid": 1,
"doubanid": None,
"year": "2026",
"imdbid": None,
"tvdbid": None,
"episode_group": None,
"poster": None,
"backdrop": None,
"description": None,
"last_update": None,
"username": None,
"to_dict": lambda: {},
}
data.update(overrides)
return SimpleNamespace(**data)
for case in cases:
meta = MetaInfo(
title=case["title"], subtitle=case["subtitle"], custom_words=["#"]
@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(episode_list=meta_episodes or []),
)
subscribe = SimpleNamespace(**case["subscribe"])
def test_get_episode_priority_falls_back_to_current_priority(self):
subscribe = self._build_subscribe(current_priority=80, episode_priority=None)
self.assertEqual(
SubscribeChain._is_episode_range_covered(
meta=meta,
subscribe=subscribe,
),
case["expected"],
SubscribeChain.get_episode_priority(subscribe),
{"1": 80, "2": 80, "3": 80},
)
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},
)
self.assertEqual(
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, "3": 90, "4": 100, "5": 70},
current_priority=100,
)
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 3)
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 90)
self.assertFalse(SubscribeChain.is_best_version_complete(subscribe))
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,
)
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 0)
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 100)
self.assertTrue(SubscribeChain.is_best_version_complete(subscribe))
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)},
},
)
self.assertTrue(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[5, 6, 7]),
subscribe=subscribe,
)
)
self.assertFalse(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[1, 2, 3, 4]),
subscribe=subscribe,
)
)
self.assertTrue(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(episode_list=[]),
subscribe=subscribe,
)
)
def test_update_subscribe_priority_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()
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
) as finish_mock:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.update.return_value = None
chain.update_subscribe_priority(
subscribe=subscribe,
meta=SimpleNamespace(),
mediainfo=mediainfo,
downloads=[download],
)
subscribe_oper.update.assert_called_once()
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 80, "3": 90, "4": 60})
self.assertEqual(payload["current_priority"], 90)
self.assertEqual(payload["lack_episode"], 3)
self.assertEqual(subscribe.episode_priority, {"1": 100, "2": 80, "3": 90, "4": 60})
self.assertEqual(subscribe.current_priority, 90)
self.assertEqual(subscribe.lack_episode, 3)
finish_mock.assert_not_called()
def test_update_subscribe_priority_marks_complete_when_all_target_episodes_done(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()
meta = SimpleNamespace()
mediainfo = SimpleNamespace(title_year="Test Show (2026)")
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
) as finish_mock:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.update.return_value = None
chain.update_subscribe_priority(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
downloads=downloads,
)
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
self.assertEqual(payload["current_priority"], 100)
self.assertEqual(payload["lack_episode"], 0)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_check_resets_current_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()
chain.recognize_media = lambda **kwargs: 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,
get_poster_image=lambda: "poster",
get_backdrop_image=lambda: "backdrop",
)
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.list.return_value = [subscribe]
subscribe_oper.update.return_value = None
chain.check()
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["total_episode"], 5)
self.assertEqual(payload["lack_episode"], 2)
self.assertEqual(payload["current_priority"], 0)
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100, "4": 0, "5": 0})
self.assertEqual(subscribe.total_episode, 5)
self.assertEqual(subscribe.lack_episode, 2)
self.assertEqual(subscribe.current_priority, 0)