refactor(subscribe): 统一 lack_episode 语义并暴露 completed_episode 派生字段 (#5817)

This commit is contained in:
InfinityPacer
2026-05-22 19:34:25 +08:00
committed by GitHub
parent 2b5528c0ac
commit 7daeb17d85
4 changed files with 135 additions and 43 deletions

View File

@@ -116,6 +116,8 @@ async def update_subscribe(
subscribe_dict = subscribe_in.model_dump()
if subscribe_in.episode_priority is None:
subscribe_dict.pop("episode_priority", None)
# completed_episode 是响应派生字段,禁止写入持久层
subscribe_dict.pop("completed_episode", None)
if not subscribe_in.lack_episode:
# 没有缺失集数时缺失集数清空避免更新为0
subscribe_dict.pop("lack_episode")

View File

@@ -139,17 +139,42 @@ class SubscribeChain(ChainBase):
return cls.__get_pending_best_version_episodes_with_priority(subscribe)
@classmethod
def get_best_version_lack_episode(
cls,
subscribe: Subscribe,
episode_priority: Optional[dict] = None,
) -> int:
def compute_completed_episode(cls, subscribe: Subscribe) -> Optional[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。
"""
if not subscribe.best_version or subscribe.type != MediaType.TV.value:
return subscribe.lack_episode or 0
return len(cls.__get_pending_best_version_episodes_with_priority(subscribe, episode_priority))
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 not subscribe.best_version:
lack = subscribe.lack_episode or 0
return max(total_episode - lack, 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
@classmethod
def get_best_version_current_priority(
@@ -1141,18 +1166,16 @@ class SubscribeChain(ChainBase):
return
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
lack_episode = self.get_best_version_lack_episode(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,
"lack_episode": lack_episode,
}
SubscribeOper().update(subscribe.id, update_data)
subscribe.episode_priority = episode_priority
subscribe.current_priority = current_priority
subscribe.lack_episode = lack_episode
subscribe.last_update = now
completed_episodes = self.__get_best_version_completed_episodes(subscribe)
@@ -1197,26 +1220,28 @@ class SubscribeChain(ChainBase):
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 ((no_lefts and meta.type == MediaType.TV)
or (downloads and meta.type == MediaType.MOVIE)
or force):
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
else:
# 未下载到内容且不完整
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
elif downloads:
# 洗版下载到了内容,更新资源优先级
return
# 洗版订阅:本轮若有下载先更新 episode_priority / current_priority让 __update_lack_episodes
# 读取到包含本轮新下载的集;否则 lack 会慢一个搜索周期才反映新下载。
if downloads:
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, downloads=downloads)
elif self.__is_best_version_complete(subscribe):
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
update_date=bool(downloads))
if self.__is_best_version_complete(subscribe):
# 洗版完成
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
else:
# 洗版,未下载到内容
elif not downloads:
logger.info(f'{mediainfo.title_year} 继续洗版 ...')
def refresh(self):
@@ -1662,26 +1687,24 @@ class SubscribeChain(ChainBase):
current_priority = None
if not subscribe.manual_total_episode and len(episodes):
total_episode = len(episodes)
# 总集数增长按 delta 同步抬升 lack
lack_episode = (subscribe.lack_episode or 0) + (total_episode - (subscribe.total_episode or 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
lack_episode = self.get_best_version_lack_episode(subscribe, episode_priority)
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
else:
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
logger.info(
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
else:
total_episode = subscribe.total_episode
lack_episode = subscribe.lack_episode
if subscribe.best_version and subscribe.type == MediaType.TV.value:
lack_episode = self.get_best_version_lack_episode(subscribe)
current_priority = self.get_best_version_current_priority(subscribe)
else:
lack_episode = subscribe.lack_episode
# 更新TMDB信息
update_data = {
"name": mediainfo.title,
@@ -1891,21 +1914,23 @@ class SubscribeChain(ChainBase):
mediainfo: MediaInfo,
update_date: Optional[bool] = False):
"""
更新订阅剩余集数及时间
写入订阅 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。
"""
update_data = {}
if update_date:
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if subscribe.best_version and subscribe.type == MediaType.TV.value:
lack_episode = len(SubscribeChain._get_pending_best_version_episodes(subscribe))
logger.info(f"{mediainfo.title_year}{subscribe.season} 剩余待洗剧集数为{lack_episode} ...")
update_data["lack_episode"] = lack_episode
if update_data:
SubscribeOper().update(subscribe.id, update_data)
return
if subscribe.type == MediaType.TV.value:
if not lefts:
# 如果 lefts 为空,表示没有缺失集数,直接设置 lack_episode 为 0
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:
@@ -1928,6 +1953,36 @@ class SubscribeChain(ChainBase):
if update_data:
SubscribeOper().update(subscribe.id, update_data)
@staticmethod
def __compute_best_version_lack_episode(subscribe: Subscribe) -> int:
"""
计算洗版订阅"未下载集数":在 ``[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 []):
try:
downloaded.add(int(ep))
except (TypeError, ValueError):
continue
for ep_str, priority in (subscribe.episode_priority or {}).items():
if not str(ep_str).isdigit():
continue
try:
if float(priority) > 0:
downloaded.add(int(ep_str))
except (TypeError, ValueError):
continue
return len(target_episodes - downloaded)
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase):
"""
完成订阅

View File

@@ -1,6 +1,8 @@
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, model_validator
from app.schemas.types import MediaType
class Subscribe(BaseModel):
@@ -45,6 +47,8 @@ class Subscribe(BaseModel):
start_episode: Optional[int] = 0
# 缺失集数
lack_episode: Optional[int] = 0
# 已完成集数
completed_episode: Optional[int] = None
# 附加信息
note: Optional[Any] = None
# 状态N-新建, R-订阅中
@@ -82,6 +86,37 @@ class Subscribe(BaseModel):
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def _fill_completed_episode(self) -> "Subscribe":
"""
填充 ``completed_episode`` 派生字段。电视剧订阅按 best_version 分支计算,
电影或缺少 total_episode 时保持 None。
"""
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
return self
class SubscribeShare(BaseModel):
# 分享ID

View File

@@ -413,7 +413,6 @@ class SubscribeChainTest(TestCase):
current_priority=100,
)
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 3)
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 90)
self.assertFalse(SubscribeChain.is_best_version_complete(subscribe))
@@ -424,7 +423,6 @@ class SubscribeChainTest(TestCase):
current_priority=90,
)
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 0)
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 100)
self.assertTrue(SubscribeChain.is_best_version_complete(subscribe))
@@ -705,7 +703,8 @@ class SubscribeChainTest(TestCase):
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 80, "3": 90, "4": 60})
self.assertEqual(payload["current_priority"], 90)
self.assertEqual(payload["lack_episode"], 3)
# update_subscribe_priority 不再回写 lack_episodelack 由下载链路末端的 __update_lack_episodes 维护
self.assertNotIn("lack_episode", payload)
self.assertEqual(subscribe.episode_priority, {"1": 100, "2": 80, "3": 90, "4": 60})
self.assertEqual(subscribe.current_priority, 90)
self.assertEqual(subscribe.lack_episode, 3)
@@ -743,7 +742,8 @@ class SubscribeChainTest(TestCase):
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
self.assertEqual(payload["current_priority"], 100)
self.assertEqual(payload["lack_episode"], 0)
# 完成判定仍由 __is_best_version_complete 走 episode_priority 字典做出lack_episode 不参与
self.assertNotIn("lack_episode", payload)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadata(self):
@@ -776,7 +776,7 @@ class SubscribeChainTest(TestCase):
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
self.assertEqual(payload["current_priority"], 100)
self.assertEqual(payload["lack_episode"], 0)
self.assertNotIn("lack_episode", payload)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_episode_best_version_updates_all_episodes_when_full_pack_has_no_episode_metadata(self):
@@ -809,7 +809,7 @@ class SubscribeChainTest(TestCase):
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
self.assertEqual(payload["current_priority"], 100)
self.assertEqual(payload["lack_episode"], 0)
self.assertNotIn("lack_episode", payload)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_check_resets_current_priority_when_new_episodes_expand_target_range(self):