Skip to content

Commit 23d6ba0

Browse files
fix(subscribe): stop best-version per-episode redownload loop (#5781)
1 parent 6685bd0 commit 23d6ba0

5 files changed

Lines changed: 201 additions & 19 deletions

File tree

app/chain/download.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,20 @@ def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
511511
return 9999
512512
return no_exist[season].total_episode
513513

514+
def __apply_allowed_episodes(_need_episodes, _context: Context) -> Set[int]:
515+
"""
516+
根据候选携带的允许集裁剪 need_episodes,返回真正可下载的剧集集合。
517+
518+
语义:allowed_episodes 为 None 表示调用方未约束,沿用 need_episodes;
519+
非空集合则与 need_episodes 取交集;空集合(显式拒绝)会被交集自然消解为空。
520+
调用方根据返回集合是否为空决定是否跳过当前候选。
521+
"""
522+
effective = set(_need_episodes)
523+
allowed = _context.allowed_episodes
524+
if allowed is not None:
525+
effective &= set(allowed)
526+
return effective
527+
514528
# 发送资源选择事件,允许外部修改上下文数据
515529
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
516530
event_data = ResourceSelectionEventData(
@@ -695,8 +709,12 @@ def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
695709
# 整季的不处理
696710
if not torrent_episodes:
697711
continue
712+
# 上游对本候选施加的允许集(如洗版按集允许列表)裁剪本季缺集,得到真正可下载范围。
713+
effective_need = __apply_allowed_episodes(need_episodes, context)
714+
if not effective_need:
715+
continue
698716
# 为需要集的子集则下载
699-
if torrent_episodes.issubset(set(need_episodes)):
717+
if torrent_episodes.issubset(effective_need):
700718
# 下载
701719
logger.info(f"开始下载 {meta.title} ...")
702720
download_id = self.download_single(context, save_path=save_path,
@@ -756,10 +774,14 @@ def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
756774
# 没有需要集后退出
757775
if not need_episodes:
758776
break
777+
# 上游对本候选施加的允许集(如洗版按集允许列表)裁剪本季缺集,得到真正可下载范围。
778+
effective_need = __apply_allowed_episodes(need_episodes, context)
779+
if not effective_need:
780+
continue
759781
# 选中一个单季整季的或单季包括需要的所有集的
760782
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
761783
and (not meta.episode_list
762-
or set(meta.episode_list).intersection(set(need_episodes))) \
784+
or set(meta.episode_list).intersection(effective_need)) \
763785
and len(meta.season_list) == 1 \
764786
and meta.season_list[0] == need_season:
765787
# 检查种子看是否有需要的集
@@ -775,7 +797,7 @@ def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
775797
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
776798
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
777799
# 选中的集
778-
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
800+
selected_episodes = set(torrent_episodes).intersection(effective_need)
779801
if not selected_episodes:
780802
logger.info(f"{torrent.site_name} - {torrent.title} 没有需要的集,跳过...")
781803
continue

app/chain/subscribe.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,17 +1027,19 @@ def search(self, sid: Optional[int] = None, state: Optional[str] = 'N', manual:
10271027
)
10281028
continue
10291029
# 洗版时,只保留至少能提升一集优先级的资源
1030-
if (
1031-
torrent_mediainfo.type == MediaType.TV
1032-
and not self.__get_best_version_interested_episodes(
1030+
if torrent_mediainfo.type == MediaType.TV:
1031+
interested_episodes = self.__get_best_version_interested_episodes(
10331032
subscribe=subscribe,
10341033
context=context,
10351034
priority=torrent_info.pri_order,
10361035
)
1037-
):
1038-
logger.info(
1039-
f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集')
1040-
continue
1036+
if not interested_episodes:
1037+
logger.info(
1038+
f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集')
1039+
continue
1040+
# 将"本候选实际能升级到的集"作为允许下载集合下传到下载层,
1041+
# 防止标题元数据与实际种子文件错位导致同优先级集被重复下载。
1042+
context.allowed_episodes = set(interested_episodes)
10411043
if (
10421044
torrent_mediainfo.type != MediaType.TV
10431045
and subscribe.current_priority
@@ -1554,17 +1556,19 @@ def match(self, torrents: Dict[str, List[Context]]):
15541556

15551557
# 洗版时,优先级小于已下载优先级的不要
15561558
if subscribe.best_version:
1557-
if (
1558-
meta.type == MediaType.TV
1559-
and not self.__get_best_version_interested_episodes(
1559+
if meta.type == MediaType.TV:
1560+
interested_episodes = self.__get_best_version_interested_episodes(
15601561
subscribe=subscribe,
15611562
context=_context,
15621563
priority=torrent_info.pri_order,
15631564
)
1564-
):
1565-
logger.info(
1566-
f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集')
1567-
continue
1565+
if not interested_episodes:
1566+
logger.info(
1567+
f'{subscribe.name} 正在洗版,{torrent_info.title} 不包含可提升优先级的剧集')
1568+
continue
1569+
# 与 search() 路径对称:把"本候选实际能升级到的集"作为允许下载集合下传到下载层,
1570+
# 避免 RSS / 订阅刷新场景下标题元数据与种子文件错位导致同优先级集重复下载。
1571+
_context.allowed_episodes = set(interested_episodes)
15681572
if (
15691573
meta.type != MediaType.TV
15701574
and subscribe.current_priority

app/core/context.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import re
22
from dataclasses import dataclass, field
33
from datetime import datetime
4-
from typing import List, Dict, Any, Tuple, Optional
4+
from typing import List, Dict, Any, Tuple, Optional, Set
55

66
from app.core.config import settings
77
from app.core.meta import MetaBase
@@ -827,6 +827,8 @@ class Context:
827827
candidate_recognized: bool = False
828828
# 当前 media_info 是否为目标媒体回填,而不是候选自身识别结果。
829829
media_info_is_target: bool = False
830+
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
831+
allowed_episodes: Optional[Set[int]] = None
830832

831833
def to_dict(self):
832834
"""
@@ -841,4 +843,6 @@ def to_dict(self):
841843
"match_source": self.match_source,
842844
"candidate_recognized": self.candidate_recognized,
843845
"media_info_is_target": self.media_info_is_target,
846+
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
847+
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None,
844848
}

tests/run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMediaScrapeEvents))
3939

4040
# 测试订阅洗版匹配
41-
suite.addTest(SubscribeChainTest('test_is_episode_range_covered'))
41+
suite.addTest(SubscribeChainTest('test_is_episode_range_covered_matches_pending_episodes'))
4242

4343
# 运行测试
4444
runner = unittest.TextTestRunner()

tests/test_subscribe_chain.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,3 +773,155 @@ def test_check_resets_current_priority_when_new_episodes_expand_target_range(sel
773773
self.assertEqual(subscribe.total_episode, 5)
774774
self.assertEqual(subscribe.lack_episode, 2)
775775
self.assertEqual(subscribe.current_priority, 0)
776+
777+
def test_best_version_interested_episodes_excludes_same_priority(self):
778+
"""同 pri_order 的候选不应再把已达到该优先级的集列为可升级集。
779+
780+
回归场景:E2 已记录在 episode_priority 中为 99,候选种子标题覆盖 E2/E3 且
781+
其 pri_order=99;E2 不应进入 interested 集合,E3(None)则应进入。这是
782+
洗版重复下载链路的源头判定,必须保持"严格大于"语义。
783+
"""
784+
subscribe = self._build_subscribe(
785+
total_episode=3,
786+
episode_priority={"1": 100, "2": 99},
787+
current_priority=100,
788+
)
789+
context = SimpleNamespace(
790+
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
791+
selected_episodes=None,
792+
)
793+
794+
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
795+
subscribe=subscribe,
796+
context=context,
797+
priority=99,
798+
)
799+
800+
self.assertEqual(interested, [3])
801+
802+
def test_best_version_interested_episodes_uses_title_episode_list_for_full_pack(self):
803+
"""整包候选(标题展开的集列表)只把仍可提升优先级的集纳入 interested。
804+
805+
防回归场景:标题显示"第53-104集",实际目标范围只有 1..92,episode_priority
806+
已经把 1..82 升到 100,E83 已经记到 99。同 pri_order=99 的同一资源再来时,
807+
interested 应只剩 [84..92],绝不能含 E83,否则后续下载层会再下一次同优先级。
808+
"""
809+
subscribe = self._build_subscribe(
810+
total_episode=92,
811+
episode_priority={
812+
**{str(ep): 100 for ep in range(1, 83)},
813+
"83": 99,
814+
},
815+
current_priority=99,
816+
)
817+
context = SimpleNamespace(
818+
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
819+
selected_episodes=None,
820+
)
821+
822+
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
823+
subscribe=subscribe,
824+
context=context,
825+
priority=99,
826+
)
827+
828+
self.assertEqual(interested, list(range(84, 93)))
829+
830+
831+
class SubscribeFilterAllowedEpisodesTest(TestCase):
832+
"""验证洗版过滤循环会把 interested 集合落到 context.allowed_episodes 上。
833+
834+
这条用例直接覆盖回归点:当 __get_best_version_interested_episodes 返回非空
835+
集合时,候选必须带着允许集进入下载层,下游 batch_download 才能在标题元数据
836+
与实际种子文件错位时做出正确取舍。
837+
"""
838+
839+
def _build_subscribe(self, **overrides):
840+
return SubscribeChainTest()._build_subscribe(**overrides)
841+
842+
def test_filter_writes_allowed_episodes_to_context(self):
843+
subscribe = self._build_subscribe(
844+
total_episode=92,
845+
episode_priority={
846+
**{str(ep): 100 for ep in range(1, 83)},
847+
"83": 99,
848+
},
849+
current_priority=99,
850+
)
851+
context = SimpleNamespace(
852+
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
853+
selected_episodes=None,
854+
)
855+
856+
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
857+
subscribe=subscribe,
858+
context=context,
859+
priority=99,
860+
)
861+
# 复刻 subscribe.py 过滤循环中的赋值,确认结果作为允许集传递。
862+
context.allowed_episodes = set(interested) if interested else None
863+
864+
self.assertIsNotNone(context.allowed_episodes)
865+
self.assertEqual(context.allowed_episodes, set(range(84, 93)))
866+
# 关键回归点:E83 已达到 99,不在允许集内;下游交集后即不会再下 E83。
867+
self.assertNotIn(83, context.allowed_episodes)
868+
869+
def test_filter_leaves_allowed_episodes_none_when_no_upgrade(self):
870+
"""同 pri_order 且目标集均已达到该优先级时,候选不应被放行,
871+
相应地也不会有 allowed_episodes 被写入。"""
872+
subscribe = self._build_subscribe(
873+
total_episode=3,
874+
episode_priority={"1": 100, "2": 99, "3": 99},
875+
current_priority=99,
876+
)
877+
context = SimpleNamespace(
878+
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
879+
selected_episodes=None,
880+
)
881+
882+
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
883+
subscribe=subscribe,
884+
context=context,
885+
priority=99,
886+
)
887+
888+
self.assertEqual(interested, [])
889+
890+
def test_filter_writes_allowed_episodes_in_match_path(self):
891+
"""RSS/订阅刷新分支 match() 需要与 search() 对称地写入 allowed_episodes。
892+
893+
match() 路径下候选是 `_context = copy.copy(context)`,再走 best_version
894+
判定。此用例复刻 match() 的过滤序列,验证浅拷贝后的 _context 在写入
895+
allowed_episodes 时不会污染原始 context,且写入结果与 search() 一致。
896+
若 match() 分支漏写 allowed_episodes,下游 batch_download 将看不到允许集
897+
约束,回归到 2c458317 之前的同优先级重复下载状态。
898+
"""
899+
import copy
900+
901+
subscribe = self._build_subscribe(
902+
total_episode=92,
903+
episode_priority={
904+
**{str(ep): 100 for ep in range(1, 83)},
905+
"83": 99,
906+
},
907+
current_priority=99,
908+
)
909+
original_context = SimpleNamespace(
910+
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
911+
selected_episodes=None,
912+
allowed_episodes=None,
913+
)
914+
_context = copy.copy(original_context)
915+
916+
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
917+
subscribe=subscribe,
918+
context=_context,
919+
priority=99,
920+
)
921+
# 复刻 match() 中的赋值;search() 与 match() 必须保持同形以避免分支漏改。
922+
if interested:
923+
_context.allowed_episodes = set(interested)
924+
925+
self.assertEqual(_context.allowed_episodes, set(range(84, 93)))
926+
# 浅拷贝 + 新字段写入不应反向污染源 context(match() 中 contexts 缓存可能跨多次匹配复用)。
927+
self.assertIsNone(original_context.allowed_episodes)

0 commit comments

Comments
 (0)