From 7c3f9629bf2405a3ca3d0358aa31243cb0c30bd1 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:40:29 +0800 Subject: [PATCH] fix(subscribe): scope duplicate checks by episode group (#6219) --- app/chain/subscribe.py | 8 +- app/db/models/subscribe.py | 16 ++- app/db/models/subscribehistory.py | 8 +- app/db/subscribe_oper.py | 52 ++++---- tests/test_subscribe_chain.py | 12 +- tests/test_subscribe_oper.py | 206 ++++++++++++++++++++++++++++++ 6 files changed, 268 insertions(+), 34 deletions(-) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index aff590c0..d9de8b3c 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -1295,6 +1295,7 @@ class SubscribeChain(ChainBase): media_source=media_source, media_id=media_id, season=meta.begin_season if meta else None, + episode_group=mediainfo.episode_group, ): return True return False @@ -2288,7 +2289,8 @@ class SubscribeChain(ChainBase): anilistid=share_sub.get("anilistid"), media_source=share_sub.get("media_source"), media_id=share_sub.get("media_id"), - season=share_sub.get("season")): + season=share_sub.get("season"), + episode_group=share_sub.get("episode_group")): continue # 已经订阅过跳过 if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"), @@ -2297,7 +2299,8 @@ class SubscribeChain(ChainBase): anilistid=share_sub.get("anilistid"), media_source=share_sub.get("media_source"), media_id=share_sub.get("media_id"), - season=share_sub.get("season")): + season=share_sub.get("season"), + episode_group=share_sub.get("episode_group")): continue # 去除无效属性 for key in list(share_sub.keys()): @@ -2328,6 +2331,7 @@ class SubscribeChain(ChainBase): year=subscribe_in.year, tmdbid=subscribe_in.tmdbid, season=subscribe_in.season, + episode_group=subscribe_in.episode_group, doubanid=subscribe_in.doubanid, bangumiid=subscribe_in.bangumiid, anilistid=subscribe_in.anilistid, diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index 634de70c..3ef3daab 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -130,8 +130,9 @@ class Subscribe(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, + episode_group: Optional[str] = None, ): - """按媒体身份与季号查询已有订阅。""" + """按媒体身份、季号与剧集组查询已有订阅。""" condition = cls._identity_condition( media_source, media_id, tmdbid, doubanid, bangumiid, anilistid ) @@ -140,6 +141,7 @@ class Subscribe(Base): query = db.query(cls).filter(condition) if season is not None: query = query.filter(cls.season == season) + query = query.filter(cls.episode_group == episode_group) return query.first() @classmethod @@ -149,8 +151,9 @@ class Subscribe(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, + episode_group: Optional[str] = None, ): - """异步按媒体身份与季号查询已有订阅。""" + """异步按媒体身份、季号与剧集组查询已有订阅。""" condition = cls._identity_condition( media_source, media_id, tmdbid, doubanid, bangumiid, anilistid ) @@ -159,6 +162,7 @@ class Subscribe(Base): query = select(cls).filter(condition) if season is not None: query = query.filter(cls.season == season) + query = query.filter(cls.episode_group == episode_group) result = await db.execute(query) return result.scalars().first() @@ -169,9 +173,10 @@ class Subscribe(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, + episode_group: Optional[str] = None, ): """ - 按订阅 owner 查询同一媒体的订阅行。 + 按订阅 owner、媒体身份、季号与剧集组查询订阅行。 """ if not username: return None @@ -183,6 +188,7 @@ class Subscribe(Base): query = db.query(cls).filter(cls.username == username, condition) if season is not None: query = query.filter(cls.season == season) + query = query.filter(cls.episode_group == episode_group) return query.first() @classmethod @@ -192,9 +198,10 @@ class Subscribe(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, + episode_group: Optional[str] = None, ): """ - 异步按订阅 owner 查询同一媒体的订阅行。 + 异步按订阅 owner、媒体身份、季号与剧集组查询订阅行。 """ if not username: return None @@ -206,6 +213,7 @@ class Subscribe(Base): query = select(cls).filter(cls.username == username, condition) if season is not None: query = query.filter(cls.season == season) + query = query.filter(cls.episode_group == episode_group) result = await db.execute(query) return result.scalars().first() diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index 2af988a1..746bf756 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -161,8 +161,9 @@ class SubscribeHistory(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, + episode_group: Optional[str] = None, ): - """按媒体身份与季号查询订阅历史。""" + """按媒体身份、季号及可选剧集组查询订阅历史。""" condition = cls._identity_condition( media_source, media_id, tmdbid, doubanid, bangumiid, anilistid ) @@ -171,6 +172,7 @@ class SubscribeHistory(Base): query = db.query(cls).filter(condition) if season is not None: query = query.filter(cls.season == season) + query = query.filter(cls.episode_group == episode_group) return query.first() @classmethod @@ -180,8 +182,9 @@ class SubscribeHistory(Base): doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, season: Optional[int] = None, + episode_group: Optional[str] = None, ): - """异步按媒体身份与季号查询订阅历史。""" + """异步按媒体身份、季号及可选剧集组查询订阅历史。""" condition = cls._identity_condition( media_source, media_id, tmdbid, doubanid, bangumiid, anilistid ) @@ -190,5 +193,6 @@ class SubscribeHistory(Base): query = select(cls).filter(condition) if season is not None: query = query.filter(cls.season == season) + query = query.filter(cls.episode_group == episode_group) result = await db.execute(query) return result.scalars().first() diff --git a/app/db/subscribe_oper.py b/app/db/subscribe_oper.py index ec4849f5..0c3a3bd9 100644 --- a/app/db/subscribe_oper.py +++ b/app/db/subscribe_oper.py @@ -45,6 +45,7 @@ class SubscribeOper(DbOper): "media_source": media_source, "media_id": media_id, "season": kwargs.get("season"), + "episode_group": mediainfo.episode_group, } if username: subscribe = Subscribe.exists_by_username(self._db, @@ -106,6 +107,7 @@ class SubscribeOper(DbOper): "media_source": media_source, "media_id": media_id, "season": kwargs.get("season"), + "episode_group": mediainfo.episode_group, } if username: subscribe = await Subscribe.async_exists_by_username(self._db, @@ -152,21 +154,22 @@ class SubscribeOper(DbOper): self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, - season: Optional[int] = None, + season: Optional[int] = None, episode_group: Optional[str] = None, ) -> bool: """ - 判断是否存在 + 按媒体身份、季号及可选剧集组判断订阅是否存在。 """ - return bool(Subscribe.exists( - self._db, - tmdbid=tmdbid, - doubanid=doubanid, - bangumiid=bangumiid, - anilistid=anilistid, - media_source=media_source, - media_id=media_id, - season=season, - )) + identity_params = { + "tmdbid": tmdbid, + "doubanid": doubanid, + "bangumiid": bangumiid, + "anilistid": anilistid, + "media_source": media_source, + "media_id": media_id, + "season": season, + "episode_group": episode_group, + } + return bool(Subscribe.exists(self._db, **identity_params)) def get(self, sid: int) -> Subscribe: """ @@ -300,18 +303,19 @@ class SubscribeOper(DbOper): self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, - season: Optional[int] = None, + season: Optional[int] = None, episode_group: Optional[str] = None, ) -> bool: """ - 判断是否存在订阅历史 + 按媒体身份、季号及可选剧集组判断订阅历史是否存在。 """ - return bool(SubscribeHistory.exists( - self._db, - tmdbid=tmdbid, - doubanid=doubanid, - bangumiid=bangumiid, - anilistid=anilistid, - media_source=media_source, - media_id=media_id, - season=season, - )) + identity_params = { + "tmdbid": tmdbid, + "doubanid": doubanid, + "bangumiid": bangumiid, + "anilistid": anilistid, + "media_source": media_source, + "media_id": media_id, + "season": season, + "episode_group": episode_group, + } + return bool(SubscribeHistory.exists(self._db, **identity_params)) diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index af4fa8e8..50bec1df 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -933,17 +933,21 @@ class SubscribeChainTest(TestCase): self.assertEqual(meta.begin_season, 0) self.assertEqual(meta.type, MediaType.TV) - def test_follow_preserves_shared_special_season_zero(self): - """follow 分享订阅携带 S0 时,标题规整不能把合法季号覆盖成未指定。""" + def test_follow_preserves_shared_special_season_and_episode_group(self): + """Follow 分享必须保留合法 S0 与自定义剧集组的完整订阅范围。""" added_calls = [] + exists_calls = [] + history_calls = [] class _SubscribeOper: """提供订阅存在性查询,避免依赖真实数据库。""" def exists(self, *args, **kwargs): + exists_calls.append(kwargs) return False def exist_history(self, *args, **kwargs): + history_calls.append(kwargs) return False class _SystemConfigOper: @@ -966,6 +970,7 @@ class SubscribeChainTest(TestCase): "tmdbid": None, "doubanid": "12345", "season": 0, + "episode_group": "eg-special", "best_version": 0, "save_path": None, "search_imdbid": False, @@ -1003,6 +1008,9 @@ class SubscribeChainTest(TestCase): self.assertEqual(len(added_calls), 1) self.assertEqual(added_calls[0]["season"], 0) + self.assertEqual(added_calls[0]["episode_group"], "eg-special") + self.assertEqual(exists_calls[0]["episode_group"], "eg-special") + self.assertEqual(history_calls[0]["episode_group"], "eg-special") def test_resolve_subscribe_missing_accepts_downloaded_episode_best_version_targets(self): """外部完成守卫可按任意已下载版本判定分集洗版目标已满足。""" diff --git a/tests/test_subscribe_oper.py b/tests/test_subscribe_oper.py index e91bad84..0fb6001d 100644 --- a/tests/test_subscribe_oper.py +++ b/tests/test_subscribe_oper.py @@ -1,5 +1,38 @@ +import asyncio +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory from app.db.subscribe_oper import SubscribeOper +from app.schemas.types import MediaType + + +def _media(episode_group): + """构造订阅新增路径所需的稳定 MediaInfo 契约替身。""" + return SimpleNamespace( + title="测试剧", + year="2026", + type=MediaType.TV, + source="themoviedb", + media_source="themoviedb", + media_id="987654321", + mediaid="tmdb:987654321", + tmdb_id=987654321, + imdb_id=None, + tvdb_id=None, + douban_id=None, + bangumi_id=None, + anilist_id=None, + episode_group=episode_group, + vote_average=8.0, + overview="测试简介", + get_poster_image=lambda: None, + get_backdrop_image=lambda: None, + ) def test_add_history_converts_boolean_integer_flags(monkeypatch): @@ -37,3 +70,176 @@ def test_add_history_converts_boolean_integer_flags(monkeypatch): "best_version_full": 1, "search_imdbid": 0, } + + +@pytest.mark.parametrize("episode_group", [None, "eg-1"]) +def test_add_scopes_duplicate_lookup_by_episode_group(episode_group): + """同步新增前后都必须按剧集组查询,主季和自定义组不能互相去重。""" + persisted = SimpleNamespace(id=88) + created = SimpleNamespace(create=MagicMock()) + + with patch("app.db.subscribe_oper.Subscribe") as subscribe_model: + subscribe_model.exists.side_effect = [None, persisted] + subscribe_model.return_value = created + + sid, message = SubscribeOper(db=object()).add( + mediainfo=_media(episode_group), + season=1, + ) + + assert (sid, message) == (88, "新增订阅成功") + assert subscribe_model.exists.call_count == 2 + assert all( + call.kwargs["episode_group"] == episode_group + for call in subscribe_model.exists.call_args_list + ) + created.create.assert_called_once() + + +@pytest.mark.parametrize("episode_group", [None, "eg-1"]) +def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group): + """异步新增与同步路径使用相同的剧集组身份契约。""" + persisted = SimpleNamespace(id=89) + created = SimpleNamespace(async_create=AsyncMock()) + + with patch("app.db.subscribe_oper.Subscribe") as subscribe_model: + subscribe_model.async_exists = AsyncMock(side_effect=[None, persisted]) + subscribe_model.return_value = created + + sid, message = asyncio.run(SubscribeOper(db=object()).async_add( + mediainfo=_media(episode_group), + season=1, + )) + + assert (sid, message) == (89, "新增订阅成功") + assert subscribe_model.async_exists.await_count == 2 + assert all( + call.kwargs["episode_group"] == episode_group + for call in subscribe_model.async_exists.await_args_list + ) + created.async_create.assert_awaited_once() + + +def test_owner_scoped_add_forwards_episode_group_sync_and_async(): + """按 owner 去重的同步与异步新增也必须使用同一剧集组身份。""" + media = _media("eg-owner") + sync_persisted = SimpleNamespace(id=90) + sync_created = SimpleNamespace(create=MagicMock()) + with patch("app.db.subscribe_oper.Subscribe") as subscribe_model: + subscribe_model.exists_by_username.side_effect = [None, sync_persisted] + subscribe_model.return_value = sync_created + + sid, _ = SubscribeOper(db=object()).add( + mediainfo=media, + season=1, + username="alice", + owner_scope=True, + ) + + assert sid == 90 + assert all( + call.kwargs["episode_group"] == "eg-owner" + for call in subscribe_model.exists_by_username.call_args_list + ) + + async_persisted = SimpleNamespace(id=91) + async_created = SimpleNamespace(async_create=AsyncMock()) + with patch("app.db.subscribe_oper.Subscribe") as subscribe_model: + subscribe_model.async_exists_by_username = AsyncMock( + side_effect=[None, async_persisted] + ) + subscribe_model.return_value = async_created + + sid, _ = asyncio.run(SubscribeOper(db=object()).async_add( + mediainfo=media, + season=1, + username="alice", + owner_scope=True, + )) + + assert sid == 91 + assert all( + call.kwargs["episode_group"] == "eg-owner" + for call in subscribe_model.async_exists_by_username.await_args_list + ) + + +def test_exists_defaults_to_main_season_episode_group(): + """省略剧集组时按主季查询,显式剧集组按对应范围查询。""" + oper = SubscribeOper(db=object()) + with patch("app.db.subscribe_oper.Subscribe") as subscribe_model: + subscribe_model.exists.return_value = SimpleNamespace(id=1) + + assert oper.exists(tmdbid=100, season=1) is True + assert subscribe_model.exists.call_args.kwargs["episode_group"] is None + + assert oper.exists(tmdbid=100, season=1, episode_group="eg-1") is True + assert subscribe_model.exists.call_args.kwargs["episode_group"] == "eg-1" + + with patch("app.db.subscribe_oper.SubscribeHistory") as history_model: + history_model.exists.return_value = SimpleNamespace(id=2) + + assert oper.exist_history(tmdbid=100, season=1) is True + assert history_model.exists.call_args.kwargs["episode_group"] is None + + assert oper.exist_history(tmdbid=100, season=1, episode_group="eg-1") is True + assert history_model.exists.call_args.kwargs["episode_group"] == "eg-1" + + +def test_subscribe_exists_distinguishes_same_season_episode_groups(): + """同一媒体同一季的主季、自定义剧集组应分别命中各自订阅。""" + oper = SubscribeOper() + tmdbid = -(900_000_000 + os.getpid()) + created_ids = [] + rows = [ + Subscribe(name="主季订阅", type=MediaType.TV.value, state="N", + tmdbid=tmdbid, season=1, episode_group=None), + Subscribe(name="剧集组订阅", type=MediaType.TV.value, state="N", + tmdbid=tmdbid, season=1, episode_group="eg-1"), + ] + try: + for row in rows: + row.create(oper._db) + + main_season = Subscribe.exists( + oper._db, tmdbid=tmdbid, season=1, episode_group=None, + ) + created_ids.append(main_season.id) + main_name = main_season.name + episode_group = Subscribe.exists( + oper._db, tmdbid=tmdbid, season=1, episode_group="eg-1", + ) + created_ids.append(episode_group.id) + episode_group_name = episode_group.name + + assert main_name == "主季订阅" + assert episode_group_name == "剧集组订阅" + + Subscribe.delete(oper._db, rid=created_ids.pop(0)) + assert Subscribe.exists(oper._db, tmdbid=tmdbid, season=1) is None + finally: + for subscribe_id in created_ids: + Subscribe.delete(oper._db, rid=subscribe_id) + + +def test_subscribe_chain_exists_forwards_episode_group(): + """订阅前置存在性检查必须查询当前剧集组,不能退回主季范围。""" + from app.chain.subscribe import SubscribeChain + + media = _media("eg-1") + meta = SimpleNamespace(begin_season=1) + with patch("app.chain.subscribe.SubscribeOper") as subscribe_oper_cls: + subscribe_oper_cls.return_value.exists.return_value = True + + assert SubscribeChain.exists(media, meta) is True + + subscribe_oper_cls.return_value.exists.assert_called_once_with( + tmdbid=media.tmdb_id, + doubanid=media.douban_id, + bangumiid=media.bangumi_id, + anilistid=media.anilist_id, + media_source="themoviedb", + media_id=str(media.tmdb_id), + season=1, + episode_group="eg-1", + )