mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: extract subscription priority rules into application layer
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
"""订阅洗版优先级与缺失集计算的领域规则。
|
||||
|
||||
本模块是订阅按集事实(note / episode_priority)、洗版准入基线
|
||||
(current_priority)和目标范围推导的唯一算法来源;链层只保留薄委托,
|
||||
编排逻辑不得内联复制这些规则。所有函数均为纯计算:不读全局配置、
|
||||
不做 I/O,仅操作传入的订阅快照、上下文与元数据对象。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast
|
||||
|
||||
from app.domain.context import Context
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.schemas.mediaserver import NotExistMediaInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.subscribe import Subscribe
|
||||
|
||||
from app.application.subscription.contract import subscribe_media_keys
|
||||
|
||||
|
||||
def normalize_episode_priority(episode_priority: Optional[Dict[Any, Any]]) -> 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
|
||||
|
||||
|
||||
def is_full_best_version_enabled(subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
判断当前订阅是否启用了电视剧全集洗版。
|
||||
"""
|
||||
return (
|
||||
bool(subscribe.best_version_full)
|
||||
and bool(subscribe.best_version)
|
||||
and subscribe.type == MediaType.TV.value
|
||||
)
|
||||
|
||||
|
||||
def get_episode_priority(subscribe: Subscribe,
|
||||
total_episode: Optional[int] = None) -> Dict[str, int]:
|
||||
"""
|
||||
获取订阅按集洗版优先级状态。
|
||||
"""
|
||||
episode_priority = normalize_episode_priority(subscribe.episode_priority)
|
||||
if episode_priority:
|
||||
return episode_priority
|
||||
|
||||
if (
|
||||
subscribe.best_version
|
||||
and not is_full_best_version_enabled(subscribe)
|
||||
and subscribe.type == MediaType.TV.value
|
||||
and subscribe.current_priority is not None
|
||||
):
|
||||
target_episodes = get_best_version_target_episodes(subscribe, total_episode=total_episode)
|
||||
return {
|
||||
str(episode): int(subscribe.current_priority)
|
||||
for episode in target_episodes
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def get_best_version_target_episodes(subscribe: Subscribe,
|
||||
total_episode: Optional[int] = None) -> List[int]:
|
||||
"""
|
||||
获取洗版订阅目标剧集范围。
|
||||
"""
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return []
|
||||
|
||||
start_episode = subscribe.start_episode or 1
|
||||
total_episode = total_episode or subscribe.total_episode or 0
|
||||
if total_episode < start_episode:
|
||||
return []
|
||||
return list(range(start_episode, total_episode + 1))
|
||||
|
||||
|
||||
def get_downloaded_best_version_episodes(subscribe: Subscribe,
|
||||
total_episode: Optional[int] = None) -> List[int]:
|
||||
"""
|
||||
获取洗版订阅目标范围内已下载到任意版本的剧集。
|
||||
|
||||
分集洗版的完成态要求 priority==100,但订阅目标满足查询有时只需要确认
|
||||
目标集是否已下载过任意版本,因此这里按 note 与 episode_priority>0 统计。
|
||||
"""
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return []
|
||||
|
||||
start_episode = subscribe.start_episode or 1
|
||||
total_episode = total_episode or subscribe.total_episode or 0
|
||||
if total_episode < start_episode:
|
||||
return []
|
||||
target_episodes = set(range(start_episode, total_episode + 1))
|
||||
downloaded = set()
|
||||
for episode in subscribe.note or []:
|
||||
try:
|
||||
episode_number = int(episode)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if episode_number in target_episodes:
|
||||
downloaded.add(episode_number)
|
||||
for episode_key, priority in get_episode_priority(subscribe, total_episode=total_episode).items():
|
||||
if not str(episode_key).isdigit():
|
||||
continue
|
||||
try:
|
||||
if float(priority) > 0:
|
||||
episode_number = int(episode_key)
|
||||
if episode_number in target_episodes:
|
||||
downloaded.add(episode_number)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(downloaded)
|
||||
|
||||
|
||||
def get_pending_best_version_episodes_with_priority(
|
||||
subscribe: Subscribe,
|
||||
episode_priority: Optional[Dict[Any, Any]] = None,
|
||||
total_episode: Optional[int] = None,
|
||||
) -> List[int]:
|
||||
"""
|
||||
使用指定按集优先级状态获取当前仍需继续洗版的剧集。
|
||||
"""
|
||||
target_episodes = get_best_version_target_episodes(subscribe, total_episode=total_episode)
|
||||
if not target_episodes:
|
||||
return []
|
||||
|
||||
if episode_priority is None:
|
||||
normalized = get_episode_priority(subscribe, total_episode=total_episode)
|
||||
else:
|
||||
normalized = normalize_episode_priority(episode_priority)
|
||||
return [episode for episode in target_episodes if normalized.get(str(episode)) != 100]
|
||||
|
||||
|
||||
def get_pending_best_version_episodes(subscribe: Subscribe,
|
||||
total_episode: Optional[int] = None) -> List[int]:
|
||||
"""
|
||||
获取当前仍需继续洗版的剧集。
|
||||
"""
|
||||
return get_pending_best_version_episodes_with_priority(subscribe, total_episode=total_episode)
|
||||
|
||||
|
||||
def compute_lack_episode(
|
||||
subscribe: Subscribe,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
计算订阅范围内尚未下载到任何版本的集数。
|
||||
|
||||
普通电视剧订阅以媒体库缺失结果为准;调用方没有缺失结果时按空缺失处理,
|
||||
避免入口级刷新失败把未知状态写成异常。洗版电视剧订阅按 note 与
|
||||
episode_priority>0 判断是否已有任意版本落点,priority<100 仍表示已下载过任意版本。
|
||||
"""
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return 0
|
||||
|
||||
if not subscribe.best_version:
|
||||
no_exists = no_exists or {}
|
||||
left_seasons = next(
|
||||
(
|
||||
no_exists.get(media_key)
|
||||
for media_key in subscribe_media_keys(subscribe)
|
||||
if no_exists.get(media_key) is not None
|
||||
),
|
||||
{},
|
||||
) or {}
|
||||
for season_info in left_seasons.values():
|
||||
if season_info.season != subscribe.season:
|
||||
continue
|
||||
left_episodes = season_info.episodes
|
||||
if not left_episodes:
|
||||
return season_info.total_episode or 0
|
||||
return len(left_episodes)
|
||||
return 0
|
||||
|
||||
total_episode = subscribe.total_episode or 0
|
||||
if not total_episode:
|
||||
return 0
|
||||
start_episode = subscribe.start_episode or 1
|
||||
if total_episode < start_episode:
|
||||
return 0
|
||||
|
||||
target_episodes = set(range(start_episode, total_episode + 1))
|
||||
downloaded: Set[int] = set()
|
||||
for episode in subscribe.note or []:
|
||||
try:
|
||||
episode_number = int(episode)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if episode_number in target_episodes:
|
||||
downloaded.add(episode_number)
|
||||
for episode_key, priority in get_episode_priority(subscribe).items():
|
||||
try:
|
||||
if float(priority) <= 0:
|
||||
continue
|
||||
episode_number = int(episode_key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if episode_number in target_episodes:
|
||||
downloaded.add(episode_number)
|
||||
return len(target_episodes - downloaded)
|
||||
|
||||
|
||||
def get_best_version_current_priority(
|
||||
subscribe: Subscribe,
|
||||
episode_priority: Optional[Dict[Any, Any]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
获取洗版订阅当前优先级状态。
|
||||
"""
|
||||
if not subscribe.best_version or subscribe.type != MediaType.TV.value:
|
||||
return subscribe.current_priority or 0
|
||||
if is_full_best_version_enabled(subscribe):
|
||||
return subscribe.current_priority or 0
|
||||
|
||||
target_episodes = get_best_version_target_episodes(subscribe)
|
||||
if not target_episodes:
|
||||
return subscribe.current_priority or 0
|
||||
|
||||
if episode_priority is None:
|
||||
normalized = get_episode_priority(subscribe)
|
||||
else:
|
||||
normalized = normalize_episode_priority(episode_priority)
|
||||
return min(
|
||||
(normalized.get(str(episode), 0) for episode in target_episodes),
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
def prepare_subscribe_progress_fields(
|
||||
subscribe: Subscribe,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None,
|
||||
touch_last_update: Optional[bool] = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
准备订阅进度持久化字段。
|
||||
|
||||
该方法只返回待写字段,不主动写库。普通电视剧的 no_exists 为空时表示当前缺失结果为空;
|
||||
洗版电视剧按 note 与 episode_priority 计算未下载过任何版本的目标集数量。
|
||||
"""
|
||||
update_data: Dict[str, Any] = {}
|
||||
if subscribe.type == MediaType.TV.value:
|
||||
if no_exists is None and not subscribe.best_version:
|
||||
no_exists = {}
|
||||
update_data["lack_episode"] = compute_lack_episode(subscribe, no_exists=no_exists)
|
||||
if subscribe.best_version and not is_full_best_version_enabled(subscribe):
|
||||
update_data["current_priority"] = get_best_version_current_priority(subscribe)
|
||||
if update_data and touch_last_update:
|
||||
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
return update_data
|
||||
|
||||
|
||||
def prepare_best_version_total_expansion_fields(
|
||||
subscribe: Subscribe,
|
||||
total_episode: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
准备洗版电视剧总集数扩展后需要写库的字段。
|
||||
|
||||
该方法会同步传入对象上的 total_episode / episode_priority,方便同一链路后续
|
||||
按最终快照继续计算进度;实际数据库写入由调用方统一执行。
|
||||
"""
|
||||
update_data: Dict[str, Any] = {"total_episode": total_episode}
|
||||
old_total_episode = subscribe.total_episode or 0
|
||||
subscribe.total_episode = total_episode
|
||||
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
episode_priority = get_episode_priority(
|
||||
subscribe,
|
||||
total_episode=old_total_episode,
|
||||
)
|
||||
if (
|
||||
not is_full_best_version_enabled(subscribe)
|
||||
and not episode_priority
|
||||
and subscribe.current_priority is not None
|
||||
):
|
||||
episode_priority = {
|
||||
str(episode): int(subscribe.current_priority)
|
||||
for episode in get_best_version_target_episodes(
|
||||
subscribe,
|
||||
total_episode=old_total_episode,
|
||||
)
|
||||
}
|
||||
subscribe.episode_priority = episode_priority
|
||||
update_data["episode_priority"] = episode_priority
|
||||
if is_full_best_version_enabled(subscribe):
|
||||
subscribe.current_priority = 0
|
||||
update_data["current_priority"] = 0
|
||||
|
||||
update_data.update(prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
|
||||
return update_data
|
||||
|
||||
|
||||
def prepare_best_version_total_change_fields(
|
||||
subscribe: Subscribe,
|
||||
total_episode: int,
|
||||
old_total_episode: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
准备洗版电视剧总集数变化后需要写库的字段。
|
||||
|
||||
总集数变化会改变目标范围,按集优先级只保留新范围内的目标集,避免范围外
|
||||
旧状态继续参与完成集、缺失集和当前优先级计算。
|
||||
"""
|
||||
update_data: Dict[str, Any] = {"total_episode": total_episode}
|
||||
target_episodes = set(get_best_version_target_episodes(
|
||||
subscribe,
|
||||
total_episode=total_episode,
|
||||
))
|
||||
episode_priority = get_episode_priority(
|
||||
subscribe,
|
||||
total_episode=old_total_episode,
|
||||
)
|
||||
filtered_priority = {
|
||||
str(episode): priority
|
||||
for episode, priority in episode_priority.items()
|
||||
if int(episode) in target_episodes
|
||||
}
|
||||
subscribe.total_episode = total_episode
|
||||
subscribe.episode_priority = filtered_priority
|
||||
if is_full_best_version_enabled(subscribe):
|
||||
current_priority = 0 if total_episode > old_total_episode else subscribe.current_priority
|
||||
else:
|
||||
current_priority = 0 if not target_episodes else get_best_version_current_priority(
|
||||
subscribe,
|
||||
episode_priority=filtered_priority,
|
||||
)
|
||||
subscribe.current_priority = current_priority
|
||||
update_data["episode_priority"] = filtered_priority
|
||||
update_data["current_priority"] = current_priority
|
||||
update_data.update(prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
|
||||
return update_data
|
||||
|
||||
|
||||
def prepare_total_episode_change_fields(
|
||||
subscribe: Subscribe,
|
||||
total_episode: int,
|
||||
old_total_episode: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
准备已有订阅总集数持久化字段,并同步内存对象上的总集数快照。
|
||||
"""
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
return prepare_best_version_total_change_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=total_episode,
|
||||
old_total_episode=old_total_episode,
|
||||
)
|
||||
|
||||
subscribe.total_episode = total_episode
|
||||
return {
|
||||
"total_episode": total_episode,
|
||||
"lack_episode": max(
|
||||
(subscribe.lack_episode or 0) + (total_episode - old_total_episode),
|
||||
0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def is_best_version_complete(subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
判断洗版订阅是否已完成。
|
||||
"""
|
||||
if not subscribe.best_version:
|
||||
return False
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return bool(subscribe.current_priority == 100)
|
||||
if is_full_best_version_enabled(subscribe):
|
||||
return bool(subscribe.current_priority == 100)
|
||||
|
||||
target_episodes = get_best_version_target_episodes(subscribe)
|
||||
if not target_episodes:
|
||||
return bool(subscribe.current_priority == 100)
|
||||
|
||||
episode_priority = get_episode_priority(subscribe)
|
||||
return all(episode_priority.get(str(episode)) == 100 for episode in target_episodes)
|
||||
|
||||
|
||||
def is_best_version_complete_with_priority(
|
||||
subscribe: Subscribe,
|
||||
episode_priority: Optional[Dict[Any, Any]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
使用指定按集优先级状态判断洗版是否已完成。
|
||||
"""
|
||||
if not subscribe.best_version:
|
||||
return False
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return bool(subscribe.current_priority == 100)
|
||||
if is_full_best_version_enabled(subscribe):
|
||||
return bool(subscribe.current_priority == 100)
|
||||
|
||||
target_episodes = get_best_version_target_episodes(subscribe)
|
||||
if not target_episodes:
|
||||
return bool(subscribe.current_priority == 100)
|
||||
|
||||
return not get_pending_best_version_episodes_with_priority(subscribe, episode_priority)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def get_best_version_completed_episodes(subscribe: Subscribe) -> List[int]:
|
||||
"""
|
||||
获取已完成洗版的剧集。
|
||||
"""
|
||||
episode_priority = get_episode_priority(subscribe)
|
||||
target_episodes = set(get_best_version_target_episodes(subscribe))
|
||||
return sorted(
|
||||
int(episode) for episode, priority in episode_priority.items()
|
||||
if str(episode).isdigit() and int(episode) in target_episodes and priority == 100
|
||||
)
|
||||
|
||||
|
||||
def get_best_version_interested_episodes(
|
||||
subscribe: Subscribe,
|
||||
context: Context,
|
||||
priority: int,
|
||||
) -> List[int]:
|
||||
"""
|
||||
获取当前资源中仍值得继续洗版的剧集。
|
||||
"""
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return []
|
||||
|
||||
target_episodes = set(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 = get_episode_priority(subscribe)
|
||||
return sorted([
|
||||
episode for episode in target_episodes
|
||||
if (known_priority := episode_priority.get(str(episode))) is None or priority > known_priority
|
||||
])
|
||||
|
||||
episode_priority = 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))
|
||||
|
||||
|
||||
def prepare_best_version_tv_candidate(
|
||||
subscribe: Subscribe,
|
||||
context: Context,
|
||||
priority: int,
|
||||
) -> bool:
|
||||
"""
|
||||
校验电视剧洗版候选,并为分集模式设置允许下载的剧集范围。
|
||||
|
||||
全集模式按当前准入基线筛选;分集模式设置能严格提升质量的目标集范围。
|
||||
"""
|
||||
if is_full_best_version_enabled(subscribe):
|
||||
try:
|
||||
return int(priority or 0) > int(subscribe.current_priority or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
interested_episodes = get_best_version_interested_episodes(
|
||||
subscribe=subscribe,
|
||||
context=context,
|
||||
priority=priority,
|
||||
)
|
||||
if not interested_episodes:
|
||||
return False
|
||||
context.allowed_episodes = set(interested_episodes)
|
||||
return True
|
||||
|
||||
|
||||
def is_full_season_resource(meta: MetaBase, subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
判断候选资源是否覆盖订阅目标全集范围。
|
||||
"""
|
||||
season_list = meta.season_list or [1]
|
||||
if len(season_list) != 1:
|
||||
return False
|
||||
if subscribe.season is not None and season_list[0] != subscribe.season:
|
||||
return False
|
||||
|
||||
episodes = meta.episode_list
|
||||
if not episodes:
|
||||
# 资源未标出单集时按整季包处理,后续下载前仍会解析种子文件确认完整性。
|
||||
return True
|
||||
|
||||
target_episodes = set(get_best_version_target_episodes(subscribe))
|
||||
if not target_episodes:
|
||||
return False
|
||||
return target_episodes.issubset(set(episodes))
|
||||
|
||||
|
||||
def is_full_season_best_version_resource(meta: MetaBase, subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
判断候选资源是否符合全集洗版资源约束。
|
||||
"""
|
||||
if not is_full_best_version_enabled(subscribe):
|
||||
return True
|
||||
|
||||
return is_full_season_resource(meta=meta, subscribe=subscribe)
|
||||
|
||||
|
||||
def should_prefer_full_pack_for_episode_best_version(
|
||||
subscribe: Subscribe,
|
||||
priority: int,
|
||||
) -> bool:
|
||||
"""
|
||||
判断分集洗版是否应优先下载整包。
|
||||
|
||||
整包优先级必须严格高于每个目标集;否则交回按集路径,只下载能提升质量的集。
|
||||
"""
|
||||
if (
|
||||
subscribe.type != MediaType.TV.value
|
||||
or is_full_best_version_enabled(subscribe)
|
||||
):
|
||||
return False
|
||||
|
||||
target_episodes = get_best_version_target_episodes(subscribe)
|
||||
if not target_episodes:
|
||||
return False
|
||||
|
||||
try:
|
||||
resource_priority = int(priority or 0)
|
||||
except (TypeError, ValueError):
|
||||
resource_priority = 0
|
||||
|
||||
episode_priority = get_episode_priority(subscribe)
|
||||
return all(
|
||||
resource_priority > episode_priority.get(str(episode), 0)
|
||||
for episode in target_episodes
|
||||
)
|
||||
|
||||
|
||||
def build_full_pack_first_no_exists(
|
||||
subscribe: Subscribe,
|
||||
mediakey: Union[int, str],
|
||||
) -> Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
构造分集洗版优先全集时使用的整季缺失范围。
|
||||
"""
|
||||
if (
|
||||
not subscribe.best_version
|
||||
or is_full_best_version_enabled(subscribe)
|
||||
or subscribe.type != MediaType.TV.value
|
||||
):
|
||||
return None
|
||||
|
||||
target_episodes = get_best_version_target_episodes(subscribe)
|
||||
if not target_episodes:
|
||||
return None
|
||||
|
||||
season_map: Dict[Optional[int], NotExistMediaInfo] = {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1,
|
||||
require_complete_coverage=True,
|
||||
)
|
||||
}
|
||||
return {mediakey: cast(Dict[int, NotExistMediaInfo], season_map)}
|
||||
Reference in New Issue
Block a user