Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"name": "订阅助手(增强版)",
"description": "多场景管理订阅,实现订阅全生命周期管理。",
"labels": "订阅",
"version": "0.6.10",
"version": "0.6.11",
"icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png",
"author": "InfinityPacer",
"level": 1,
"system_version": ">=2.14.6",
"history": {
"v0.6.11": "修正全集洗版可能阻断新增集自动纠错的问题。",
"v0.6.10": "完善配置页前端质量检查与构建门禁,提升构建稳定性。",
"v0.6.9": "完善分集转全集的覆盖校验、优先级迁移与回滚,并优化新增剧集订阅重建和转换生命周期。",
"v0.6.8": "优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。",
Expand Down
1 change: 1 addition & 0 deletions plugins.v2/subscribeassistantenhanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

## 版本更新日志

- v0.6.11:修正全集洗版可能阻断新增集自动纠错的问题。
- v0.6.10:完善配置页前端质量检查与构建门禁,提升构建稳定性。
- v0.6.9:完善分集转全集的覆盖校验、优先级迁移与回滚,并优化新增剧集订阅重建和转换生命周期。
- v0.6.8:优化订阅通知图片策略,媒体通知优先使用订阅图片,无图消息改为纯文本并标注插件来源。
Expand Down
52 changes: 12 additions & 40 deletions plugins.v2/subscribeassistantenhanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
is_tv_episode_best_version_subscribe,
resolve_subscribe_media_type,
)
from .postcheck.verifier import CompletionVerifier, _format_snapshot_label
from .postcheck.verifier import CompletionVerifier
from .postcheck.rebuilder import CompletionSubscribeRebuilder
from .postcheck.timeout import PendingTimeoutManager
from .events import EventProxy
from .shared.media import parse_date
Expand Down Expand Up @@ -88,7 +89,7 @@ class SubscribeAssistantEnhanced(_PluginBase):
# 插件图标
plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/subscribeassistantenhanced.png"
# 插件版本
plugin_version = "0.6.10"
plugin_version = "0.6.11"
_site_cache_candidate_helper_warned = False
# 插件作者
plugin_author = "InfinityPacer"
Expand Down Expand Up @@ -237,13 +238,20 @@ def _init_modules(self):
cadence_acceleration=cfg.timeout_cadence_acceleration,
subscribe_get_fn=self._subscribe_oper.get,
)
completion_rebuilder = CompletionSubscribeRebuilder(
subscribe_chain=self._subscribe_chain,
subscribe_oper=self._subscribe_oper,
default_config_getter=self.systemconfig.get,
plugin_name=self.plugin_name,
)
verifier = CompletionVerifier(
tm.read, tm.update,
tmdb_episodes_fn=self._tmdb_episodes,
subscribe_oper=self._subscribe_oper,
retention_days=cfg.verify_retention_days,
notify_fn=self._notify_subscribe,
rebuild_subscribe_fn=self._rebuild_subscribe_from_snapshot,
rebuild_subscribe_fn=completion_rebuilder.rebuild,
validate_rebuild_subscribe_fn=completion_rebuilder.validate,
get_subscribe_image_fn=self._get_subscribe_image,
)
priority_manager = PriorityManager(
Expand Down Expand Up @@ -497,6 +505,7 @@ def _init_modules(self):
self._modules = {
"volatility": volatility,
"timeout_manager": timeout_manager,
"completion_rebuilder": completion_rebuilder,
"verifier": verifier,
"priority_manager": priority_manager,
"converter": converter,
Expand Down Expand Up @@ -1504,43 +1513,6 @@ def _detect_episode_coverage(self, subscribe) -> Tuple[list, list]:
except Exception:
return [], sorted(target)

def _rebuild_subscribe_from_snapshot(self, snap: dict, config: dict) -> bool:
"""使用当前默认订阅规则和完成快照重建增集订阅。"""
if not self._subscribe_chain:
return False
payload = dict(config)
title = payload.pop("name", "")
year = payload.pop("year", None)
for field in (
"id", "type", "tmdbid", "season", "episode_group",
"best_version", "best_version_full",
):
payload.pop(field, None)
payload["manual_total_episode"] = 0
payload["state"] = "N"
try:
subscribe_id, _ = self._subscribe_chain.add(
title=title,
year=year,
mtype=MediaType.TV,
tmdbid=snap.get("tmdbid"),
season=snap.get("season"),
episode_group=snap.get("episode_group_id"),
username=self.plugin_name,
message=False,
exist_ok=True,
**payload,
)
if subscribe_id:
logger.info(f"完成后验证:{_format_snapshot_label(snap)} 检测到增集,已重建订阅(新 id={subscribe_id})")
return bool(subscribe_id)
except Exception as err:
logger.warning(
"订阅助手(增强版)按完成快照重建订阅失败:"
f"{_format_snapshot_label(snap)}, error={err}"
)
return False

def _delete_downloader_torrent(self, downloader, torrent_hash):
"""从下载器删除种子(delete_file=True,连源文件一并删);缺下载器服务或参数时跳过。

Expand Down
127 changes: 127 additions & 0 deletions plugins.v2/subscribeassistantenhanced/postcheck/rebuilder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""完成快照订阅重建:解析模式、创建订阅并校验实际接管范围。"""
from typing import Callable, Tuple

from app.log import logger
from app.schemas.types import MediaType, SystemConfigKey

from .verifier import format_snapshot_label


class CompletionSubscribeRebuilder:
"""按完成快照重建增集订阅,并确保结果不是全集洗版。"""

def __init__(self, subscribe_chain, subscribe_oper,
default_config_getter: Callable, plugin_name: str):
"""注入订阅创建、订阅读取和主程序默认规则查询能力。"""
self._subscribe_chain = subscribe_chain
self._subscribe_oper = subscribe_oper
self._default_config_getter = default_config_getter
self._plugin_name = plugin_name

def rebuild(self, snap: dict, config: dict) -> bool:
"""使用当前默认规则和完成快照重建按集追踪的增集订阅。"""
if not self._subscribe_chain or not self._subscribe_oper:
return False
payload = dict(config)
title = payload.pop("name", "")
year = payload.pop("year", None)
for field in (
"id", "type", "tmdbid", "season", "episode_group",
"best_version", "best_version_full",
):
payload.pop(field, None)
best_version, best_version_full = self._resolve_mode(snap)
payload["best_version"] = best_version
payload["best_version_full"] = best_version_full
payload["manual_total_episode"] = 0
payload["state"] = "N"
try:
subscribe_id, _ = self._subscribe_chain.add(
title=title,
year=year,
mtype=MediaType.TV,
tmdbid=snap.get("tmdbid"),
season=snap.get("season"),
episode_group=snap.get("episode_group_id"),
username=self._plugin_name,
message=False,
exist_ok=True,
**payload,
)
rebuilt = self._subscribe_oper.get(subscribe_id) if subscribe_id else None
if not self._is_valid(
rebuilt,
snap=snap,
config=config,
best_version=best_version,
):
logger.warning(
f"完成后验证:{format_snapshot_label(snap)} 重建结果未接管目标新增集,"
"已保留快照等待重试"
)
return False
logger.info(
f"完成后验证:{format_snapshot_label(snap)} 检测到增集,"
f"已重建订阅(新 id={subscribe_id})"
)
return True
except Exception as err:
logger.warning(
f"{self._plugin_name}按完成快照重建订阅失败:"
f"{format_snapshot_label(snap)}, error={err}"
)
return False

def validate(self, subscribe, snap: dict, current_total: int) -> bool:
"""验证既有订阅是否按当前纠错规则接管快照发现的新增集。"""
best_version, _ = self._resolve_mode(snap)
return self._is_valid(
subscribe,
snap=snap,
config={
"start_episode": snap.get("total_at_completion", 0) + 1,
"total_episode": current_total,
},
best_version=best_version,
)

@staticmethod
def _is_valid(subscribe, snap: dict, config: dict, best_version: int) -> bool:
"""确认实际订阅按目标模式覆盖完整新增集区间。"""
if not subscribe:
return False
requested_start = config.get("start_episode") or 1
requested_total = config.get("total_episode") or 0
actual_start = subscribe.start_episode or 1
actual_total = subscribe.total_episode or 0
return (
subscribe.tmdbid == snap.get("tmdbid")
and subscribe.season == snap.get("season")
and subscribe.episode_group == snap.get("episode_group_id")
and bool(subscribe.best_version) == bool(best_version)
and not bool(subscribe.best_version_full)
and actual_start <= requested_start
and actual_total >= requested_total
)

def _resolve_mode(self, snap: dict) -> Tuple[int, int]:
"""解析自动纠错重建模式,保证结果只可能是普通订阅或分集洗版。"""
default_best_version, default_best_version_full = self._get_default_tv_mode()
if not default_best_version_full:
return int(default_best_version), 0

snapshot_config = snap.get("subscribe_config") or {}
snapshot_best_version = bool(snapshot_config.get("best_version"))
snapshot_best_version_full = bool(snapshot_config.get("best_version_full"))
if snapshot_best_version and not snapshot_best_version_full:
return 1, 0
return 0, 0

def _get_default_tv_mode(self) -> Tuple[bool, bool]:
"""读取用户在主程序中保存的默认电视剧订阅模式。"""
default_config = self._default_config_getter(SystemConfigKey.DefaultTvSubscribeConfig)
if not isinstance(default_config, dict):
default_config = {}
best_version = bool(default_config.get("best_version"))
best_version_full = best_version and bool(default_config.get("best_version_full"))
return best_version, best_version_full
53 changes: 37 additions & 16 deletions plugins.v2/subscribeassistantenhanced/postcheck/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable,
retention_days: int = 180,
notify_fn: Optional[Callable] = None,
rebuild_subscribe_fn: Optional[Callable] = None,
validate_rebuild_subscribe_fn: Optional[Callable] = None,
get_subscribe_image_fn: Optional[Callable] = None):
"""注入完成快照存储、集数查询、订阅查询和真实订阅重建能力。"""
self._read = task_data_read
Expand All @@ -27,6 +28,7 @@ def __init__(self, task_data_read: Callable, task_data_update: Callable,
self._retention_seconds = retention_days * 86400
self._notify = notify_fn
self._rebuild_subscribe = rebuild_subscribe_fn
self._validate_rebuild_subscribe = validate_rebuild_subscribe_fn
self._get_subscribe_image = get_subscribe_image_fn

def snapshot(self, subscribe, mediainfo, scope: Optional[SeasonScope]):
Expand Down Expand Up @@ -70,12 +72,19 @@ def verify_all(self):
to_remove = []

for snap in snapshots:
current_total = self._fetch_current_total(snap)
if current_total is not None and current_total > snap.get("total_at_completion", 0):
snap_label = _format_snapshot_label(snap)
logger.info(f"完成后验证:{snap_label} 检测到增集 {snap.get('total_at_completion', 0)}→{current_total},尝试重建订阅")
if self._rebuild(snap, current_total):
to_remove.append(snap)
try:
current_total = self._fetch_current_total(snap)
if current_total is not None and current_total > snap.get("total_at_completion", 0):
snap_label = format_snapshot_label(snap)
logger.info(f"完成后验证:{snap_label} 检测到增集 {snap.get('total_at_completion', 0)}→{current_total},尝试重建订阅")
if self._rebuild(snap, current_total):
to_remove.append(snap)
except Exception as err:
# 单条快照失败时保留原记录重试,不能阻断同批其他订阅的纠错。
logger.warning(
f"完成后验证:{format_snapshot_label(snap)} 处理失败,"
f"已保留快照等待重试,error={err}"
)

if to_remove:
self._remove_snapshots(to_remove)
Expand Down Expand Up @@ -123,17 +132,27 @@ def _rebuild(self, snap: dict, current_total: int) -> bool:
and sub.episode_group == episode_group_id
)
]
if any((sub.total_episode or 0) >= current_total for sub in matched):
return True

# 普通或分集洗版订阅由现有订阅流程继续处理;目标范围尚未覆盖时不能消费完成快照。
if any(not is_full_best_version_subscribe(sub) for sub in matched):
return False
full_best_version_subscribes = [
sub for sub in matched if is_full_best_version_subscribe(sub)
]
if full_best_version_subscribes:
for sub in full_best_version_subscribes:
logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅")
self._subscribe_oper.delete(sub.id)
removed_full_best_version = True
else:
covered = [sub for sub in matched if (sub.total_episode or 0) >= current_total]
if (
covered
and self._validate_rebuild_subscribe
and any(self._validate_rebuild_subscribe(sub, snap, current_total) for sub in covered)
):
return True

for sub in matched:
logger.info(f"完成后验证:删除旧洗版订阅 {format_subscribe_label(sub)} 以便重建增集订阅")
self._subscribe_oper.delete(sub.id)
removed_full_best_version = True
# 普通或分集洗版订阅由现有订阅流程继续处理;目标范围尚未覆盖时不能消费完成快照。
if matched:
return False

old_total = snap.get("total_at_completion", 0)
config["start_episode"] = old_total + 1
Expand Down Expand Up @@ -168,7 +187,7 @@ def _snap_key(snap: dict) -> tuple:
return (snap.get("tmdbid"), snap.get("season"), snap.get("episode_group_id"))


def _format_snapshot_label(snap: dict) -> str:
def format_snapshot_label(snap: dict) -> str:
"""格式化完成快照日志标签;配置缺名称时回退到 TMDB/季号。"""
config = snap.get("subscribe_config") or {}
name = config.get("name")
Expand All @@ -187,6 +206,8 @@ def _extract_config(subscribe) -> dict:
"season": subscribe.season,
"episode_group": subscribe.episode_group,
"type": subscribe.type,
"best_version": int(bool(subscribe.best_version)),
"best_version_full": int(bool(subscribe.best_version_full)),
"keyword": subscribe.keyword,
"save_path": subscribe.save_path,
"sites": subscribe.sites,
Expand Down
Loading