Merge remote-tracking branch 'origin/v2' into v2

This commit is contained in:
jxxghp
2026-06-29 07:08:08 +08:00
11 changed files with 1752 additions and 298 deletions

View File

@@ -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)
# 更新仍需集数

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,

View 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):

View File

@@ -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="目标路径")

View File

@@ -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

View 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`
- 插件可以调用主程序公开入口补事实或刷新进度,不应复制主程序缺集、洗版完成或当前优先级计算规则。

View File

@@ -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

View File

@@ -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):
"""
回调发送刮削事件时应直接使用整理结果里的目标目录项。