Files
MoviePilot/app/chain/subscribe/refresh.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

799 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅元数据、进度与剧集范围刷新
"""
from dataclasses import replace
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
from app.application.subscription import priority as _priority
from app.application.subscription.activity import activity_time, subscription_activity_scope
from app.application.subscription.contract import (
SubscriptionSnapshot,
build_subscribe_meta,
subscribe_media_key,
subscribe_media_keys,
)
from app.application.subscription.facts import FreshFactLease
from app.application.subscription.observability import subscription_log_scope
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.subscribe.identity import subscribe_recognize_kwargs
from app.chain.subscribe.metadata import SubscribeMetadataOwner
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.domain.context import (
MediaInfo,
)
from app.domain.meta.metabase import MetaBase
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.schemas.event import SubscribeEpisodesRefreshEventData
from app.schemas.mediaserver import NotExistMediaInfo as _SchemaNotExistMediaInfo
from app.schemas.types import (
ChainEventType,
MediaSource,
MediaType,
)
class SubscribeRefreshOwner(SubscribeMetadataOwner):
"""
订阅元数据、进度与剧集范围刷新,作为 SubscribeChain 的单一职责组件。
"""
def refresh(
self,
progress_callback: Optional[Callable[..., None]] = None,
*,
mtype: Optional[str] = None,
source: str = "automatic",
) -> None:
"""
刷新指定媒体类型的订阅
:param progress_callback: 定时服务进度更新回调
:param mtype: 可选媒体类型,省略时刷新所有类型
:param source: 手工入口传 manual,定时和兼容调用默认 automatic
"""
# 触发刷新站点资源,从缓存中匹配订阅
sites = self.get_subscribed_sites(mtype)
if sites is None:
if progress_callback:
progress_callback(value=100, text="没有订阅需要刷新")
return
def _update_refresh_progress(
value: Optional[float] = None,
text: Optional[str] = None,
data: Optional[dict[str, Any]] = None,
) -> None:
"""
将站点刷新进度映射到订阅刷新的前半阶段。
"""
if progress_callback:
progress_callback(
value=(value or 0) * 0.6,
text=text,
data=data,
)
def _update_match_progress(
value: Optional[float] = None,
text: Optional[str] = None,
data: Optional[dict[str, Any]] = None,
) -> None:
"""
将订阅匹配进度映射到订阅刷新的后半阶段。
"""
if progress_callback:
progress_callback(
value=60 + (value or 0) * 0.4,
text=text,
data=data,
)
site_facts: list[dict[str, object]] = []
torrents_chain = TorrentsChain()
started_at = activity_time()
try:
candidates = torrents_chain.refresh(
sites=sites,
progress_callback=_update_refresh_progress if progress_callback else None,
# 存在音乐订阅时额外抓取站点音乐专用入口,音乐不一定在默认种子首页
include_music=self.has_music_subscribe(mtype),
site_observer=site_facts.append,
)
except Exception:
try:
self._record_refresh_failure(site_facts, started_at, mtype, source)
except Exception:
logger.error("候选刷新失败活动保存失败", exc_info=True)
raise
self.match(
candidates,
progress_callback=_update_match_progress if progress_callback else None,
mtype=mtype,
site_facts=tuple(site_facts),
source=source,
)
if progress_callback:
progress_callback(value=100, text="订阅刷新完成")
def _record_refresh_failure(self, site_facts: list[dict[str, object]], started_at: str,
mtype: Optional[str], source: str) -> None:
"""
上游候选刷新失败时记录未进入匹配的事实,不伪造资源判断或下载结果。
"""
for subscribe in self.subscription_repository.list():
if subscribe.state not in {"R", "P"} or (mtype and subscribe.type != mtype):
continue
with subscription_activity_scope(getattr(self, "subscription_activity", None), subscribe, "refresh", source) as activity:
activity.started_at = started_at
activity.status = "failed"
activity.details["phase"] = "refresh_failed"
activity.details["reason"] = "本轮站点候选刷新失败,尚未进入订阅匹配;请查看运行日志了解原因"
activity.details["sites"] = cast(Any, list(site_facts))
activity.counts["sites"] = len(site_facts)
def check(
self,
progress_callback: Optional[Callable[..., None]] = None,
reconcile_completion: bool = False,
*,
mtype: Optional[str] = None,
) -> None:
"""
定时检查订阅,更新订阅信息
:param progress_callback: 定时服务进度更新回调
:param reconcile_completion: 是否复用本次新鲜媒体事实执行独立完成对账
:param mtype: 可选媒体类型,省略时检查所有类型
"""
repository = self.subscription_repository
subscribes = repository.list()
if mtype is not None:
subscribes = [subscribe for subscribe in subscribes if subscribe.type == mtype]
fresh_fact_lease = FreshFactLease()
total_num = len(subscribes)
if progress_callback:
progress_callback(
value=0,
text=f"开始更新订阅元数据,共 {total_num} 个订阅 ...",
data={"total": total_num, "finished": 0},
)
for index, subscribe in enumerate(subscribes, start=1):
if runtime_stop_state.is_system_stopped:
break
with subscription_log_scope(subscribe.id, "refresh"):
logger.info(f"开始更新订阅元数据:{subscribe.name} ...")
if progress_callback:
progress_callback(
value=(index - 1) / total_num * 100 if total_num else 100,
text=f"正在更新订阅元数据({index}/{total_num}){subscribe.name} ...",
data={
"total": total_num,
"finished": index - 1,
"current": subscribe.id,
},
)
updated_subscribe = self._check_subscription(
subscribe,
fresh_fact_lease,
reconcile_completion=reconcile_completion,
media_chain_factory=MediaChain,
)
if updated_subscribe is None:
continue
logger.info(f"{updated_subscribe.name} 订阅元数据更新完成")
if progress_callback:
progress_callback(
value=index / total_num * 100 if total_num else 100,
text=f"订阅元数据({index}/{total_num})更新完成",
data={"total": total_num, "finished": index},
)
if progress_callback:
progress_callback(value=100, text="订阅元数据更新完成")
def check_and_reconcile(
self,
progress_callback: Optional[Callable[..., None]] = None,
*,
mtype: Optional[str] = None,
) -> None:
"""
刷新指定媒体类型的元数据,并复用同一轮新鲜事实执行完成对账。
"""
return self.check(
progress_callback=progress_callback,
reconcile_completion=True,
mtype=mtype,
)
async def cache_calendar(
self,
progress_callback: Optional[Callable[..., None]] = None,
) -> None:
"""
预缓存订阅日历,实际上就是查询一遍所有订阅的媒体信息
前端请示是异常的,所以需要使用异步缓存方法
:param progress_callback: 定时服务进度更新回调
"""
logger.info("开始预缓存订阅日历 ...")
subscribes = await self.subscription_repository.async_list()
total_num = len(subscribes)
if progress_callback:
progress_callback(
value=0,
text=f"开始预缓存订阅日历,共 {total_num} 个订阅 ...",
data={"total": total_num, "finished": 0},
)
for index, subscribe in enumerate(subscribes, start=1):
if runtime_stop_state.is_system_stopped:
break
if progress_callback:
progress_callback(
value=(index - 1) / total_num * 100 if total_num else 100,
text=f"正在预缓存订阅日历({index}/{total_num}){subscribe.name} ...",
data={
"total": total_num,
"finished": index - 1,
"current": subscribe.id,
},
)
try:
mtype = MediaType(subscribe.type)
except ValueError:
logger.error(f"订阅 {subscribe.name} 类型错误:{subscribe.type}")
continue
# 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。
if mtype == MediaType.MUSIC:
mediainfo = await self._async_recognize_music_subscribe(subscribe)
else:
mediainfo = await MediaChain().async_recognize_media(
mtype=mtype,
**subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
logger.warn(
f"未识别到媒体信息,标题:{subscribe.name},"
f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}"
)
continue
if mtype == MediaType.TV and mediainfo.media_source == MediaSource.TMDB and mediainfo.tmdb_id:
episodes = await TmdbChain().async_tmdb_episodes(
tmdbid=mediainfo.tmdb_id, season=subscribe.season, episode_group=subscribe.episode_group
)
if not episodes:
logger.warn(
f"未识别到季集信息,标题:{subscribe.name},tmdbid:{mediainfo.tmdb_id},季:{subscribe.season}"
)
continue
if progress_callback:
progress_callback(
value=index / total_num * 100 if total_num else 100,
text=f"订阅日历({index}/{total_num})预缓存完成",
data={"total": total_num, "finished": index},
)
logger.info("订阅日历预缓存完成")
if progress_callback:
progress_callback(value=100, text="订阅日历预缓存完成")
def resolve_subscribe_missing(
self,
subscribe: SubscriptionSnapshot,
meta: MetaBase,
mediainfo: MediaInfo,
mediakey: Optional[Union[str, int]] = None,
best_version_accept_downloaded: bool = False,
) -> Tuple[bool, Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]]:
"""
按主程序订阅口径查询当前目标是否仍有缺失,不推进订阅状态。
该方法只组合媒体库缺集、订阅范围、下载历史和洗版优先级,用于外部策略在
完成前复用主程序"还要不要搜索/下载"的判断口径。它不得完成订阅、写入
lack_episode、发送事件或修改数据库。
电视剧的类型和目标季以订阅快照为准,避免候选元数据误识别或包含多季时
跳过开始集数裁剪,或把其他季的缺集带入下载。
best_version_accept_downloaded 仅用于分集洗版的外部完成守卫:为 True 时,
priority>0 的目标集视为已满足;默认 False 保持主程序洗版完成需 priority==100
的搜索/完成口径。
"""
mediakey = mediakey or subscribe_media_key(subscribe)
if subscribe.type == MediaType.TV.value:
meta = build_subscribe_meta(subscribe)
effective_total_episode = self._SubscribeChain__resolve_effective_total_episode(subscribe, mediainfo)
if not subscribe.best_version:
totals = {}
if subscribe.season is not None and effective_total_episode:
totals = {subscribe.season: effective_total_episode}
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo, totals=totals)
elif meta.type != MediaType.TV and self._SubscribeChain__is_best_version_complete(subscribe):
return True, {}
else:
exist_flag = False
if meta.type == MediaType.TV:
if self._SubscribeChain__is_full_best_version_enabled(subscribe):
pending_episodes = []
elif best_version_accept_downloaded:
downloaded_set = set(
self._SubscribeChain__get_downloaded_best_version_episodes(
subscribe, total_episode=effective_total_episode
)
)
start_episode = subscribe.start_episode or 1
pending_episodes = [
episode
for episode in range(start_episode, effective_total_episode + 1)
if episode not in downloaded_set
]
if not pending_episodes:
return True, {}
else:
pending_episodes = self._get_pending_best_version_episodes(
subscribe, total_episode=effective_total_episode
)
if not pending_episodes:
return True, {}
no_exists = {
mediakey: {
subscribe.season: _SchemaNotExistMediaInfo(
season=subscribe.season,
episodes=pending_episodes,
total_episode=effective_total_episode,
start_episode=subscribe.start_episode or 1,
require_complete_coverage=self._SubscribeChain__is_full_best_version_enabled(subscribe),
)
}
}
else:
no_exists = {}
if exist_flag:
return True, no_exists
downloaded: List[int] = self._SubscribeChain__get_downloaded(subscribe)
if self._SubscribeChain__is_full_best_version_enabled(subscribe):
downloaded = []
if meta.type == MediaType.TV:
return cast(
Tuple[bool, Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]],
self._SubscribeChain__get_subscribe_no_exits(
subscribe_name=f"{subscribe.name} {meta.season}",
no_exists=no_exists,
mediakey=mediakey,
# 搜索词未带季号时识别元数据没有 begin_season,
# 回落到订阅目标季以覆盖原缺失视图。
begin_season=(
meta.begin_season
if meta.begin_season is not None
else subscribe.season
),
total_episode=effective_total_episode,
start_episode=subscribe.start_episode,
downloaded_episodes=downloaded,
),
)
if meta.type in (MediaType.MOVIE, MediaType.MUSIC):
return bool(downloaded), no_exists
return False, no_exists
def _SubscribeChain__resolve_total_episode_decrease(
self,
subscribe: SubscriptionSnapshot,
candidate_total: int,
meta: MetaBase,
mediainfo: MediaInfo,
mediakey: Optional[Union[str, int]] = None,
) -> int:
"""
以旧目标范围内已确认存在的最高集号限制总集数回落。
"""
old_total = subscribe.total_episode or 0
if candidate_total >= old_total or not old_total:
return candidate_total
if subscribe.type != MediaType.TV.value or self._SubscribeChain__is_full_best_version_enabled(subscribe):
return candidate_total
target_key = mediakey or subscribe_media_key(subscribe)
target_season = subscribe.season
if target_key is None or target_season is None:
return candidate_total
target_start = subscribe.start_episode or 1
snapshot = replace(subscribe, total_episode=old_total)
try:
satisfied, no_exists = self.resolve_subscribe_missing(
subscribe=snapshot,
meta=meta,
mediainfo=mediainfo,
mediakey=target_key,
best_version_accept_downloaded=bool(subscribe.best_version),
)
except Exception as err:
logger.warning(f"订阅 {subscribe.name} 已存在分集事实查询失败,按元数据总集数继续:{err}")
return candidate_total
if satisfied:
return old_total
if not isinstance(no_exists, dict):
return candidate_total
seasons = next(
(
no_exists.get(media_key)
for media_key in [target_key, *subscribe_media_keys(subscribe)]
if no_exists.get(media_key) is not None
),
None,
)
if not isinstance(seasons, dict):
return candidate_total
missing_info = seasons.get(target_season)
if not missing_info:
return candidate_total
try:
scope_matches = (
missing_info.season == target_season
and missing_info.start_episode == target_start
and missing_info.total_episode == old_total
)
episodes = missing_info.episodes
except AttributeError:
return candidate_total
if not scope_matches:
return candidate_total
if not isinstance(episodes, list) or not episodes:
return candidate_total
if any(
isinstance(episode, bool) or not isinstance(episode, int) or episode < target_start or episode > old_total
for episode in episodes
):
return candidate_total
confirmed = set(range(target_start, old_total + 1)).difference(episodes)
return max(candidate_total, max(confirmed) if confirmed else 0)
@staticmethod
def _SubscribeChain__resolve_effective_total_episode(subscribe: SubscriptionSnapshot, mediainfo: MediaInfo) -> int:
"""
只读计算完成前有效总集数,不触发事件、不写回订阅。
主流程会通过 ``__refresh_total_episode_before_completion`` 持久化增长后的总集数;
该查询接口只需要同样避免旧 total 造成误判,因此仅使用当前 mediainfo 中更大的
季集数作为临时目标范围。
"""
current_total = subscribe.total_episode or 0
if subscribe.type != MediaType.TV.value:
return current_total
if subscribe.manual_total_episode:
return current_total
if subscribe.season is None:
return current_total
media_total = len((mediainfo.seasons or {}).get(subscribe.season) or [])
if media_total > current_total:
return media_total
return current_total
@staticmethod
def _SubscribeChain__apply_episodes_refresh(
current_total: int,
season: Optional[int],
*,
mediainfo: Optional[MediaInfo] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
subscribe_id: Optional[int] = None,
scene: Optional[str] = None,
) -> int:
"""
发送订阅总集数推算事件,允许外部把当前数据源识别到的季总集数向上覆盖。
用途:插件在"待定集数"等场景经事件注入 total_episode
无监听者或外部未覆盖时返回入参原值,保证零行为变更。
:param current_total: 主程序本次识别到的当前季总集数
:param season: 季号
:return: 最终采用的总集数
"""
event_data = SubscribeEpisodesRefreshEventData(
media_source=media_source,
media_id=media_id,
season=season,
mediainfo=mediainfo,
current_total_episode=current_total,
subscribe_id=subscribe_id,
scene=scene,
)
event = eventmanager.send_event(ChainEventType.SubscribeEpisodesRefresh, event_data)
if event and isinstance(event.event_data, SubscribeEpisodesRefreshEventData):
result = event.event_data
if result.updated and result.total_episode:
result.total_episode = max(current_total or 0, result.total_episode)
total_episode: int = result.total_episode
return total_episode
return current_total
@staticmethod
async def _SubscribeChain__async_apply_episodes_refresh(
current_total: int,
season: Optional[int],
*,
mediainfo: Optional[MediaInfo] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
subscribe_id: Optional[int] = None,
scene: Optional[str] = None,
) -> int:
"""
__apply_episodes_refresh 的异步版本
"""
event_data = SubscribeEpisodesRefreshEventData(
media_source=media_source,
media_id=media_id,
season=season,
mediainfo=mediainfo,
current_total_episode=current_total,
subscribe_id=subscribe_id,
scene=scene,
)
event = await eventmanager.async_send_event(ChainEventType.SubscribeEpisodesRefresh, event_data)
if event and isinstance(event.event_data, SubscribeEpisodesRefreshEventData):
result = event.event_data
if result.updated and result.total_episode:
result.total_episode = max(current_total or 0, result.total_episode)
total_episode: int = result.total_episode
return total_episode
return current_total
def _SubscribeChain__refresh_total_episode_before_completion(
self,
subscribe: SubscriptionSnapshot,
mediainfo: MediaInfo,
meta: Optional[MetaBase] = None,
mediakey: Optional[Union[str, int]] = None,
) -> SubscriptionSnapshot:
"""
在完成判断前,按最新识别结果兜底修正订阅总集数,防止旧总集数导致误完成。
"""
if subscribe.type != MediaType.TV.value:
return subscribe
if subscribe.manual_total_episode:
return subscribe
if subscribe.season is None:
return subscribe
current_total_episode = len((mediainfo.seasons or {}).get(subscribe.season) or [])
# 外部事件只能向上覆盖主程序本次识别到的 TMDB 当前季总集数,已有订阅回落由主程序跟随本次识别结果持久化。
new_total_episode = self._SubscribeChain__apply_episodes_refresh(
current_total_episode,
season=subscribe.season,
mediainfo=mediainfo,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
subscribe_id=subscribe.id,
scene="precheck",
)
old_total_episode = subscribe.total_episode or 0
if meta is not None and new_total_episode and new_total_episode < old_total_episode:
new_total_episode = self._SubscribeChain__resolve_total_episode_decrease(
subscribe=subscribe,
candidate_total=new_total_episode,
meta=meta,
mediainfo=mediainfo,
mediakey=mediakey,
)
if not new_total_episode or new_total_episode == old_total_episode:
return subscribe
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
update_data = self._SubscribeChain__prepare_total_episode_change_fields(
subscribe=subscribe,
total_episode=new_total_episode,
old_total_episode=old_total_episode,
)
update_data["last_update"] = now
subscribe = self._SubscribeChain__apply_subscribe_update(
subscribe,
update_data,
scene="episode_refresh",
)
logger.info(
f"订阅 {subscribe.name} 第{subscribe.season}季 总集数更新为 {new_total_episode},"
f"缺失集数更新为 {subscribe.lack_episode}"
)
return subscribe
@classmethod
def _SubscribeChain__prepare_subscribe_progress_fields(
cls,
subscribe: SubscriptionSnapshot,
no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None,
touch_last_update: Optional[bool] = False,
) -> Dict[str, Any]:
"""
准备订阅进度持久化字段;算法见 application.subscription.priority。
"""
return cast(
Dict[str, Any],
_priority.prepare_subscribe_progress_fields(
subscribe=subscribe,
no_exists=no_exists,
touch_last_update=touch_last_update,
),
)
def _SubscribeChain__refresh_subscribe_progress_with_no_exists(
self,
subscribe: SubscriptionSnapshot,
no_exists: Optional[Dict[Union[int, str], Dict[int, _SchemaNotExistMediaInfo]]] = None,
touch_last_update: Optional[bool] = False,
scene: str = "download",
) -> Dict[str, Any]:
"""
使用已解析的缺失信息刷新订阅进度,避免下载链路重复查询媒体库。
"""
old_lack_episode = subscribe.lack_episode
old_current_priority = subscribe.current_priority
update_data = self._SubscribeChain__prepare_subscribe_progress_fields(
subscribe=subscribe,
no_exists=no_exists,
touch_last_update=touch_last_update,
)
if not update_data:
return {"scene": scene, "updated": False, "fields": [], "reason": "unsupported_subscribe_type"}
subscribe = self._SubscribeChain__apply_subscribe_update(subscribe, update_data)
logger.info(
f"订阅 {subscribe.id} 进度刷新:scene={scene},fields={list(update_data)},"
f"lack_episode {old_lack_episode}->{subscribe.lack_episode},"
f"current_priority {old_current_priority}->{subscribe.current_priority},reason=updated"
)
return {
"scene": scene,
"updated": True,
"fields": list(update_data),
"lack_episode": update_data.get("lack_episode", subscribe.lack_episode),
"current_priority": update_data.get("current_priority", subscribe.current_priority),
"reason": "updated",
"subscribe": subscribe,
}
def refresh_subscribe_progress(self, subscribe: SubscriptionSnapshot, *, scene: str = "update") -> Dict[str, Any]:
"""
按主程序口径重新计算并持久化订阅进度。
"""
if subscribe.type != MediaType.TV.value:
return {"scene": scene, "updated": False, "fields": [], "reason": "unsupported_subscribe_type"}
no_exists = None
mediainfo = None
if not subscribe.best_version:
meta = build_subscribe_meta(subscribe)
mediainfo = MediaChain().recognize_media(
meta=meta,
mtype=meta.type,
**subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
return {"scene": scene, "updated": False, "fields": [], "reason": "recognize_failed"}
mediakey = subscribe_media_key(subscribe)
exist_flag, no_exists = self.resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=mediakey,
)
if not exist_flag and not no_exists:
return {"scene": scene, "updated": False, "fields": [], "reason": "resolve_missing_failed"}
return self._SubscribeChain__refresh_subscribe_progress_with_no_exists(
subscribe=subscribe,
no_exists=no_exists,
touch_last_update=False,
scene=scene,
)
def backfill_existing_episodes(
self,
subscribe: SubscriptionSnapshot,
episodes: List[Union[int, str]],
priority: Optional[int] = None,
scene: str = "backfill",
) -> Dict[str, Any]:
"""
将媒体库既有剧集补写为订阅下载事实,并按需刷新进度字段。
"""
accepted = []
ignored = []
priority_updated: List[int] = []
priority_ignored: List[dict[str, Union[int, str]]] = []
target_episodes = set(self._SubscribeChain__get_best_version_target_episodes(subscribe))
note = sorted({int(episode) for episode in subscribe.note or [] if str(episode).isdigit()})
note_set = set(note)
priority_episodes = set()
for episode in episodes or []:
try:
episode_number = int(episode)
except (TypeError, ValueError):
ignored.append({"episode": episode, "reason": "invalid"})
continue
if episode_number not in target_episodes:
ignored.append({"episode": episode, "reason": "out_of_range"})
continue
if episode_number in note_set:
ignored.append({"episode": episode, "reason": "duplicate"})
priority_episodes.add(episode_number)
continue
accepted.append(episode_number)
note_set.add(episode_number)
priority_episodes.add(episode_number)
summary: Dict[str, Any] = {
"scene": scene,
"accepted": accepted,
"ignored": ignored,
"priority_updated": priority_updated,
"priority_ignored": priority_ignored,
"fields": [],
}
update_data: Dict[str, Any] = {}
if accepted:
note = sorted(note_set)
update_data["note"] = note
priority_valid = isinstance(priority, int) and not isinstance(priority, bool) and 1 <= priority <= 100
if priority is not None and not priority_valid:
summary["ignored_priority"] = priority
if priority_valid:
episode_priority = self._SubscribeChain__get_episode_priority(subscribe)
for episode_number in sorted(priority_episodes):
episode_key = str(episode_number)
old_priority = episode_priority.get(episode_key)
if old_priority is None or priority > old_priority:
episode_priority[episode_key] = priority
priority_updated.append(episode_number)
else:
priority_ignored.append(
{
"episode": episode_number,
"reason": "duplicate" if old_priority == priority else "not_higher_priority",
}
)
if priority_updated:
update_data["episode_priority"] = episode_priority
should_refresh_progress = subscribe.type == MediaType.TV.value and (accepted or priority_updated)
progress_summary = None
if should_refresh_progress and subscribe.best_version:
working_snapshot = replace(subscribe, **update_data)
update_data.update(
self._SubscribeChain__prepare_subscribe_progress_fields(
subscribe=working_snapshot,
touch_last_update=True,
)
)
if update_data:
subscribe = self._SubscribeChain__apply_subscribe_update(subscribe, update_data)
summary["fields"] = list(update_data)
if should_refresh_progress and not subscribe.best_version:
progress_summary = self.refresh_subscribe_progress(subscribe, scene=scene)
if progress_summary is not None:
summary["progress"] = progress_summary
subscribe = progress_summary.get("subscribe", subscribe)
summary["subscribe"] = subscribe
summary["updated"] = bool(update_data)
if progress_summary:
summary["updated"] = summary["updated"] or bool(progress_summary.get("updated"))
return summary