Skip to content

Commit 7daeb17

Browse files
refactor(subscribe): 统一 lack_episode 语义并暴露 completed_episode 派生字段 (#5817)
1 parent 2b5528c commit 7daeb17

4 files changed

Lines changed: 135 additions & 43 deletions

File tree

app/api/endpoints/subscribe.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ async def update_subscribe(
116116
subscribe_dict = subscribe_in.model_dump()
117117
if subscribe_in.episode_priority is None:
118118
subscribe_dict.pop("episode_priority", None)
119+
# completed_episode 是响应派生字段,禁止写入持久层
120+
subscribe_dict.pop("completed_episode", None)
119121
if not subscribe_in.lack_episode:
120122
# 没有缺失集数时,缺失集数清空,避免更新为0
121123
subscribe_dict.pop("lack_episode")

app/chain/subscribe.py

Lines changed: 91 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,42 @@ def _get_pending_best_version_episodes(cls, subscribe: Subscribe) -> List[int]:
139139
return cls.__get_pending_best_version_episodes_with_priority(subscribe)
140140

141141
@classmethod
142-
def get_best_version_lack_episode(
143-
cls,
144-
subscribe: Subscribe,
145-
episode_priority: Optional[dict] = None,
146-
) -> int:
142+
def compute_completed_episode(cls, subscribe: Subscribe) -> Optional[int]:
147143
"""
148-
获取洗版订阅当前剩余待洗剧集数。
144+
计算订阅"已完成"集数派生值,仅用于响应填充,不入库。
145+
146+
语义:
147+
- 普通订阅 (best_version=0):``max(total_episode - lack_episode, 0)``,即媒体库已入库集数。
148+
- 洗版订阅 (best_version=1,含分集与全集洗版):
149+
``(start_episode - 1) + (episode_priority 中 priority==100 且 ep ∈ [start, total] 的命中数)``。
150+
start_episode 之前的集不在订阅范围内,视为"逻辑上已完成",与主文案分母 total_episode 对齐。
151+
152+
- 入参:完整 Subscribe ORM/Schema 对象,需至少包含 best_version、type、start_episode、
153+
total_episode、lack_episode、episode_priority 字段。
154+
- 返回:完成集数;电影或缺少 total_episode 时返回 None。
149155
"""
150-
if not subscribe.best_version or subscribe.type != MediaType.TV.value:
151-
return subscribe.lack_episode or 0
152-
return len(cls.__get_pending_best_version_episodes_with_priority(subscribe, episode_priority))
156+
total_episode = subscribe.total_episode or 0
157+
if subscribe.type != MediaType.TV.value or not total_episode:
158+
return None
159+
160+
start_episode = subscribe.start_episode or 1
161+
162+
if not subscribe.best_version:
163+
lack = subscribe.lack_episode or 0
164+
return max(total_episode - lack, 0)
165+
166+
# 洗版口径:start 之前的集视为已完成 + 范围内 priority==100 命中。
167+
# ``start_episode > total_episode`` 是异常配置,需把"起始集前"偏移截断到 total,
168+
# 避免 completed 越过分母 total_episode。
169+
episode_priority = subscribe.episode_priority or {}
170+
priority_completed = sum(
171+
1
172+
for ep_key, priority in episode_priority.items()
173+
if str(ep_key).isdigit()
174+
and start_episode <= int(ep_key) <= total_episode
175+
and priority == 100
176+
)
177+
return min(max(start_episode - 1, 0), total_episode) + priority_completed
153178

154179
@classmethod
155180
def get_best_version_current_priority(
@@ -1141,18 +1166,16 @@ def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
11411166
return
11421167

11431168
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
1144-
lack_episode = self.get_best_version_lack_episode(subscribe, episode_priority)
1169+
# lack_episode 由 finish_subscribe_or_not -> __update_lack_episodes 按媒体库实况维护,本处不写
11451170
update_data: Dict[str, Any] = {
11461171
"episode_priority": episode_priority,
11471172
"last_update": now,
11481173
"current_priority": current_priority,
1149-
"lack_episode": lack_episode,
11501174
}
11511175

11521176
SubscribeOper().update(subscribe.id, update_data)
11531177
subscribe.episode_priority = episode_priority
11541178
subscribe.current_priority = current_priority
1155-
subscribe.lack_episode = lack_episode
11561179
subscribe.last_update = now
11571180

11581181
completed_episodes = self.__get_best_version_completed_episodes(subscribe)
@@ -1197,26 +1220,28 @@ def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainf
11971220
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
11981221
# 是否完成订阅
11991222
if not subscribe.best_version:
1200-
# 更新订阅剩余集数和时间
1223+
# 普通订阅:先按 lefts 写 lack,再判断完成
12011224
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
12021225
update_date=bool(downloads))
1203-
# 判断是否需要完成订阅
12041226
if ((no_lefts and meta.type == MediaType.TV)
12051227
or (downloads and meta.type == MediaType.MOVIE)
12061228
or force):
12071229
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
12081230
else:
1209-
# 未下载到内容且不完整
12101231
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
1211-
elif downloads:
1212-
# 洗版下载到了内容,更新资源优先级
1232+
return
1233+
1234+
# 洗版订阅:本轮若有下载先更新 episode_priority / current_priority,让 __update_lack_episodes
1235+
# 读取到包含本轮新下载的集;否则 lack 会慢一个搜索周期才反映新下载。
1236+
if downloads:
12131237
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
12141238
mediainfo=mediainfo, downloads=downloads)
1215-
elif self.__is_best_version_complete(subscribe):
1239+
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
1240+
update_date=bool(downloads))
1241+
if self.__is_best_version_complete(subscribe):
12161242
# 洗版完成
12171243
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
1218-
else:
1219-
# 洗版,未下载到内容
1244+
elif not downloads:
12201245
logger.info(f'{mediainfo.title_year} 继续洗版 ...')
12211246

12221247
def refresh(self):
@@ -1662,26 +1687,24 @@ def check(self):
16621687
current_priority = None
16631688
if not subscribe.manual_total_episode and len(episodes):
16641689
total_episode = len(episodes)
1690+
# 总集数增长按 delta 同步抬升 lack
1691+
lack_episode = (subscribe.lack_episode or 0) + (total_episode - (subscribe.total_episode or 0))
16651692
if subscribe.best_version and subscribe.type == MediaType.TV.value:
1693+
# 为新增集补齐 episode_priority 初始项(priority=0)
16661694
old_total_episode = subscribe.total_episode or 0
16671695
episode_priority = self.__get_episode_priority(subscribe)
16681696
for episode in range(old_total_episode + 1, total_episode + 1):
16691697
episode_priority.setdefault(str(episode), 0)
16701698
subscribe.total_episode = total_episode
16711699
subscribe.episode_priority = episode_priority
1672-
lack_episode = self.get_best_version_lack_episode(subscribe, episode_priority)
16731700
current_priority = self.get_best_version_current_priority(subscribe, episode_priority)
1674-
else:
1675-
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
16761701
logger.info(
16771702
f'订阅 {subscribe.name} 总集数变化,更新总集数为{total_episode},缺失集数为{lack_episode} ...')
16781703
else:
16791704
total_episode = subscribe.total_episode
1705+
lack_episode = subscribe.lack_episode
16801706
if subscribe.best_version and subscribe.type == MediaType.TV.value:
1681-
lack_episode = self.get_best_version_lack_episode(subscribe)
16821707
current_priority = self.get_best_version_current_priority(subscribe)
1683-
else:
1684-
lack_episode = subscribe.lack_episode
16851708
# 更新TMDB信息
16861709
update_data = {
16871710
"name": mediainfo.title,
@@ -1891,21 +1914,23 @@ def __update_lack_episodes(lefts: Dict[Union[int, str], Dict[int, schemas.NotExi
18911914
mediainfo: MediaInfo,
18921915
update_date: Optional[bool] = False):
18931916
"""
1894-
更新订阅剩余集数及时间
1917+
写入订阅 lack_episode,可选同时刷新 last_update。
1918+
1919+
lack 统一语义为"订阅范围内尚未下载到任何版本的集数"。
1920+
- 普通订阅:lack 从 ``lefts`` 提取(lefts 已在 ``__get_subscribe_no_exits`` 里扣过 note)
1921+
- 洗版订阅:lack = ``[start, total]`` 范围内既不在 note 也不在 episode_priority(>0) 命中的集数。
1922+
洗版的 lefts 由 ``check_and_handle_existing_media`` 按 priority<100 构造,承担"搜索目标"职责,
1923+
与"未下载"维度并不同义——若复用会把"已下载但待升级"的集错算成 lack。
18951924
"""
18961925
update_data = {}
18971926
if update_date:
18981927
update_data["last_update"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
1899-
if subscribe.best_version and subscribe.type == MediaType.TV.value:
1900-
lack_episode = len(SubscribeChain._get_pending_best_version_episodes(subscribe))
1901-
logger.info(f"{mediainfo.title_year}{subscribe.season} 剩余待洗剧集数为{lack_episode} ...")
1902-
update_data["lack_episode"] = lack_episode
1903-
if update_data:
1904-
SubscribeOper().update(subscribe.id, update_data)
1905-
return
19061928
if subscribe.type == MediaType.TV.value:
1907-
if not lefts:
1908-
# 如果 lefts 为空,表示没有缺失集数,直接设置 lack_episode 为 0
1929+
if subscribe.best_version:
1930+
lack_episode = SubscribeChain.__compute_best_version_lack_episode(subscribe)
1931+
logger.info(f"{mediainfo.title_year}{subscribe.season} 剩余未下载剧集数为{lack_episode} ...")
1932+
elif not lefts:
1933+
# lefts 为空:媒体库实缺为 0
19091934
lack_episode = 0
19101935
logger.info(f'{mediainfo.title_year} 没有缺失集数,直接更新为 0 ...')
19111936
else:
@@ -1928,6 +1953,36 @@ def __update_lack_episodes(lefts: Dict[Union[int, str], Dict[int, schemas.NotExi
19281953
if update_data:
19291954
SubscribeOper().update(subscribe.id, update_data)
19301955

1956+
@staticmethod
1957+
def __compute_best_version_lack_episode(subscribe: Subscribe) -> int:
1958+
"""
1959+
计算洗版订阅"未下载集数":在 ``[start, total]`` 范围内排除已在 ``note`` 或
1960+
``episode_priority`` (>0) 中记账的集。priority<100 但 >0 的集视为"已下载、待升级",
1961+
不计入 lack——与 UI 上"已下载 = total - lack"展示口径一致。
1962+
"""
1963+
total_episode = subscribe.total_episode or 0
1964+
if not total_episode:
1965+
return 0
1966+
start_episode = subscribe.start_episode or 1
1967+
if total_episode < start_episode:
1968+
return 0
1969+
target_episodes = set(range(start_episode, total_episode + 1))
1970+
downloaded: set = set()
1971+
for ep in (subscribe.note or []):
1972+
try:
1973+
downloaded.add(int(ep))
1974+
except (TypeError, ValueError):
1975+
continue
1976+
for ep_str, priority in (subscribe.episode_priority or {}).items():
1977+
if not str(ep_str).isdigit():
1978+
continue
1979+
try:
1980+
if float(priority) > 0:
1981+
downloaded.add(int(ep_str))
1982+
except (TypeError, ValueError):
1983+
continue
1984+
return len(target_episodes - downloaded)
1985+
19311986
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase):
19321987
"""
19331988
完成订阅

app/schemas/subscribe.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from typing import Optional, List, Dict, Any
22

3-
from pydantic import BaseModel, Field, ConfigDict
3+
from pydantic import BaseModel, Field, ConfigDict, model_validator
4+
5+
from app.schemas.types import MediaType
46

57

68
class Subscribe(BaseModel):
@@ -45,6 +47,8 @@ class Subscribe(BaseModel):
4547
start_episode: Optional[int] = 0
4648
# 缺失集数
4749
lack_episode: Optional[int] = 0
50+
# 已完成集数
51+
completed_episode: Optional[int] = None
4852
# 附加信息
4953
note: Optional[Any] = None
5054
# 状态:N-新建, R-订阅中
@@ -82,6 +86,37 @@ class Subscribe(BaseModel):
8286

8387
model_config = ConfigDict(from_attributes=True)
8488

89+
@model_validator(mode="after")
90+
def _fill_completed_episode(self) -> "Subscribe":
91+
"""
92+
填充 ``completed_episode`` 派生字段。电视剧订阅按 best_version 分支计算,
93+
电影或缺少 total_episode 时保持 None。
94+
"""
95+
if self.completed_episode is not None:
96+
# 调用方显式提供过的值不覆盖
97+
return self
98+
total_episode = self.total_episode or 0
99+
if self.type != MediaType.TV.value or not total_episode:
100+
return self
101+
start_episode = self.start_episode or 1
102+
if not self.best_version:
103+
lack = self.lack_episode or 0
104+
self.completed_episode = max(total_episode - lack, 0)
105+
return self
106+
# 洗版口径:起始集前视为逻辑完成 + [start, total] 范围内 priority==100 命中。
107+
# ``start_episode > total_episode`` 属于异常配置,需把 "起始集前" 偏移截断到 total,
108+
# 防止 completed_episode 越过分母 total_episode。
109+
episode_priority = self.episode_priority or {}
110+
priority_completed = sum(
111+
1
112+
for ep_key, priority in episode_priority.items()
113+
if str(ep_key).isdigit()
114+
and start_episode <= int(ep_key) <= total_episode
115+
and priority == 100
116+
)
117+
self.completed_episode = min(max(start_episode - 1, 0), total_episode) + priority_completed
118+
return self
119+
85120

86121
class SubscribeShare(BaseModel):
87122
# 分享ID

tests/test_subscribe_chain.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,6 @@ def test_best_version_progress_helpers_return_remaining_priority(self):
413413
current_priority=100,
414414
)
415415

416-
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 3)
417416
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 90)
418417
self.assertFalse(SubscribeChain.is_best_version_complete(subscribe))
419418

@@ -424,7 +423,6 @@ def test_best_version_progress_helpers_mark_complete_when_all_target_episodes_do
424423
current_priority=90,
425424
)
426425

427-
self.assertEqual(SubscribeChain.get_best_version_lack_episode(subscribe), 0)
428426
self.assertEqual(SubscribeChain.get_best_version_current_priority(subscribe), 100)
429427
self.assertTrue(SubscribeChain.is_best_version_complete(subscribe))
430428

@@ -705,7 +703,8 @@ def test_update_subscribe_priority_uses_selected_episodes(self):
705703
payload = subscribe_oper.update.call_args.args[1]
706704
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 80, "3": 90, "4": 60})
707705
self.assertEqual(payload["current_priority"], 90)
708-
self.assertEqual(payload["lack_episode"], 3)
706+
# update_subscribe_priority 不再回写 lack_episode;lack 由下载链路末端的 __update_lack_episodes 维护
707+
self.assertNotIn("lack_episode", payload)
709708
self.assertEqual(subscribe.episode_priority, {"1": 100, "2": 80, "3": 90, "4": 60})
710709
self.assertEqual(subscribe.current_priority, 90)
711710
self.assertEqual(subscribe.lack_episode, 3)
@@ -743,7 +742,8 @@ def test_update_subscribe_priority_marks_complete_when_all_target_episodes_done(
743742
payload = subscribe_oper.update.call_args.args[1]
744743
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
745744
self.assertEqual(payload["current_priority"], 100)
746-
self.assertEqual(payload["lack_episode"], 0)
745+
# 完成判定仍由 __is_best_version_complete 走 episode_priority 字典做出,lack_episode 不参与
746+
self.assertNotIn("lack_episode", payload)
747747
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
748748

749749
def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadata(self):
@@ -776,7 +776,7 @@ def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadat
776776
payload = subscribe_oper.update.call_args.args[1]
777777
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
778778
self.assertEqual(payload["current_priority"], 100)
779-
self.assertEqual(payload["lack_episode"], 0)
779+
self.assertNotIn("lack_episode", payload)
780780
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
781781

782782
def test_episode_best_version_updates_all_episodes_when_full_pack_has_no_episode_metadata(self):
@@ -809,7 +809,7 @@ def test_episode_best_version_updates_all_episodes_when_full_pack_has_no_episode
809809
payload = subscribe_oper.update.call_args.args[1]
810810
self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100})
811811
self.assertEqual(payload["current_priority"], 100)
812-
self.assertEqual(payload["lack_episode"], 0)
812+
self.assertNotIn("lack_episode", payload)
813813
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
814814

815815
def test_check_resets_current_priority_when_new_episodes_expand_target_range(self):

0 commit comments

Comments
 (0)