mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat: add full-season pack option for TV best-version subscriptions
- Introduce `best_version_full` field to subscribe and subscribehistory models and migration - Update subscription logic to support only downloading full-season packs when enabled - Extend CLI, API, and documentation to reflect new option - Add tests for full-season best-version behavior
This commit is contained in:
@@ -33,6 +33,7 @@ QUERY_SUBSCRIBE_OUTPUT_FIELDS = [
|
|||||||
"sites",
|
"sites",
|
||||||
"downloader",
|
"downloader",
|
||||||
"best_version",
|
"best_version",
|
||||||
|
"best_version_full",
|
||||||
"current_priority",
|
"current_priority",
|
||||||
"episode_priority",
|
"episode_priority",
|
||||||
"save_path",
|
"save_path",
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ class UpdateSubscribeInput(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="Whether to upgrade to best version: 0 for no, 1 for yes (optional)",
|
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(
|
custom_words: Optional[str] = Field(
|
||||||
None, description="Custom recognition words (optional)"
|
None, description="Custom recognition words (optional)"
|
||||||
)
|
)
|
||||||
@@ -140,6 +144,7 @@ class UpdateSubscribeTool(MoviePilotTool):
|
|||||||
downloader: Optional[str] = None,
|
downloader: Optional[str] = None,
|
||||||
save_path: Optional[str] = None,
|
save_path: Optional[str] = None,
|
||||||
best_version: Optional[int] = None,
|
best_version: Optional[int] = None,
|
||||||
|
best_version_full: Optional[int] = None,
|
||||||
custom_words: Optional[str] = None,
|
custom_words: Optional[str] = None,
|
||||||
media_category: Optional[str] = None,
|
media_category: Optional[str] = None,
|
||||||
episode_group: Optional[str] = None,
|
episode_group: Optional[str] = None,
|
||||||
@@ -230,6 +235,8 @@ class UpdateSubscribeTool(MoviePilotTool):
|
|||||||
subscribe_dict["save_path"] = save_path
|
subscribe_dict["save_path"] = save_path
|
||||||
if best_version is not None:
|
if best_version is not None:
|
||||||
subscribe_dict["best_version"] = best_version
|
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:
|
if custom_words is not None:
|
||||||
|
|||||||
+64
-1
@@ -292,6 +292,41 @@ class SubscribeChain(ChainBase):
|
|||||||
interested.append(episode_num)
|
interested.append(episode_num)
|
||||||
return sorted(set(interested))
|
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
|
@staticmethod
|
||||||
def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
|
def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
|
||||||
"""
|
"""
|
||||||
@@ -356,6 +391,8 @@ class SubscribeChain(ChainBase):
|
|||||||
"exclude") else kwargs.get("exclude"),
|
"exclude") else kwargs.get("exclude"),
|
||||||
'best_version': self.__get_default_subscribe_config(mtype, "best_version") if not kwargs.get(
|
'best_version': self.__get_default_subscribe_config(mtype, "best_version") if not kwargs.get(
|
||||||
"best_version") else kwargs.get("best_version"),
|
"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': self.__get_default_subscribe_config(mtype, "search_imdbid") if not kwargs.get(
|
||||||
"search_imdbid") else kwargs.get("search_imdbid"),
|
"search_imdbid") else kwargs.get("search_imdbid"),
|
||||||
'sites': self.__get_default_subscribe_config(mtype, "sites") or None if not kwargs.get(
|
'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 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 (
|
if (
|
||||||
torrent_mediainfo.type == MediaType.TV
|
torrent_mediainfo.type == MediaType.TV
|
||||||
@@ -958,6 +1005,9 @@ class SubscribeChain(ChainBase):
|
|||||||
for download in downloads:
|
for download in downloads:
|
||||||
download_priority = download.torrent_info.pri_order
|
download_priority = download.torrent_info.pri_order
|
||||||
downloaded_episodes = self.__get_downloaded_episodes([download])
|
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:
|
if not downloaded_episodes:
|
||||||
continue
|
continue
|
||||||
for episode in downloaded_episodes:
|
for episode in downloaded_episodes:
|
||||||
@@ -1342,6 +1392,14 @@ class SubscribeChain(ChainBase):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
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 (
|
if (
|
||||||
meta.type == MediaType.TV
|
meta.type == MediaType.TV
|
||||||
@@ -2827,7 +2885,9 @@ class SubscribeChain(ChainBase):
|
|||||||
else:
|
else:
|
||||||
exist_flag = False
|
exist_flag = False
|
||||||
if meta.type == MediaType.TV:
|
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 = {
|
no_exists = {
|
||||||
mediakey: {
|
mediakey: {
|
||||||
@@ -2850,6 +2910,9 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
# 获取已下载的集数或电影
|
# 获取已下载的集数或电影
|
||||||
downloaded = self.__get_downloaded(subscribe)
|
downloaded = self.__get_downloaded(subscribe)
|
||||||
|
if self.__is_full_best_version_enabled(subscribe):
|
||||||
|
# 全集洗版必须保留整季缺失范围,避免下载链路从整包中拆选单集。
|
||||||
|
downloaded = []
|
||||||
if meta.type == MediaType.TV:
|
if meta.type == MediaType.TV:
|
||||||
# 对于电视剧类型,整合缺失集数并剔除已下载的集数
|
# 对于电视剧类型,整合缺失集数并剔除已下载的集数
|
||||||
exist_flag, no_exists = self.__get_subscribe_no_exits(
|
exist_flag, no_exists = self.__get_subscribe_no_exits(
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ class Subscribe(Base):
|
|||||||
downloader = Column(String)
|
downloader = Column(String)
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version = Column(Integer, default=0)
|
best_version = Column(Integer, default=0)
|
||||||
|
# 是否只洗全集整包,开启后电视剧洗版不按单集下载
|
||||||
|
best_version_full = Column(Integer, default=0)
|
||||||
# 当前优先级
|
# 当前优先级
|
||||||
current_priority = Column(Integer)
|
current_priority = Column(Integer)
|
||||||
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
|
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ class SubscribeHistory(Base):
|
|||||||
sites = Column(JSON)
|
sites = Column(JSON)
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version = Column(Integer, default=0)
|
best_version = Column(Integer, default=0)
|
||||||
|
# 是否只洗全集整包,开启后电视剧洗版不按单集下载
|
||||||
|
best_version_full = Column(Integer, default=0)
|
||||||
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
|
# 洗版时已下载剧集的优先级状态,格式:{"1": 90, "2": 100}
|
||||||
episode_priority = Column(JSON)
|
episode_priority = Column(JSON)
|
||||||
# 保存路径
|
# 保存路径
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ class Subscribe(BaseModel):
|
|||||||
downloader: Optional[str] = None
|
downloader: Optional[str] = None
|
||||||
# 是否洗版
|
# 是否洗版
|
||||||
best_version: Optional[int] = 0
|
best_version: Optional[int] = 0
|
||||||
|
# 是否只洗全集整包
|
||||||
|
best_version_full: Optional[int] = 0
|
||||||
# 当前优先级
|
# 当前优先级
|
||||||
current_priority: Optional[int] = None
|
current_priority: Optional[int] = None
|
||||||
# 洗版时已下载剧集的优先级状态
|
# 洗版时已下载剧集的优先级状态
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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`
|
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status` (boolean: true=success, false=failed), `errmsg`, `date`
|
||||||
|
|
||||||
### subscribe (订阅)
|
### 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 (订阅历史)
|
### subscribehistory (订阅历史)
|
||||||
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
|
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ List active subscriptions:
|
|||||||
Update subscription filters:
|
Update subscription filters:
|
||||||
`node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p"`
|
`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):
|
Trigger a search for missing episodes (confirm with user first):
|
||||||
`node scripts/mp-cli.js search_subscribe subscribe_id=123`
|
`node scripts/mp-cli.js search_subscribe subscribe_id=123`
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ class SubscribeChainTest(TestCase):
|
|||||||
"name": "Test Show",
|
"name": "Test Show",
|
||||||
"season": 1,
|
"season": 1,
|
||||||
"best_version": 1,
|
"best_version": 1,
|
||||||
|
"best_version_full": 0,
|
||||||
"type": MediaType.TV.value,
|
"type": MediaType.TV.value,
|
||||||
"start_episode": 1,
|
"start_episode": 1,
|
||||||
"total_episode": 3,
|
"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):
|
def test_update_subscribe_priority_uses_selected_episodes(self):
|
||||||
subscribe = self._build_subscribe(
|
subscribe = self._build_subscribe(
|
||||||
total_episode=4,
|
total_episode=4,
|
||||||
@@ -455,6 +482,39 @@ class SubscribeChainTest(TestCase):
|
|||||||
self.assertEqual(payload["lack_episode"], 0)
|
self.assertEqual(payload["lack_episode"], 0)
|
||||||
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
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):
|
def test_check_resets_current_priority_when_new_episodes_expand_target_range(self):
|
||||||
subscribe = self._build_subscribe(
|
subscribe = self._build_subscribe(
|
||||||
total_episode=3,
|
total_episode=3,
|
||||||
|
|||||||
Reference in New Issue
Block a user