diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py index 41f65034..f33196a4 100644 --- a/app/agent/tools/impl/query_subscribes.py +++ b/app/agent/tools/impl/query_subscribes.py @@ -33,6 +33,7 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [ "sites", "downloader", "best_version", + "best_version_full", "current_priority", "episode_priority", "save_path", diff --git a/app/agent/tools/impl/update_subscribe.py b/app/agent/tools/impl/update_subscribe.py index a60d277e..11411292 100644 --- a/app/agent/tools/impl/update_subscribe.py +++ b/app/agent/tools/impl/update_subscribe.py @@ -74,6 +74,10 @@ class UpdateSubscribeInput(BaseModel): None, description="Whether to upgrade to best version: 0 for no, 1 for yes (optional)", ) + best_version_full: Optional[int] = Field( + None, + description="For TV best-version subscriptions, only download full-season packs: 0 for no, 1 for yes (optional)", + ) custom_words: Optional[str] = Field( None, description="Custom recognition words (optional)" ) @@ -140,6 +144,7 @@ class UpdateSubscribeTool(MoviePilotTool): downloader: Optional[str] = None, save_path: Optional[str] = None, best_version: Optional[int] = None, + best_version_full: Optional[int] = None, custom_words: Optional[str] = None, media_category: Optional[str] = None, episode_group: Optional[str] = None, @@ -230,6 +235,8 @@ class UpdateSubscribeTool(MoviePilotTool): subscribe_dict["save_path"] = save_path if best_version is not None: subscribe_dict["best_version"] = best_version + if best_version_full is not None: + subscribe_dict["best_version_full"] = best_version_full # 其他配置 if custom_words is not None: diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index a1c56dd4..a727a28d 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -292,6 +292,41 @@ class SubscribeChain(ChainBase): interested.append(episode_num) return sorted(set(interested)) + @classmethod + def __is_full_best_version_enabled(cls, subscribe: Subscribe) -> bool: + """ + 判断当前订阅是否启用了电视剧全集洗版。 + """ + return ( + bool(getattr(subscribe, "best_version_full", 0)) + and bool(subscribe.best_version) + and subscribe.type == MediaType.TV.value + ) + + @classmethod + def __is_full_season_best_version_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool: + """ + 判断候选资源是否覆盖订阅目标的全集范围。 + """ + if not cls.__is_full_best_version_enabled(subscribe): + return True + + season_list = meta.season_list or [1] + if len(season_list) != 1: + return False + if subscribe.season is not None and season_list[0] != subscribe.season: + return False + + episodes = meta.episode_list + if not episodes: + # 资源未标出单集时按整季包处理,后续下载前仍会解析种子文件确认完整性。 + return True + + target_episodes = set(cls.__get_best_version_target_episodes(subscribe)) + if not target_episodes: + return False + return target_episodes.issubset(set(episodes)) + @staticmethod def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]: """ @@ -356,6 +391,8 @@ class SubscribeChain(ChainBase): "exclude") else kwargs.get("exclude"), 'best_version': self.__get_default_subscribe_config(mtype, "best_version") if not kwargs.get( "best_version") else kwargs.get("best_version"), + 'best_version_full': self.__get_default_subscribe_config(mtype, "best_version_full") + if kwargs.get("best_version_full") is None else kwargs.get("best_version_full"), 'search_imdbid': self.__get_default_subscribe_config(mtype, "search_imdbid") if not kwargs.get( "search_imdbid") else kwargs.get("search_imdbid"), 'sites': self.__get_default_subscribe_config(mtype, "sites") or None if not kwargs.get( @@ -852,6 +889,16 @@ class SubscribeChain(ChainBase): # 洗版 if subscribe.best_version: + if ( + torrent_mediainfo.type == MediaType.TV + and not self.__is_full_season_best_version_resource( + meta=torrent_meta, subscribe=subscribe + ) + ): + logger.info( + f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源" + ) + continue # 洗版时,不符合订阅集数的不要 if ( torrent_mediainfo.type == MediaType.TV @@ -958,6 +1005,9 @@ class SubscribeChain(ChainBase): for download in downloads: download_priority = download.torrent_info.pri_order downloaded_episodes = self.__get_downloaded_episodes([download]) + if self.__is_full_best_version_enabled(subscribe) and not downloaded_episodes: + # 全集整包下载时资源标题常不携带集数,视为覆盖当前订阅的全部目标集。 + downloaded_episodes = self.__get_best_version_target_episodes(subscribe) if not downloaded_episodes: continue for episode in downloaded_episodes: @@ -1342,6 +1392,14 @@ class SubscribeChain(ChainBase): ) continue else: + if not self.__is_full_season_best_version_resource( + meta=torrent_meta, + subscribe=subscribe, + ): + logger.debug( + f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源" + ) + continue # 洗版时,不符合订阅集数的不要 if ( meta.type == MediaType.TV @@ -2827,7 +2885,9 @@ class SubscribeChain(ChainBase): else: exist_flag = False if meta.type == MediaType.TV: - pending_episodes = self._get_pending_best_version_episodes(subscribe) + pending_episodes = [] if self.__is_full_best_version_enabled( + subscribe + ) else self._get_pending_best_version_episodes(subscribe) # 对于电视剧,构造缺失的媒体信息 no_exists = { mediakey: { @@ -2850,6 +2910,9 @@ class SubscribeChain(ChainBase): # 获取已下载的集数或电影 downloaded = self.__get_downloaded(subscribe) + if self.__is_full_best_version_enabled(subscribe): + # 全集洗版必须保留整季缺失范围,避免下载链路从整包中拆选单集。 + downloaded = [] if meta.type == MediaType.TV: # 对于电视剧类型,整合缺失集数并剔除已下载的集数 exist_flag, no_exists = self.__get_subscribe_no_exits( diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index eedabe01..d31463d7 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -71,6 +71,8 @@ class Subscribe(Base): downloader = Column(String) # 是否洗版 best_version = Column(Integer, default=0) + # 是否只洗全集整包,开启后电视剧洗版不按单集下载 + best_version_full = Column(Integer, default=0) # 当前优先级 current_priority = Column(Integer) # 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100} diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index 94b536b9..a7cff4da 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -60,6 +60,8 @@ class SubscribeHistory(Base): sites = Column(JSON) # 是否洗版 best_version = Column(Integer, default=0) + # 是否只洗全集整包,开启后电视剧洗版不按单集下载 + best_version_full = Column(Integer, default=0) # 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100} episode_priority = Column(JSON) # 保存路径 diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index b91fd529..fac1e20a 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -59,6 +59,8 @@ class Subscribe(BaseModel): downloader: Optional[str] = None # 是否洗版 best_version: Optional[int] = 0 + # 是否只洗全集整包 + best_version_full: Optional[int] = 0 # 当前优先级 current_priority: Optional[int] = None # 洗版时已下载剧集的优先级状态 diff --git a/database/versions/1f0d2c3b4a5e_2_2_7.py b/database/versions/1f0d2c3b4a5e_2_2_7.py new file mode 100644 index 00000000..9cef2fa8 --- /dev/null +++ b/database/versions/1f0d2c3b4a5e_2_2_7.py @@ -0,0 +1,42 @@ +"""2.2.7 +为电视剧洗版增加全集整包开关 + +Revision ID: 1f0d2c3b4a5e +Revises: 9caa49cb3e10 +Create Date: 2026-05-13 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "1f0d2c3b4a5e" +down_revision = "9caa49cb3e10" +branch_labels = None +depends_on = None + + +def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + if table_name not in inspector.get_table_names(): + return False + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, "subscribe", "best_version_full") is False: + op.add_column("subscribe", sa.Column("best_version_full", sa.Integer(), nullable=True, server_default="0")) + + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, "subscribehistory", "best_version_full") is False: + op.add_column("subscribehistory", sa.Column("best_version_full", sa.Integer(), nullable=True, server_default="0")) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, "subscribehistory", "best_version_full"): + op.drop_column("subscribehistory", "best_version_full") + + inspector = sa.inspect(op.get_bind()) + if _has_column(inspector, "subscribe", "best_version_full"): + op.drop_column("subscribe", "best_version_full") diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index afe1bbaf..eea526de 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -102,7 +102,7 @@ Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filep Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status` (boolean: true=success, false=failed), `errmsg`, `date` ### subscribe (订阅) -Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state` ('N'=new, 'R'=running, 'S'=paused), `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `date`, `username` +Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state` ('N'=new, 'R'=running, 'S'=paused), `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username` ### subscribehistory (订阅历史) Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username` diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index 8870096f..53a979b9 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -140,6 +140,9 @@ List active subscriptions: Update subscription filters: `node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p"` +Only download full-season packs for a TV best-version subscription: +`node scripts/mp-cli.js update_subscribe subscribe_id=123 best_version=1 best_version_full=1` + Trigger a search for missing episodes (confirm with user first): `node scripts/mp-cli.js search_subscribe subscribe_id=123` diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index b707a296..90bfbd9f 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -279,6 +279,7 @@ class SubscribeChainTest(TestCase): "name": "Test Show", "season": 1, "best_version": 1, + "best_version_full": 0, "type": MediaType.TV.value, "start_episode": 1, "total_episode": 3, @@ -381,6 +382,32 @@ class SubscribeChainTest(TestCase): ) ) + def test_full_best_version_rejects_episode_resource(self): + subscribe = self._build_subscribe(best_version_full=1, total_episode=3) + + self.assertFalse( + SubscribeChain._SubscribeChain__is_full_season_best_version_resource( + meta=SimpleNamespace(season_list=[1], episode_list=[1]), + subscribe=subscribe, + ) + ) + + def test_full_best_version_accepts_full_pack_resource(self): + subscribe = self._build_subscribe(best_version_full=1, total_episode=3) + + self.assertTrue( + SubscribeChain._SubscribeChain__is_full_season_best_version_resource( + meta=SimpleNamespace(season_list=[1], episode_list=[]), + subscribe=subscribe, + ) + ) + self.assertTrue( + SubscribeChain._SubscribeChain__is_full_season_best_version_resource( + meta=SimpleNamespace(season_list=[1], episode_list=[1, 2, 3]), + subscribe=subscribe, + ) + ) + def test_update_subscribe_priority_uses_selected_episodes(self): subscribe = self._build_subscribe( total_episode=4, @@ -455,6 +482,39 @@ class SubscribeChainTest(TestCase): self.assertEqual(payload["lack_episode"], 0) finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo) + def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadata(self): + subscribe = self._build_subscribe( + best_version_full=1, + total_episode=3, + episode_priority={"1": 80, "2": 80, "3": 80}, + current_priority=80, + lack_episode=3, + ) + download = self._build_download(priority=100, selected_episodes=[], meta_episodes=[]) + chain = SubscribeChain() + meta = SimpleNamespace() + mediainfo = SimpleNamespace(title_year="Test Show (2026)") + + with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object( + SubscribeChain, + "_SubscribeChain__finish_subscribe", + ) as finish_mock: + subscribe_oper = subscribe_oper_cls.return_value + subscribe_oper.update.return_value = None + + chain.update_subscribe_priority( + subscribe=subscribe, + meta=meta, + mediainfo=mediainfo, + downloads=[download], + ) + + payload = subscribe_oper.update.call_args.args[1] + self.assertEqual(payload["episode_priority"], {"1": 100, "2": 100, "3": 100}) + self.assertEqual(payload["current_priority"], 100) + self.assertEqual(payload["lack_episode"], 0) + finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo) + def test_check_resets_current_priority_when_new_episodes_expand_target_range(self): subscribe = self._build_subscribe( total_episode=3,