mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-13 07:26:45 +00:00
feat(subscribe): add episode priority tracking for subscription updates
This commit is contained in:
@@ -33,6 +33,8 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
||||
"sites",
|
||||
"downloader",
|
||||
"best_version",
|
||||
"current_priority",
|
||||
"episode_priority",
|
||||
"save_path",
|
||||
"custom_words",
|
||||
"media_category",
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
# 重新获取更新后的订阅数据
|
||||
|
||||
@@ -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:
|
||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 搜索
|
||||
|
||||
@@ -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 搜索
|
||||
|
||||
@@ -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 搜索
|
||||
|
||||
42
database/versions/9caa49cb3e10_2_2_6.py
Normal file
42
database/versions/9caa49cb3e10_2_2_6.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user