mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-03 14:39:56 +08:00
refactor(subscribe): 统一 lack_episode 语义并暴露 completed_episode 派生字段 (#5817)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
完成订阅
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_episode;lack 由下载链路末端的 __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):
|
||||
|
||||
Reference in New Issue
Block a user