From 2c45831714da6b819faf376fdc93abdca5bc9421 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 12 May 2026 17:22:50 +0800 Subject: [PATCH] feat(subscribe): add episode priority tracking for subscription updates --- app/agent/tools/impl/query_subscribes.py | 2 + app/api/endpoints/subscribe.py | 4 + app/chain/download.py | 6 + app/chain/search.py | 67 +-- app/chain/subscribe.py | 390 +++++++++++++- app/db/models/subscribe.py | 2 + app/db/models/subscribehistory.py | 2 + app/schemas/subscribe.py | 2 + database/versions/9caa49cb3e10_2_2_6.py | 42 ++ tests/test_subscribe_chain.py | 645 +++++++++++++++++------ 10 files changed, 943 insertions(+), 219 deletions(-) create mode 100644 database/versions/9caa49cb3e10_2_2_6.py diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py index bb36a68c..41f65034 100644 --- a/app/agent/tools/impl/query_subscribes.py +++ b/app/agent/tools/impl/query_subscribes.py @@ -33,6 +33,8 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [ "sites", "downloader", "best_version", + "current_priority", + "episode_priority", "save_path", "custom_words", "media_category", diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index ed347091..14807b1b 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -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" }) # 重新获取更新后的订阅数据 diff --git a/app/chain/download.py b/app/chain/download.py index b718d1a5..0fe13395 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -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: # 下载种子文件,得到的可能是文件也可能是磁力链 diff --git a/app/chain/search.py b/app/chain/search.py index b880fdef..fcf01059 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -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 @@ -50,7 +50,7 @@ class SearchChain(ChainBase): @staticmethod def _calculate_recommend_request_hash( - filtered_indices: Optional[List[int]], search_results_count: int + filtered_indices: Optional[List[int]], search_results_count: int ) -> str: """ 计算当前推荐请求哈希,用于识别筛选条件是否变化。 @@ -94,7 +94,7 @@ class SearchChain(ChainBase): return self._build_ai_recommend_status() def get_recommend_status( - self, filtered_indices: Optional[List[int]], search_results_count: int + self, filtered_indices: Optional[List[int]], search_results_count: int ) -> Dict[str, Any]: """ 获取AI推荐状态,并在筛选条件变化时返回 idle。 @@ -141,7 +141,7 @@ class SearchChain(ChainBase): @staticmethod def _extract_recommend_items( - filtered_indices: Optional[List[int]], results: List[Any] + filtered_indices: Optional[List[int]], results: List[Any] ) -> tuple[List[str], List[int]]: """ 构建发送给模型的候选列表和索引映射。 @@ -180,10 +180,10 @@ class SearchChain(ChainBase): @staticmethod def _restore_original_indices( - ai_indices: List[int], - filtered_indices: Optional[List[int]], - valid_indices: List[int], - results_count: int, + ai_indices: List[int], + filtered_indices: Optional[List[int]], + valid_indices: List[int], + results_count: int, ) -> List[int]: """ 将模型输出的局部索引映射回原始搜索结果索引。 @@ -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: """ 通过统一后台提示词机制执行资源推荐。 """ @@ -233,10 +234,10 @@ class SearchChain(ChainBase): return full_output[0].strip() def start_recommend_task( - self, - filtered_indices: Optional[List[int]], - search_results_count: int, - results: List[Any], + self, + filtered_indices: Optional[List[int]], + search_results_count: int, + results: List[Any], ) -> None: """ 启动AI推荐任务。 @@ -274,8 +275,8 @@ class SearchChain(ChainBase): return user_preference = ( - settings.AI_RECOMMEND_USER_PREFERENCE - or "Prefer high-quality resources with more seeders" + settings.AI_RECOMMEND_USER_PREFERENCE + or "Prefer high-quality resources with more seeders" ) search_results_text = ( f"User Preference: {user_preference}\n\n" @@ -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, - keyword=mediainfo.imdb_id if mediainfo else None, - mtype=mediainfo.type if mediainfo else None, - page=page) + 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, - keyword=keyword, - mtype=mediainfo.type if mediainfo else None, - page=page) - return site, result or [] + site_result = await self.async_search_torrents(site=site, + keyword=keyword, + mtype=mediainfo.type if mediainfo else None, + page=page) + return site, site_result or [] tasks = [asyncio.create_task(search_site(site)) for site in indexer_sites] results_count = 0 diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index b6adc801..a1c56dd4 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -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) - lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode) + 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 - lack_episode = subscribe.lack_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: diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index b8305624..eedabe01 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -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 搜索 diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index 698c516e..94b536b9 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -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 搜索 diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 35c5a39c..b91fd529 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -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 搜索 diff --git a/database/versions/9caa49cb3e10_2_2_6.py b/database/versions/9caa49cb3e10_2_2_6.py new file mode 100644 index 00000000..13b94090 --- /dev/null +++ b/database/versions/9caa49cb3e10_2_2_6.py @@ -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") diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 11618566..b707a296 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -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": "地狱乐 / 地獄楽 / Hell’s 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) + + @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 []), + ) + + def test_get_episode_priority_falls_back_to_current_priority(self): + subscribe = self._build_subscribe(current_priority=80, episode_priority=None) + + self.assertEqual( + 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)") - for case in cases: - meta = MetaInfo( - title=case["title"], subtitle=case["subtitle"], custom_words=["#"] - ) - subscribe = SimpleNamespace(**case["subscribe"]) + 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 - self.assertEqual( - SubscribeChain._is_episode_range_covered( - meta=meta, - subscribe=subscribe, - ), - case["expected"], + 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)