mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-30 00:36:43 +08:00
Merge remote-tracking branch 'origin/v2' into v2
This commit is contained in:
@@ -941,6 +941,7 @@ class DownloadChain(ChainBase):
|
||||
continue
|
||||
# 种子季是需要季或者子集
|
||||
if set(torrent_season).issubset(set(need_season)):
|
||||
complete_coverage_matched = False
|
||||
if len(torrent_season) == 1:
|
||||
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
||||
logger.info(f"开始下载种子 {torrent.title} ...")
|
||||
@@ -965,7 +966,13 @@ class DownloadChain(ChainBase):
|
||||
required_episodes = __get_required_episodes(need_mid, torrent_season[0]) \
|
||||
if __requires_complete_coverage(need_tv_info) else set()
|
||||
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
||||
if required_episodes and not required_episodes.issubset(torrent_episodes_set):
|
||||
complete_coverage_matched = bool(required_episodes) \
|
||||
and required_episodes.issubset(torrent_episodes_set)
|
||||
if complete_coverage_matched:
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数已完整覆盖目标范围:"
|
||||
f"{StringUtils.format_ep(sorted(required_episodes))}")
|
||||
if required_episodes and not complete_coverage_matched:
|
||||
missing_episodes = sorted(required_episodes.difference(torrent_episodes_set))
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数未覆盖目标范围,"
|
||||
@@ -998,6 +1005,8 @@ class DownloadChain(ChainBase):
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
if complete_coverage_matched:
|
||||
context.confirmed_full_coverage = True
|
||||
logger.info(f"{torrent.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需季集
|
||||
@@ -1079,6 +1088,8 @@ class DownloadChain(ChainBase):
|
||||
downloader=downloader)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
if __requires_complete_coverage(tv):
|
||||
context.confirmed_full_coverage = True
|
||||
logger.info(f"{meta.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需集数
|
||||
|
||||
@@ -58,7 +58,18 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
|
||||
|
||||
class SubscribeChain(ChainBase):
|
||||
"""
|
||||
订阅管理处理链
|
||||
订阅管理处理链。
|
||||
|
||||
订阅链路同时服务电影、普通电视剧、分集洗版和全集洗版。普通电视剧订阅与
|
||||
分集洗版共享按集事实:note 表示目标集已经存在或已经下载,
|
||||
episode_priority 表示每集已知下载质量;二者可以互相切换。全集洗版关注
|
||||
完整目标范围的整体质量,只有下载层确认整包完整覆盖目标范围后,才把资源
|
||||
写成目标范围内的按集事实。
|
||||
|
||||
实现上保持三个入口分离:下载事实入口只写 note / episode_priority;
|
||||
progress 刷新入口只把当前事实计算为 lack_episode 和电视剧洗版
|
||||
current_priority;完成入口只根据最终事实和完成策略收敛订阅状态。电影没有
|
||||
按集事实,电影洗版的 current_priority 由电影下载优先级 writer 单独维护。
|
||||
"""
|
||||
|
||||
_rlock = threading.RLock()
|
||||
@@ -150,7 +161,7 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
if episode_number in target_episodes:
|
||||
downloaded.add(episode_number)
|
||||
for episode, priority in (subscribe.episode_priority or {}).items():
|
||||
for episode, priority in cls.__get_episode_priority(subscribe, total_episode=total_episode).items():
|
||||
if not str(episode).isdigit():
|
||||
continue
|
||||
try:
|
||||
@@ -191,42 +202,60 @@ class SubscribeChain(ChainBase):
|
||||
return cls.__get_pending_best_version_episodes_with_priority(subscribe, total_episode=total_episode)
|
||||
|
||||
@classmethod
|
||||
def compute_completed_episode(cls, subscribe: Subscribe) -> Optional[int]:
|
||||
def compute_lack_episode(
|
||||
cls,
|
||||
subscribe: Subscribe,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
计算订阅"已完成"集数派生值,仅用于响应填充,不入库。
|
||||
计算订阅范围内尚未下载到任何版本的集数。
|
||||
|
||||
语义:
|
||||
- 普通订阅 (best_version=0):``max(total_episode - lack_episode, 0)``,即媒体库已入库集数。
|
||||
- 洗版订阅 (best_version=1,含分集与全集洗版):
|
||||
``(start_episode - 1) + (episode_priority 中 priority==100 且 ep ∈ [start, total] 的命中数)``。
|
||||
start_episode 之前的集不在订阅范围内,视为"逻辑上已完成",与主文案分母 total_episode 对齐。
|
||||
|
||||
- 入参:完整 Subscribe ORM/Schema 对象,需至少包含 best_version、type、start_episode、
|
||||
total_episode、lack_episode、episode_priority 字段。
|
||||
- 返回:完成集数;电影或缺少 total_episode 时返回 None。
|
||||
普通电视剧订阅以媒体库缺失结果为准;调用方没有缺失结果时按空缺失处理,
|
||||
避免入口级刷新失败把未知状态写成异常。洗版电视剧订阅按 note 与
|
||||
episode_priority>0 判断是否已有任意版本落点,priority<100 仍表示已下载过任意版本。
|
||||
"""
|
||||
total_episode = subscribe.total_episode or 0
|
||||
if subscribe.type != MediaType.TV.value or not total_episode:
|
||||
return None
|
||||
|
||||
start_episode = subscribe.start_episode or 1
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return 0
|
||||
|
||||
if not subscribe.best_version:
|
||||
lack = subscribe.lack_episode or 0
|
||||
return max(total_episode - lack, 0)
|
||||
no_exists = no_exists or {}
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
left_seasons = no_exists.get(mediakey) 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
|
||||
|
||||
# 洗版口径:start 之前的集视为已完成 + 范围内 priority==100 命中。
|
||||
# ``start_episode > total_episode`` 是异常配置,需把"起始集前"偏移截断到 total,
|
||||
# 避免 completed 越过分母 total_episode。
|
||||
episode_priority = subscribe.episode_priority or {}
|
||||
priority_completed = sum(
|
||||
1
|
||||
for ep_key, priority in episode_priority.items()
|
||||
if str(ep_key).isdigit()
|
||||
and start_episode <= int(ep_key) <= total_episode
|
||||
and priority == 100
|
||||
)
|
||||
return min(max(start_episode - 1, 0), total_episode) + priority_completed
|
||||
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 = 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, priority in cls.__get_episode_priority(subscribe).items():
|
||||
try:
|
||||
if float(priority) <= 0:
|
||||
continue
|
||||
episode_number = int(episode)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if episode_number in target_episodes:
|
||||
downloaded.add(episode_number)
|
||||
return len(target_episodes - downloaded)
|
||||
|
||||
@classmethod
|
||||
def get_best_version_current_priority(
|
||||
@@ -240,19 +269,54 @@ class SubscribeChain(ChainBase):
|
||||
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
|
||||
target_episodes = cls.__get_best_version_target_episodes(subscribe)
|
||||
if not target_episodes:
|
||||
return subscribe.current_priority or 0
|
||||
|
||||
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),
|
||||
return min(
|
||||
(normalized.get(str(episode), 0) for episode in target_episodes),
|
||||
default=0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def __prepare_best_version_total_expansion_fields(
|
||||
cls,
|
||||
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 = cls.__get_episode_priority(
|
||||
subscribe,
|
||||
total_episode=old_total_episode,
|
||||
)
|
||||
if not episode_priority and subscribe.current_priority is not None:
|
||||
episode_priority = {
|
||||
str(episode): int(subscribe.current_priority)
|
||||
for episode in cls.__get_best_version_target_episodes(
|
||||
subscribe,
|
||||
total_episode=old_total_episode,
|
||||
)
|
||||
}
|
||||
subscribe.episode_priority = episode_priority
|
||||
update_data["episode_priority"] = episode_priority
|
||||
|
||||
update_data.update(cls.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
|
||||
return update_data
|
||||
|
||||
@classmethod
|
||||
def __is_best_version_complete(cls, subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
@@ -414,7 +478,7 @@ class SubscribeChain(ChainBase):
|
||||
@classmethod
|
||||
def __is_full_season_priority_higher_than_all_targets(cls, subscribe: Subscribe, priority: int) -> bool:
|
||||
"""
|
||||
判断整季资源优先级是否高于订阅目标范围内所有分集。
|
||||
判断整季资源优先级是否高于订阅目标范围的整体优先级门槛。
|
||||
"""
|
||||
if subscribe.type != MediaType.TV.value:
|
||||
return False
|
||||
@@ -428,12 +492,13 @@ class SubscribeChain(ChainBase):
|
||||
except (TypeError, ValueError):
|
||||
resource_priority = 0
|
||||
|
||||
episode_priority = cls.__get_episode_priority(subscribe)
|
||||
for episode in target_episodes:
|
||||
current_priority = episode_priority.get(str(episode), 0)
|
||||
if resource_priority <= current_priority:
|
||||
return False
|
||||
return True
|
||||
try:
|
||||
current_priority = int(subscribe.current_priority) if subscribe.current_priority is not None \
|
||||
else cls.get_best_version_current_priority(subscribe)
|
||||
except (TypeError, ValueError):
|
||||
current_priority = 0
|
||||
|
||||
return resource_priority > current_priority
|
||||
|
||||
@classmethod
|
||||
def __build_full_pack_first_no_exists(
|
||||
@@ -462,6 +527,7 @@ class SubscribeChain(ChainBase):
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1,
|
||||
require_complete_coverage=True,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -486,16 +552,30 @@ class SubscribeChain(ChainBase):
|
||||
if context.media_info.type == MediaType.TV
|
||||
and self.__is_full_season_resource(meta=context.meta_info, subscribe=subscribe)
|
||||
] if full_pack_no_exists else []
|
||||
full_pack_contexts = [
|
||||
context for context in full_season_contexts
|
||||
if self.__is_full_season_priority_higher_than_all_targets(
|
||||
target_episodes = self.__get_best_version_target_episodes(subscribe)
|
||||
target_range = f"{target_episodes[0]}-{target_episodes[-1]}" if target_episodes else "empty"
|
||||
try:
|
||||
current_priority_gate = int(subscribe.current_priority) if subscribe.current_priority is not None \
|
||||
else self.get_best_version_current_priority(subscribe)
|
||||
except (TypeError, ValueError):
|
||||
current_priority_gate = 0
|
||||
full_pack_contexts = []
|
||||
for context in full_season_contexts:
|
||||
candidate_priority = getattr(context.torrent_info, "pri_order", 0)
|
||||
accepted = self.__is_full_season_priority_higher_than_all_targets(
|
||||
subscribe=subscribe,
|
||||
priority=context.torrent_info.pri_order,
|
||||
priority=candidate_priority,
|
||||
)
|
||||
]
|
||||
logger.info(
|
||||
f"{subscribe.name} 整包候选优先级判断:candidate_priority={candidate_priority},"
|
||||
f"current_priority={current_priority_gate},target_range={target_range},"
|
||||
f"decision={'accept' if accepted else 'reject'}"
|
||||
)
|
||||
if accepted:
|
||||
full_pack_contexts.append(context)
|
||||
|
||||
if full_season_contexts and not full_pack_contexts:
|
||||
logger.info(f"{subscribe.name} 全集候选优先级未高于所有目标集,回退到分集洗版")
|
||||
logger.info(f"{subscribe.name} 全集候选优先级未高于 current_priority 门槛,回退到分集洗版")
|
||||
|
||||
if full_pack_contexts:
|
||||
logger.info(f"{subscribe.name} 分集洗版优先尝试全集资源,共匹配到 {len(full_pack_contexts)} 个候选")
|
||||
@@ -1223,61 +1303,25 @@ class SubscribeChain(ChainBase):
|
||||
self._rlock.release()
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
|
||||
mediainfo: MediaInfo, downloads: Optional[List[Context]]):
|
||||
@staticmethod
|
||||
def __update_movie_best_version_download_priority(
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
downloads: Optional[List[Context]],
|
||||
):
|
||||
"""
|
||||
更新订阅已下载资源的优先级
|
||||
记录电影洗版本轮下载资源优先级。
|
||||
"""
|
||||
if not downloads:
|
||||
return
|
||||
if not subscribe.best_version:
|
||||
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 and self.__is_full_season_resource(download.meta_info, subscribe):
|
||||
# 整包下载时资源标题常不携带集数,视为覆盖当前订阅的全部目标集。
|
||||
downloaded_episodes = self.__get_best_version_target_episodes(subscribe)
|
||||
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 由 finish_subscribe_or_not -> __update_lack_episodes 按媒体库实况维护,本处不写
|
||||
update_data: Dict[str, Any] = {
|
||||
"episode_priority": episode_priority,
|
||||
"last_update": now,
|
||||
"current_priority": current_priority,
|
||||
}
|
||||
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
subscribe.episode_priority = episode_priority
|
||||
subscribe.current_priority = current_priority
|
||||
subscribe.last_update = now
|
||||
|
||||
completed_episodes = self.__get_best_version_completed_episodes(subscribe)
|
||||
if not self.__is_best_version_complete(subscribe):
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 正在洗版,更新剧集优先级为 {priority},已完成剧集:{completed_episodes}'
|
||||
)
|
||||
if subscribe.type != MediaType.MOVIE.value:
|
||||
return
|
||||
|
||||
# 订阅存在待定策略,不管是否已完成,均需更新订阅信息
|
||||
SubscribeOper().update(subscribe.id, {
|
||||
"current_priority": priority,
|
||||
"last_update": now
|
||||
@@ -1298,17 +1342,20 @@ class SubscribeChain(ChainBase):
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
# 是否有剩余集
|
||||
no_lefts = not lefts or not lefts.get(mediakey)
|
||||
# 不论是否洗版,只要本轮有下载产生就要把集数追加进 subscribe.note,
|
||||
# 保证"已下载过哪些集"这条事实在所有订阅模式下都有可靠落点;洗版分支
|
||||
# 之前只写 episode_priority,导致用户切回普通订阅时丢失下载历史,并让
|
||||
# __get_downloaded 在洗版下无法从 note 拿到 priority 未达 100 但实际下过的集。
|
||||
if downloads:
|
||||
if downloads and meta.type == MediaType.TV:
|
||||
self.__record_subscribe_download_facts(subscribe=subscribe, mediainfo=mediainfo, downloads=downloads)
|
||||
elif downloads:
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
# 是否完成订阅
|
||||
if not subscribe.best_version:
|
||||
# 普通订阅:先按 lefts 写 lack,再判断完成
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
|
||||
update_date=bool(downloads))
|
||||
if meta.type == MediaType.TV:
|
||||
self.__refresh_subscribe_progress_with_no_exists(
|
||||
no_exists=lefts,
|
||||
subscribe=subscribe,
|
||||
touch_last_update=bool(downloads),
|
||||
scene="download",
|
||||
)
|
||||
if ((no_lefts and meta.type == MediaType.TV)
|
||||
or (downloads and meta.type == MediaType.MOVIE)
|
||||
or force):
|
||||
@@ -1317,13 +1364,20 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
||||
return
|
||||
|
||||
# 洗版订阅:本轮若有下载先更新 episode_priority / current_priority,让 __update_lack_episodes
|
||||
# 读取到包含本轮新下载的集;否则 lack 会慢一个搜索周期才反映新下载。
|
||||
if downloads:
|
||||
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, downloads=downloads)
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
|
||||
update_date=bool(downloads))
|
||||
if downloads and meta.type == MediaType.MOVIE:
|
||||
# 电影没有按集质量事实,只能用 current_priority 表达洗版下载质量。
|
||||
self.__update_movie_best_version_download_priority(
|
||||
subscribe=subscribe,
|
||||
mediainfo=mediainfo,
|
||||
downloads=downloads,
|
||||
)
|
||||
if meta.type == MediaType.TV:
|
||||
self.__refresh_subscribe_progress_with_no_exists(
|
||||
no_exists=lefts,
|
||||
subscribe=subscribe,
|
||||
touch_last_update=bool(downloads),
|
||||
scene="download",
|
||||
)
|
||||
if self.__is_best_version_complete(subscribe):
|
||||
# 洗版完成
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
@@ -1859,7 +1913,7 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
current_priority = None
|
||||
progress_update = {}
|
||||
if not subscribe.manual_total_episode and len(episodes):
|
||||
total_episode = len(episodes)
|
||||
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
|
||||
@@ -1867,23 +1921,34 @@ class SubscribeChain(ChainBase):
|
||||
total_episode, season=subscribe.season, mediainfo=mediainfo,
|
||||
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
|
||||
subscribe_id=subscribe.id, scene="refresh")
|
||||
lack_episode = max((subscribe.lack_episode or 0) + (total_episode - (subscribe.total_episode or 0)), 0)
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
# 为新增集补齐 episode_priority 初始项(priority=0)
|
||||
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
|
||||
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
|
||||
if total_episode > (subscribe.total_episode or 0):
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
progress_update = self.__prepare_best_version_total_expansion_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=total_episode,
|
||||
)
|
||||
else:
|
||||
old_total_episode = subscribe.total_episode or 0
|
||||
progress_update = {
|
||||
"total_episode": total_episode,
|
||||
"lack_episode": max(
|
||||
(subscribe.lack_episode or 0) + (total_episode - old_total_episode),
|
||||
0,
|
||||
),
|
||||
}
|
||||
else:
|
||||
total_episode = subscribe.total_episode
|
||||
progress_update = {"lack_episode": subscribe.lack_episode}
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
progress_update = self.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={})
|
||||
logger.info(
|
||||
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
|
||||
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},'
|
||||
f'缺失集数为{progress_update.get("lack_episode", subscribe.lack_episode)} ...')
|
||||
else:
|
||||
total_episode = subscribe.total_episode
|
||||
lack_episode = subscribe.lack_episode
|
||||
progress_update = {"lack_episode": subscribe.lack_episode}
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
current_priority = self.get_best_version_current_priority(subscribe)
|
||||
progress_update = self.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={})
|
||||
# 更新TMDB信息
|
||||
update_data = {
|
||||
"name": mediainfo.title,
|
||||
@@ -1895,15 +1960,10 @@ class SubscribeChain(ChainBase):
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
"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
|
||||
update_data.update(progress_update)
|
||||
for key, value in progress_update.items():
|
||||
setattr(subscribe, key, value)
|
||||
subscribeoper.update(subscribe.id, update_data)
|
||||
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
||||
if progress_callback:
|
||||
@@ -2155,80 +2215,297 @@ class SubscribeChain(ChainBase):
|
||||
return note
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def __update_lack_episodes(lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: Optional[bool] = False):
|
||||
@classmethod
|
||||
def __prepare_subscribe_progress_fields(
|
||||
cls,
|
||||
subscribe: Subscribe,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = None,
|
||||
touch_last_update: Optional[bool] = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
写入订阅 lack_episode,可选同时刷新 last_update。
|
||||
准备订阅进度持久化字段。
|
||||
|
||||
lack 统一语义为"订阅范围内尚未下载到任何版本的集数"。
|
||||
- 普通订阅:lack 从 ``lefts`` 提取(lefts 已在 ``__get_subscribe_no_exits`` 里扣过 note)
|
||||
- 洗版订阅:lack = ``[start, total]`` 范围内既不在 note 也不在 episode_priority(>0) 命中的集数。
|
||||
洗版的 lefts 由 ``check_and_handle_existing_media`` 按 priority<100 构造,承担"搜索目标"职责,
|
||||
与"未下载"维度并不同义——若复用会把"已下载但待升级"的集错算成 lack。
|
||||
该方法只返回待写字段,不主动写库。普通电视剧的 no_exists 为空时表示当前缺失结果为空;
|
||||
洗版电视剧按 note 与 episode_priority 计算未下载过任何版本的目标集数量。
|
||||
"""
|
||||
update_data = {}
|
||||
if update_date:
|
||||
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
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"] = cls.compute_lack_episode(subscribe, no_exists=no_exists)
|
||||
if subscribe.best_version:
|
||||
lack_episode = SubscribeChain.__compute_best_version_lack_episode(subscribe)
|
||||
logger.info(f"{mediainfo.title_year} 季 {subscribe.season} 剩余未下载剧集数为{lack_episode} ...")
|
||||
elif not lefts:
|
||||
# lefts 为空:媒体库实缺为 0
|
||||
lack_episode = 0
|
||||
logger.info(f'{mediainfo.title_year} 没有缺失集数,直接更新为 0 ...')
|
||||
else:
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
left_seasons = lefts.get(mediakey)
|
||||
lack_episode = 0
|
||||
if left_seasons:
|
||||
for season_info in left_seasons.values():
|
||||
season = season_info.season
|
||||
if season == subscribe.season:
|
||||
left_episodes = season_info.episodes
|
||||
if not left_episodes:
|
||||
lack_episode = season_info.total_episode
|
||||
else:
|
||||
lack_episode = len(left_episodes)
|
||||
logger.info(f"{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...")
|
||||
break
|
||||
update_data["lack_episode"] = lack_episode
|
||||
# 更新数据库
|
||||
if update_data:
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
update_data["current_priority"] = cls.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
|
||||
|
||||
@staticmethod
|
||||
def __compute_best_version_lack_episode(subscribe: Subscribe) -> int:
|
||||
def __apply_subscribe_update(subscribe: Subscribe, update_data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
计算洗版订阅"未下载集数":在 ``[start, total]`` 范围内排除已在 ``note`` 或
|
||||
``episode_priority`` (>0) 中记账的集。priority<100 但 >0 的集视为"已下载、待升级",
|
||||
不计入 lack——与 UI 上"已下载 = total - lack"展示口径一致。
|
||||
写入订阅字段并同步当前内存对象,保证后续事件和判断读取最终快照。
|
||||
"""
|
||||
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 = set()
|
||||
for ep in (subscribe.note or []):
|
||||
if not update_data:
|
||||
return
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
for key, value in update_data.items():
|
||||
setattr(subscribe, key, value)
|
||||
|
||||
def __refresh_subscribe_progress_with_no_exists(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]] = 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.__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"}
|
||||
|
||||
self.__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",
|
||||
}
|
||||
|
||||
def refresh_subscribe_progress(self, subscribe: Subscribe, *, 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 = self.recognize_media(
|
||||
meta=meta,
|
||||
mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid,
|
||||
bangumiid=getattr(subscribe, "bangumiid", None),
|
||||
episode_group=getattr(subscribe, "episode_group", None),
|
||||
cache=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
return {"scene": scene, "updated": False, "fields": [], "reason": "recognize_failed"}
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
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.__refresh_subscribe_progress_with_no_exists(
|
||||
subscribe=subscribe,
|
||||
no_exists=no_exists,
|
||||
touch_last_update=False,
|
||||
scene=scene,
|
||||
)
|
||||
|
||||
def backfill_existing_episodes(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
episodes: List[Union[int, str]],
|
||||
priority: Optional[int] = None,
|
||||
scene: str = "backfill",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将媒体库既有剧集补写为订阅下载事实,并按需刷新进度字段。
|
||||
"""
|
||||
accepted = []
|
||||
ignored = []
|
||||
priority_updated = []
|
||||
priority_ignored = []
|
||||
target_episodes = set(self.__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:
|
||||
downloaded.add(int(ep))
|
||||
episode_number = int(episode)
|
||||
except (TypeError, ValueError):
|
||||
ignored.append({"episode": episode, "reason": "invalid"})
|
||||
continue
|
||||
for ep_str, priority in (subscribe.episode_priority or {}).items():
|
||||
if not str(ep_str).isdigit():
|
||||
if episode_number not in target_episodes:
|
||||
ignored.append({"episode": episode, "reason": "out_of_range"})
|
||||
continue
|
||||
try:
|
||||
if float(priority) > 0:
|
||||
downloaded.add(int(ep_str))
|
||||
except (TypeError, ValueError):
|
||||
if episode_number in note_set:
|
||||
ignored.append({"episode": episode, "reason": "duplicate"})
|
||||
priority_episodes.add(episode_number)
|
||||
continue
|
||||
return len(target_episodes - downloaded)
|
||||
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)
|
||||
subscribe.note = note
|
||||
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.__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:
|
||||
subscribe.episode_priority = episode_priority
|
||||
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:
|
||||
update_data.update(self.__prepare_subscribe_progress_fields(
|
||||
subscribe=subscribe,
|
||||
touch_last_update=True,
|
||||
))
|
||||
|
||||
if update_data:
|
||||
self.__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
|
||||
summary["updated"] = bool(update_data)
|
||||
if progress_summary:
|
||||
summary["updated"] = summary["updated"] or bool(progress_summary.get("updated"))
|
||||
return summary
|
||||
|
||||
def __record_subscribe_download_facts(
|
||||
self,
|
||||
subscribe: Subscribe,
|
||||
*,
|
||||
mediainfo: MediaInfo,
|
||||
downloads: Optional[List[Context]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
记录主程序本轮下载产生的订阅事实,并返回本轮覆盖摘要。
|
||||
"""
|
||||
if not downloads:
|
||||
return {"episodes": [], "fields": [], "updated": False}
|
||||
|
||||
covered_episodes = set()
|
||||
written_priorities: Dict[str, int] = {}
|
||||
used_full_coverage_fallback = False
|
||||
episode_priority = self.__get_episode_priority(subscribe)
|
||||
note_set = {
|
||||
int(episode)
|
||||
for episode in subscribe.note or []
|
||||
if str(episode).isdigit()
|
||||
}
|
||||
update_data: Dict[str, Any] = {}
|
||||
|
||||
for download in downloads:
|
||||
media = download.media_info
|
||||
if subscribe.tmdbid and getattr(media, "tmdb_id", None) and media.tmdb_id != subscribe.tmdbid:
|
||||
continue
|
||||
if subscribe.doubanid and getattr(media, "douban_id", None) and media.douban_id != subscribe.doubanid:
|
||||
continue
|
||||
|
||||
if subscribe.type == MediaType.MOVIE.value and media.type == MediaType.MOVIE:
|
||||
note_set.add(1)
|
||||
covered_episodes.add(1)
|
||||
continue
|
||||
|
||||
if subscribe.type != MediaType.TV.value or media.type != MediaType.TV:
|
||||
continue
|
||||
|
||||
selected_episodes = getattr(download, "selected_episodes", None)
|
||||
if selected_episodes:
|
||||
episodes = selected_episodes
|
||||
elif getattr(download, "meta_info", None) and download.meta_info.episode_list:
|
||||
episodes = download.meta_info.episode_list
|
||||
elif getattr(download, "confirmed_full_coverage", False):
|
||||
episodes = self.__get_best_version_target_episodes(subscribe)
|
||||
used_full_coverage_fallback = True
|
||||
else:
|
||||
episodes = []
|
||||
|
||||
valid_episodes = []
|
||||
for episode in episodes:
|
||||
try:
|
||||
episode_number = int(episode)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if episode_number not in self.__get_best_version_target_episodes(subscribe):
|
||||
continue
|
||||
valid_episodes.append(episode_number)
|
||||
if not valid_episodes:
|
||||
continue
|
||||
|
||||
priority = getattr(download.torrent_info, "pri_order", None)
|
||||
for episode_number in valid_episodes:
|
||||
note_set.add(episode_number)
|
||||
covered_episodes.add(episode_number)
|
||||
episode_key = str(episode_number)
|
||||
old_priority = episode_priority.get(episode_key)
|
||||
if isinstance(priority, int) and not isinstance(priority, bool) \
|
||||
and (old_priority is None or priority > old_priority):
|
||||
episode_priority[episode_key] = priority
|
||||
written_priorities[episode_key] = priority
|
||||
|
||||
if covered_episodes:
|
||||
update_data["note"] = sorted(note_set)
|
||||
if subscribe.type == MediaType.TV.value:
|
||||
update_data["episode_priority"] = episode_priority
|
||||
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if update_data:
|
||||
self.__apply_subscribe_update(subscribe, update_data)
|
||||
logger.info(
|
||||
f"{mediainfo.title_year} 订阅 {subscribe.id} 第 {subscribe.season} 季记录下载事实:"
|
||||
f"mode=best_version:{subscribe.best_version},full:{subscribe.best_version_full},"
|
||||
f"covered_episodes={sorted(covered_episodes)},episode_priority={written_priorities},"
|
||||
f"confirmed_full_coverage_fallback={used_full_coverage_fallback}"
|
||||
)
|
||||
return {
|
||||
"episodes": sorted(covered_episodes),
|
||||
"fields": list(update_data),
|
||||
"updated": bool(update_data),
|
||||
}
|
||||
|
||||
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase):
|
||||
"""
|
||||
@@ -3507,19 +3784,28 @@ class SubscribeChain(ChainBase):
|
||||
if not new_total_episode or new_total_episode <= old_total_episode:
|
||||
return
|
||||
|
||||
old_lack_episode = subscribe.lack_episode or 0
|
||||
new_lack_episode = old_lack_episode + (new_total_episode - old_total_episode)
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
SubscribeOper().update(subscribe.id, {
|
||||
"total_episode": new_total_episode,
|
||||
"lack_episode": new_lack_episode,
|
||||
"last_update": now
|
||||
})
|
||||
subscribe.total_episode = new_total_episode
|
||||
subscribe.lack_episode = new_lack_episode
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
update_data = self.__prepare_best_version_total_expansion_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=new_total_episode,
|
||||
)
|
||||
else:
|
||||
update_data = {
|
||||
"total_episode": new_total_episode,
|
||||
"lack_episode": max(
|
||||
(subscribe.lack_episode or 0) + (new_total_episode - old_total_episode),
|
||||
0,
|
||||
),
|
||||
}
|
||||
update_data["last_update"] = now
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
for key, value in update_data.items():
|
||||
setattr(subscribe, key, value)
|
||||
subscribe.last_update = now
|
||||
logger.info(
|
||||
f"订阅 {subscribe.name} 第{subscribe.season}季 总集数更新为 {new_total_episode},缺失集数更新为 {new_lack_episode}"
|
||||
f"订阅 {subscribe.name} 第{subscribe.season}季 总集数更新为 {new_total_episode},"
|
||||
f"缺失集数更新为 {subscribe.lack_episode}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -919,6 +919,8 @@ class Context:
|
||||
media_info_is_target: bool = False
|
||||
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
|
||||
allowed_episodes: Optional[Set[int]] = None
|
||||
# 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源。
|
||||
confirmed_full_coverage: bool = False
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
@@ -935,4 +937,5 @@ class Context:
|
||||
"media_info_is_target": self.media_info_is_target,
|
||||
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
|
||||
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None,
|
||||
"confirmed_full_coverage": self.confirmed_full_coverage,
|
||||
}
|
||||
|
||||
@@ -536,6 +536,7 @@ class TransHandler:
|
||||
# 整理文件
|
||||
new_item, err_msg = self.__transfer_file(
|
||||
fileitem=fileitem,
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_file=new_file,
|
||||
@@ -962,6 +963,7 @@ class TransHandler:
|
||||
def __transfer_file(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: MediaInfo,
|
||||
source_oper: StorageBase,
|
||||
target_oper: StorageBase,
|
||||
@@ -974,6 +976,7 @@ class TransHandler:
|
||||
"""
|
||||
整理一个文件,同时处理其他相关文件
|
||||
:param fileitem: 原文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
@@ -990,6 +993,7 @@ class TransHandler:
|
||||
)
|
||||
event_data = TransferInterceptEventData(
|
||||
fileitem=fileitem,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_path=target_file,
|
||||
|
||||
@@ -314,6 +314,8 @@ class Context(BaseModel):
|
||||
candidate_recognized: Optional[bool] = False
|
||||
# 当前 media_info 是否为目标媒体回填
|
||||
media_info_is_target: Optional[bool] = False
|
||||
# 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源
|
||||
confirmed_full_coverage: Optional[bool] = False
|
||||
|
||||
|
||||
class MediaSeason(BaseModel):
|
||||
|
||||
@@ -388,6 +388,7 @@ class TransferInterceptEventData(ChainEventData):
|
||||
Attributes:
|
||||
# 输入参数
|
||||
fileitem (FileItem): 源文件
|
||||
meta (Any): 元数据
|
||||
target_storage (str): 目标存储
|
||||
target_path (Path): 目标路径
|
||||
transfer_type (str): 整理方式(copy、move、link、softlink等)
|
||||
@@ -401,6 +402,7 @@ class TransferInterceptEventData(ChainEventData):
|
||||
|
||||
# 输入参数
|
||||
fileitem: FileItem = Field(..., description="源文件")
|
||||
meta: Optional[Any] = Field(default=None, description="元数据")
|
||||
mediainfo: Any = Field(..., description="媒体信息")
|
||||
target_storage: str = Field(..., description="目标存储")
|
||||
target_path: Path = Field(..., description="目标路径")
|
||||
|
||||
@@ -5,6 +5,39 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def compute_subscribe_completed_episode(subscribe: Any) -> Optional[int]:
|
||||
"""
|
||||
计算订阅"已完成"集数派生值,仅用于响应填充,不入库。
|
||||
|
||||
普通电视剧按 ``total_episode - lack_episode`` 计算;洗版电视剧按订阅目标范围内
|
||||
priority==100 的分集数量,加上起始集前的逻辑完成集数计算。
|
||||
"""
|
||||
total_episode = getattr(subscribe, "total_episode", None) or 0
|
||||
if getattr(subscribe, "type", None) != MediaType.TV.value or not total_episode:
|
||||
return None
|
||||
|
||||
start_episode = getattr(subscribe, "start_episode", None) or 1
|
||||
if not getattr(subscribe, "best_version", None):
|
||||
lack = getattr(subscribe, "lack_episode", None) or 0
|
||||
return max(total_episode - lack, 0)
|
||||
|
||||
episode_priority = getattr(subscribe, "episode_priority", None) or {}
|
||||
if not episode_priority and getattr(subscribe, "current_priority", None) is not None:
|
||||
# 兼容只有整体优先级的洗版快照,响应派生值需与链路侧按集口径保持一致。
|
||||
episode_priority = {
|
||||
str(episode): int(getattr(subscribe, "current_priority"))
|
||||
for episode in range(start_episode, total_episode + 1)
|
||||
}
|
||||
priority_completed = sum(
|
||||
1
|
||||
for ep_key, priority in episode_priority.items()
|
||||
if str(ep_key).isdigit()
|
||||
and start_episode <= int(ep_key) <= total_episode
|
||||
and priority == 100
|
||||
)
|
||||
return min(max(start_episode - 1, 0), total_episode) + priority_completed
|
||||
|
||||
|
||||
class Subscribe(BaseModel):
|
||||
id: Optional[int] = None
|
||||
# 订阅名称
|
||||
@@ -95,26 +128,7 @@ class Subscribe(BaseModel):
|
||||
if self.completed_episode is not None:
|
||||
# 调用方显式提供过的值不覆盖
|
||||
return self
|
||||
total_episode = self.total_episode or 0
|
||||
if self.type != MediaType.TV.value or not total_episode:
|
||||
return self
|
||||
start_episode = self.start_episode or 1
|
||||
if not self.best_version:
|
||||
lack = self.lack_episode or 0
|
||||
self.completed_episode = max(total_episode - lack, 0)
|
||||
return self
|
||||
# 洗版口径:起始集前视为逻辑完成 + [start, total] 范围内 priority==100 命中。
|
||||
# ``start_episode > total_episode`` 属于异常配置,需把 "起始集前" 偏移截断到 total,
|
||||
# 防止 completed_episode 越过分母 total_episode。
|
||||
episode_priority = self.episode_priority or {}
|
||||
priority_completed = sum(
|
||||
1
|
||||
for ep_key, priority in episode_priority.items()
|
||||
if str(ep_key).isdigit()
|
||||
and start_episode <= int(ep_key) <= total_episode
|
||||
and priority == 100
|
||||
)
|
||||
self.completed_episode = min(max(start_episode - 1, 0), total_episode) + priority_completed
|
||||
self.completed_episode = compute_subscribe_completed_episode(self)
|
||||
return self
|
||||
|
||||
|
||||
|
||||
85
docs/subscribe-lifecycle.md
Normal file
85
docs/subscribe-lifecycle.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 订阅生命周期与订阅模式
|
||||
|
||||
本文说明主程序当前订阅领域的业务语义、字段所有权和使用方式,供维护订阅搜索、下载、进度刷新、重置和插件协同时参考。
|
||||
|
||||
## 核心模型
|
||||
|
||||
订阅是一条持续运行的媒体目标。它把用户想要的内容、搜索下载策略、已下载事实和完成进度保存在同一个订阅记录中。
|
||||
|
||||
订阅链路围绕四类状态工作:
|
||||
|
||||
| 状态类别 | 主要字段 | 含义 | 写入入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| 目标范围 | `type`、`season`、`start_episode`、`total_episode` | 订阅需要覆盖的媒体范围。电影视为单个目标;电视剧按季和集范围处理。 | 新增订阅、订阅编辑、剧集刷新 |
|
||||
| 下载事实 | `note`、`episode_priority` | 主程序或可信回填入口确认已经存在或已经下载的目标。`note` 表示存在事实,`episode_priority` 表示电视剧每集已知下载质量。 | 下载事实入口、backfill 入口、reset 入口 |
|
||||
| 进度摘要 | `lack_episode`、`current_priority`、`completed_episode` | 面向搜索、完成判定和展示的派生进度。`completed_episode` 由响应层按当前事实计算。 | progress 刷新入口、响应构造 |
|
||||
| 生命周期状态 | `state`、`last_update` | 订阅是否继续搜索,以及最近一次系统写入时间。 | 搜索下载流程、完成订阅、reset、状态更新 |
|
||||
|
||||
字段所有权应保持集中:下载事实入口写事实,progress 刷新入口写进度摘要,完成入口写完成状态,普通更新入口不应绕过这些语义直接改派生字段。
|
||||
|
||||
## 生命周期
|
||||
|
||||
| 阶段 | 触发来源 | 主程序行为 | 关键输出 |
|
||||
| --- | --- | --- | --- |
|
||||
| 创建订阅 | 用户、API、Agent、插件 | 记录媒体目标、订阅模式、季集范围、保存路径和下载策略。 | 初始订阅记录 |
|
||||
| 搜索匹配 | 定时任务、手动搜索、RSS/站点刷新 | 按订阅目标和模式过滤候选资源,计算需要下载或升级的目标。 | 匹配候选、待下载上下文 |
|
||||
| 下载选择 | 下载链路 | 将候选资源交给下载器,下载层可返回明确集数或完整覆盖确认。 | 下载上下文、剩余缺口 |
|
||||
| 事实记录 | 下载完成或可信 backfill | 写入 `note`,电视剧同步写入或提升 `episode_priority`。 | 已下载事实快照 |
|
||||
| 进度刷新 | 下载流程、backfill、剧集范围变化、显式刷新 | 普通电视剧按媒体库缺失结果刷新 `lack_episode`;洗版电视剧按目标范围和按集事实刷新 `lack_episode` 与 `current_priority`。 | 进度字段 |
|
||||
| 完成判定 | 下载流程末端、强制完成 | 根据媒体类型和订阅模式判断是否完成,完成前允许完成检查事件否决。 | 订阅历史、完成状态 |
|
||||
| 重置/回填 | 用户操作、插件、维护工具 | reset 清空事实和进度;backfill 写入调用方确认的外部存在事实。 | 新事实快照和进度摘要 |
|
||||
|
||||
## 订阅模式
|
||||
|
||||
MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普通电视剧订阅和分集洗版都是“按集目标”订阅:前者每集下载一次即可,后者允许同一集继续升级质量。全集洗版是整体资源质量目标,整包候选需要完整覆盖目标范围。
|
||||
|
||||
| 模式 | 适用媒体 | 目标口径 | 下载事实 | 进度口径 | 完成口径 | 典型用途 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 电影普通订阅 | 电影 | 单个电影目标 | `note=[1]` 表示电影已下载。 | 不维护电视剧缺集进度。 | 有下载或强制完成即可完成。 | 追新电影、补电影库 |
|
||||
| 电影洗版 | 电影 | 单个电影目标的整体质量 | `note=[1]` 表示电影已下载。 | `current_priority` 表示当前电影资源质量。 | `current_priority == 100` 或完成策略满足时完成。 | 用更高质量版本替换已有电影 |
|
||||
| 普通电视剧订阅 | 电视剧 | 季内目标集范围 | `note` 记录已存在或已下载集;`episode_priority` 记录每集已知下载质量。 | `lack_episode` 来自媒体库缺失结果。 | 目标范围无缺集时完成。 | 追剧、补缺集 |
|
||||
| 分集洗版 | 电视剧 | 季内目标集范围 | `note` 记录存在事实;`episode_priority` 记录每集质量。 | `lack_episode` 统计从未下载过任何版本的集;`current_priority` 是目标范围最低已知质量。 | 目标范围内每集达到顶级质量时完成洗版。 | 对单集逐步升级质量 |
|
||||
| 全集洗版 | 电视剧 | 完整目标集范围的整体质量 | 整包下载确认完整覆盖后,按目标范围写 `note` 和 `episode_priority`。 | `current_priority` 作为整包候选整体质量门槛,并由按集事实刷新。 | 完整目标范围达到顶级质量时完成洗版。 | 用完整季包替换已有剧集 |
|
||||
|
||||
## 按集订阅与全集洗版
|
||||
|
||||
普通电视剧订阅和分集洗版共享同一个按集事实源:
|
||||
|
||||
- `note` 表示某一集已经存在或已经下载。
|
||||
- `episode_priority` 表示某一集已知的下载质量,缺失 key 表示没有质量事实。
|
||||
- `lack_episode` 只表达“还缺多少集没有任何版本”,不表达“还有多少集需要升级质量”。
|
||||
- `current_priority` 对电视剧洗版是目标范围内最低已知质量摘要,缺失质量事实按 `0` 参与计算。
|
||||
|
||||
因此普通电视剧订阅和分集洗版可以互相转换。普通订阅下载产生的按集质量事实可被分集洗版继续使用;分集洗版产生的 `note` 也可被普通订阅继续作为已下载事实。
|
||||
|
||||
全集洗版关注完整目标范围的整体资源质量。整包候选必须完整覆盖 `[start_episode, total_episode]`,不是只覆盖当前缺口集。下载层确认完整覆盖后,主程序才可在资源缺少显式集数时按目标范围补写事实。
|
||||
|
||||
## 字段语义
|
||||
|
||||
| 字段 | 语义 | 关键约束 |
|
||||
| --- | --- | --- |
|
||||
| `note` | 已存在或已下载的电影项/电视剧集。 | 电影使用 `[1]`;电视剧使用集数列表。 |
|
||||
| `episode_priority` | 电视剧按集质量事实,键为集数,值为下载优先级。 | 只升不降;普通订阅和洗版订阅都可以写入。 |
|
||||
| `lack_episode` | 电视剧目标范围内仍缺少任意版本的集数。 | 普通订阅来自媒体库缺失结果;洗版订阅按 `note` 和 `episode_priority > 0` 计算。 |
|
||||
| `current_priority` | 洗版整体质量摘要。 | 电影洗版直接维护;电视剧洗版按目标范围内最低已知质量刷新。 |
|
||||
| `completed_episode` | 展示用完成集数。 | 响应层按当前事实计算,不作为主要事实源。 |
|
||||
| `state` | 订阅运行状态。 | 完成、暂停、重置等入口按业务状态维护。 |
|
||||
|
||||
## 用户视角
|
||||
|
||||
| 想要做什么 | 推荐模式 | 行为说明 | 注意事项 |
|
||||
| --- | --- | --- | --- |
|
||||
| 新剧更新后自动下载 | 普通电视剧订阅 | 每集下载到任意版本即可,后续缺集继续追。 | 下载过的集会作为事实保留,后续可切到分集洗版。 |
|
||||
| 已有剧集想逐集升级质量 | 分集洗版 | 每集按质量继续升级,达到顶级质量后不再下载该集。 | 低质量已下载集不计入缺集,但仍会作为待升级目标。 |
|
||||
| 想用完整季包替换整季 | 全集洗版 | 优先寻找完整覆盖目标范围的整包资源,并按整体质量门槛判断是否下载。 | 整包必须覆盖目标集范围,不只是覆盖当前缺口。 |
|
||||
| 新电影自动下载 | 电影普通订阅 | 下载到电影后完成订阅。 | 电影没有分集事实。 |
|
||||
| 电影已有版本想升级 | 电影洗版 | 用 `current_priority` 表达当前电影质量,下载更高质量版本。 | 完成策略按电影整体质量判断。 |
|
||||
| 手动入库或插件扫描到已有集 | backfill | 可信调用方写入已存在集;可选择是否提供 quality priority。 | `priority=None` 只表达存在事实;提供有效 priority 才表达质量事实。 |
|
||||
|
||||
## 维护边界
|
||||
|
||||
- 用户/API/Agent 的普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要。
|
||||
- 主程序下载链路产生的电视剧下载必须同时维护 `note` 和 `episode_priority`。
|
||||
- backfill 入口只接收调用方确认的外部存在事实,不主动扫描媒体库。
|
||||
- progress 刷新入口负责把当前事实转换为 `lack_episode` 和 TV 洗版 `current_priority`。
|
||||
- 插件可以调用主程序公开入口补事实或刷新进度,不应复制主程序缺集、洗版完成或当前优先级计算规则。
|
||||
@@ -330,6 +330,7 @@ def _build_tv_context(episode_list=None):
|
||||
),
|
||||
torrent_info=SimpleNamespace(title="Test Show S01 2160p", site_name="TestSite"),
|
||||
allowed_episodes=None,
|
||||
confirmed_full_coverage=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -362,6 +363,40 @@ def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target
|
||||
|
||||
assert downloads == []
|
||||
assert lefts == no_exists
|
||||
assert context.confirmed_full_coverage is False
|
||||
chain.download_single.assert_not_called()
|
||||
|
||||
|
||||
def test_batch_download_rejects_complete_coverage_when_only_missing_episodes_match(monkeypatch):
|
||||
"""
|
||||
完整覆盖要求目标范围全集,不能只覆盖当前缺口集。
|
||||
"""
|
||||
_FakeBatchTorrentHelper.episodes = [4, 5]
|
||||
monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper)
|
||||
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
||||
|
||||
chain = DownloadChain.__new__(DownloadChain)
|
||||
chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"]))
|
||||
chain.download_single = MagicMock(return_value="hash")
|
||||
|
||||
context = _build_tv_context()
|
||||
no_exists = {
|
||||
1: {
|
||||
1: NotExistMediaInfo(
|
||||
season=1,
|
||||
episodes=[4, 5],
|
||||
total_episode=5,
|
||||
start_episode=1,
|
||||
require_complete_coverage=True,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists)
|
||||
|
||||
assert downloads == []
|
||||
assert lefts == no_exists
|
||||
assert context.confirmed_full_coverage is False
|
||||
chain.download_single.assert_not_called()
|
||||
|
||||
|
||||
@@ -462,9 +497,43 @@ def test_batch_download_accepts_complete_coverage_when_files_cover_target_range(
|
||||
|
||||
assert downloads == [context]
|
||||
assert lefts == {}
|
||||
assert context.confirmed_full_coverage is True
|
||||
chain.download_single.assert_called_once()
|
||||
|
||||
|
||||
def test_batch_download_rejects_complete_coverage_when_files_have_same_count_but_wrong_range(monkeypatch):
|
||||
"""
|
||||
完整覆盖按目标集号集合判断,不能让同数量的偏移局部包通过。
|
||||
"""
|
||||
_FakeBatchTorrentHelper.episodes = list(range(1, 45))
|
||||
monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper)
|
||||
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
||||
|
||||
chain = DownloadChain.__new__(DownloadChain)
|
||||
chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"]))
|
||||
chain.download_single = MagicMock(return_value="hash")
|
||||
|
||||
context = _build_tv_context()
|
||||
no_exists = {
|
||||
1: {
|
||||
1: NotExistMediaInfo(
|
||||
season=1,
|
||||
episodes=[],
|
||||
total_episode=143,
|
||||
start_episode=100,
|
||||
require_complete_coverage=True,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists)
|
||||
|
||||
assert downloads == []
|
||||
assert lefts == no_exists
|
||||
assert context.confirmed_full_coverage is False
|
||||
chain.download_single.assert_not_called()
|
||||
|
||||
|
||||
def test_batch_download_accepts_complete_coverage_when_title_episodes_cover_target(monkeypatch):
|
||||
"""
|
||||
显式标出完整范围的候选也可满足完整覆盖任务。
|
||||
@@ -494,6 +563,7 @@ def test_batch_download_accepts_complete_coverage_when_title_episodes_cover_targ
|
||||
|
||||
assert downloads == [context]
|
||||
assert lefts == {}
|
||||
assert context.confirmed_full_coverage is True
|
||||
chain.download_torrent.assert_not_called()
|
||||
chain.download_single.assert_called_once()
|
||||
|
||||
@@ -527,6 +597,7 @@ def test_batch_download_rejects_complete_coverage_when_title_episodes_are_partia
|
||||
|
||||
assert downloads == []
|
||||
assert lefts == no_exists
|
||||
assert context.confirmed_full_coverage is False
|
||||
chain.download_torrent.assert_not_called()
|
||||
chain.download_single.assert_not_called()
|
||||
|
||||
@@ -561,6 +632,7 @@ def test_batch_download_complete_coverage_ignores_allowed_episode_narrowing(monk
|
||||
|
||||
assert downloads == []
|
||||
assert lefts == no_exists
|
||||
assert context.confirmed_full_coverage is False
|
||||
chain.download_torrent.assert_not_called()
|
||||
chain.download_single.assert_not_called()
|
||||
|
||||
@@ -593,4 +665,5 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch)
|
||||
|
||||
assert downloads == [context]
|
||||
assert lefts == {}
|
||||
assert context.confirmed_full_coverage is False
|
||||
chain.download_single.assert_called_once()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -265,6 +265,81 @@ class TransferJobManagerTest(unittest.TestCase):
|
||||
self.assertEqual(target_item, transferinfo.target_item)
|
||||
self.assertEqual(target_folder, transferinfo.target_diritem)
|
||||
|
||||
def test_single_file_transfer_intercept_event_carries_file_meta(self):
|
||||
"""
|
||||
单文件整理拦截事件应携带元数据,便于事件处理器按季集匹配。
|
||||
"""
|
||||
handler = TransHandler()
|
||||
source_item = FileItem(
|
||||
storage="alist",
|
||||
path="/downloads/Test.Show.S02E03.mkv",
|
||||
type="file",
|
||||
name="Test.Show.S02E03.mkv",
|
||||
basename="Test.Show.S02E03",
|
||||
extension="mkv",
|
||||
size=1024,
|
||||
modify_time=1715939275.0,
|
||||
)
|
||||
target_path = Path("/library")
|
||||
target_file = Path("/library/Test.Show.S02E03.mkv")
|
||||
target_folder = FileItem(
|
||||
storage="alist",
|
||||
type="dir",
|
||||
path="/library/",
|
||||
name="library",
|
||||
basename="library",
|
||||
)
|
||||
target_item = FileItem(
|
||||
storage="alist",
|
||||
path=target_file.as_posix(),
|
||||
type="file",
|
||||
name=target_file.name,
|
||||
basename=target_file.stem,
|
||||
extension="mkv",
|
||||
size=1024,
|
||||
)
|
||||
source_oper = SimpleNamespace(
|
||||
is_support_transtype=lambda transfer_type: True,
|
||||
move=lambda fileitem, path, name: True,
|
||||
)
|
||||
target_oper = SimpleNamespace(
|
||||
get_folder=lambda path: target_folder,
|
||||
get_item=lambda path: None,
|
||||
)
|
||||
in_meta = MetaVideo("Test.Show.S02E03")
|
||||
|
||||
with patch.object(
|
||||
TransHandler, "get_rename_path", return_value=target_file
|
||||
), patch(
|
||||
"app.modules.filemanager.transhandler.DirectoryHelper.get_media_root_path",
|
||||
return_value=Path("/library"),
|
||||
), patch.object(
|
||||
TransHandler,
|
||||
"_TransHandler__transfer_command",
|
||||
return_value=(target_item, ""),
|
||||
), patch(
|
||||
"app.modules.filemanager.transhandler.eventmanager.send_event",
|
||||
return_value=None,
|
||||
) as send_event:
|
||||
transferinfo = handler.transfer_media(
|
||||
fileitem=source_item,
|
||||
in_meta=in_meta,
|
||||
mediainfo=make_media_info(),
|
||||
target_storage="alist",
|
||||
target_path=target_path,
|
||||
transfer_type="move",
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper,
|
||||
need_scrape=True,
|
||||
need_notify=True,
|
||||
)
|
||||
|
||||
self.assertTrue(transferinfo.success)
|
||||
event_data = send_event.call_args.args[1]
|
||||
self.assertIs(in_meta, event_data.meta)
|
||||
self.assertEqual(2, event_data.meta.begin_season)
|
||||
self.assertEqual(3, event_data.meta.begin_episode)
|
||||
|
||||
def test_success_callback_uses_transfer_result_target_diritem(self):
|
||||
"""
|
||||
回调发送刮削事件时应直接使用整理结果里的目标目录项。
|
||||
|
||||
Reference in New Issue
Block a user