fix(subscribe): persist best-version downloads to note and read it back (#5783)

This commit is contained in:
InfinityPacer
2026-05-18 17:09:56 +08:00
committed by GitHub
parent 22bb15583d
commit f5eeeebeba
2 changed files with 159 additions and 4 deletions

View File

@@ -1189,11 +1189,14 @@ 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:
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
# 是否完成订阅
if not subscribe.best_version:
# 订阅存在待定策略,不管是否已完成,均需更新订阅信息
# 更新订阅已下载信息
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
# 更新订阅剩余集数和时间
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, mediainfo=mediainfo,
update_date=bool(downloads))
@@ -1856,7 +1859,11 @@ class SubscribeChain(ChainBase):
@staticmethod
def __get_downloaded(subscribe: Subscribe) -> List[int]:
"""
获取已下载过的集数或电影
获取已下载过的集数或电影
洗版分支只返回 priority==100 的完成集priority<100 的集仍要继续搜索更高
优先级版本,不能并入返回值(会让下游把 pending 减空、订阅卡死)。
note 由非洗版分支消费,用于洗版关闭后的迁移读取。
"""
if subscribe.best_version:
if subscribe.type == MediaType.TV.value:

View File

@@ -925,3 +925,151 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
self.assertEqual(_context.allowed_episodes, set(range(84, 93)))
# 浅拷贝 + 新字段写入不应反向污染源 contextmatch() 中 contexts 缓存可能跨多次匹配复用)。
self.assertIsNone(original_context.allowed_episodes)
class SubscribeNoteTrackingTest(TestCase):
"""覆盖洗版与非洗版下 subscribe.note 的下载历史追踪。
回归目标finish_subscribe_or_not 必须在所有订阅模式下都把本轮下载的集数追加进
subscribe.note__get_downloaded 在洗版分支必须把 note 与 episode_priority==100
的完成集合并返回,避免迁移或低优先级下载场景下已下集被误判为"未下载"
"""
def _build_subscribe(self, **overrides):
return SubscribeChainTest()._build_subscribe(**overrides)
@staticmethod
def _build_download_context(episodes):
"""构造一个最小化下载 context只携带 finish_subscribe_or_not / __update_subscribe_note 路径会读到的字段。"""
return SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(episodes)),
media_info=SimpleNamespace(
type=MediaType.TV,
tmdb_id=1,
douban_id=None,
),
torrent_info=SimpleNamespace(pri_order=99, title="fake-torrent"),
selected_episodes=list(episodes),
)
def test_finish_subscribe_writes_note_for_best_version_downloads(self):
"""洗版分支若产生 downloadssubscribe.note 必须被追加,不再被 best_version 标志拦截。
旧逻辑只在非洗版分支调用 __update_subscribe_note导致 best_version=1 时
下载历史只落在 episode_priority用户切回普通订阅或排障对账时缺失"下过哪些集"
的事实源。这条用例验证修复后两个分支都会写 note。
"""
subscribe = self._build_subscribe(
best_version=1,
total_episode=92,
episode_priority={"1": 100},
note=[1],
)
chain = SubscribeChain()
downloads = [self._build_download_context([83])]
captured_updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
captured_updates.append((subscribe_id, payload))
def get(self, *args, **kwargs):
return subscribe
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object(
SubscribeChain,
"update_subscribe_priority",
), patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
):
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.TV),
mediainfo=SimpleNamespace(title_year="Test Show (2026)", type=MediaType.TV,
tmdb_id=1, douban_id=None),
downloads=downloads,
lefts=None,
)
# note 更新必然发生在 SubscribeOper.update 上,定位"note" 键的最近一次写入。
note_writes = [payload["note"] for _, payload in captured_updates if "note" in payload]
self.assertTrue(note_writes, "best_version downloads should still trigger note update")
self.assertIn(83, note_writes[-1])
self.assertIn(1, note_writes[-1]) # 既有 note 保留
def test_finish_subscribe_skips_note_when_no_downloads(self):
"""没有 downloads 时不应触碰 note避免空写入或误清除。"""
subscribe = self._build_subscribe(best_version=1, total_episode=92, note=[1, 2])
chain = SubscribeChain()
captured_updates = []
class _SubscribeOper:
def update(self, subscribe_id, payload):
captured_updates.append((subscribe_id, payload))
def get(self, *args, **kwargs):
return subscribe
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object(
SubscribeChain,
"_SubscribeChain__is_best_version_complete",
return_value=False,
), patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
):
chain.finish_subscribe_or_not(
subscribe=subscribe,
meta=SimpleNamespace(type=MediaType.TV),
mediainfo=SimpleNamespace(title_year="Test Show (2026)", type=MediaType.TV,
tmdb_id=1, douban_id=None),
downloads=None,
lefts=None,
)
# 无下载时不应该有 note 写入。
self.assertFalse(
[payload for _, payload in captured_updates if "note" in payload],
"note must not be touched when downloads is empty",
)
def test_get_downloaded_best_version_returns_only_completed_episodes(self):
"""关键回归:洗版分支不得把 note 合并进 __get_downloaded 返回值。
否则 check_and_handle_existing_media → __get_subscribe_no_exits 会把
priority<100 但已下载的集从 pending no_exists 中减掉,配合 force=True 但
__is_best_version_complete=False 的 finish_subscribe_or_not会让订阅每轮
都跳过搜索却又永远不完成。__get_downloaded 在洗版下的语义是"无需再处理的
",只有 priority==100 才满足该语义。
"""
subscribe = self._build_subscribe(
best_version=1,
total_episode=3,
episode_priority={"1": 100, "2": 100, "3": 99},
note=[1, 2, 3],
)
downloaded = SubscribeChain._SubscribeChain__get_downloaded(subscribe)
# E3 priority=99 仍是 pending绝对不能合并到 downloaded 里
self.assertEqual(downloaded, [1, 2])
self.assertNotIn(3, downloaded)
def test_get_downloaded_non_best_version_reads_note_after_wash_migration(self):
"""迁移场景:洗版期间 finish_subscribe_or_not 把下载集写入 note
用户随后把 best_version 关掉,订阅切回普通模式时 __get_downloaded
从非洗版分支读取 note旧洗版集仍能作为"已下载"被识别,避免重新匹配。
"""
subscribe = self._build_subscribe(
best_version=0,
total_episode=5,
episode_priority={"1": 100, "2": 99}, # 旧洗版残留,普通分支不读
note=[1, 2, 3],
)
downloaded = SubscribeChain._SubscribeChain__get_downloaded(subscribe)
self.assertEqual(downloaded, [1, 2, 3])