mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 15:34:31 +08:00
fix(subscribe): scope duplicate checks by episode group (#6219)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
"""外部完成守卫可按任意已下载版本判定分集洗版目标已满足。"""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user