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
6 changes: 3 additions & 3 deletions plugins.v2/subscribeassistantenhanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,10 @@
<a id="cfg-best_version_episode_to_full"></a>
#### 分集转全集(`best_version_episode_to_full`)

- **解决什么问题**:分集洗版在订阅目标集满足后,可以原地切换为全集洗版,后续用整季包继续完成最终洗版
- **解决什么问题**:分集洗版在媒体库实际覆盖完整目标范围后,可以切换为全集洗版,后续用整季包统一做种和继续洗版
- **什么时候开或关闭**:长期追剧并希望最终获得整季高质量版本时开启。电影或纯整季洗版不需要开启。
- **典型联动**:「洗版类型」启用时才会注册洗版巡检。整理完成事件会先尝试即时切换;若未命中或错过事件,洗版巡检仍按「洗版检查周期」兜底推进分集转全集。转为全集洗版后会按「剧集洗版时限(天)」参与超时终止,分集洗版本身不适用该时限。「回填已存在集」能补齐存量下载事实,减少重复下载
- **常见误用**:开启分集转全集但没有任何目标集满足时不会立即切换。即时切换前提是当前订阅仍为分集洗版,且按主程序订阅目标满足口径已无剩余搜索 / 下载范围;分集洗版会把已下载过任意版本的目标集视为满足,全集洗版仍继续等待整季洗版资源
- **典型联动**:「洗版类型」启用时才会注册洗版巡检。整理完成事件与洗版巡检共用同一转换检查:目标范围有效、没有下载待定,且媒体库实际覆盖全部目标集。其他待定来源不会单独阻断转换。转为全集洗版后会按「剧集洗版时限(天)」参与超时终止,分集洗版本身不适用该时限。
- **常见误用**:完整覆盖只表示可以切换模式,不代表转换后一定不会下载。关联分集下载历史为零或一条时保留当前全集准入基线,避免已完结剧直接下载一个完整包后重复下载;存在多条分集下载历史时,全集准入基线从 `0` 开始,以便后续下载整包统一做种。历史查询失败时本轮不会转换,会等待下次巡检

<a id="cfg-subscription_cleanup_history_type"></a>
#### 清理整理记录范围(`subscription_cleanup_history_type`)
Expand Down
79 changes: 69 additions & 10 deletions plugins.v2/subscribeassistantenhanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ def _init_modules(self):
subscription_cleanup=subscription_cleanup,
converter=converter,
best_version_episode_to_full=cfg.best_version_episode_to_full,
convert_episode_best_version_to_full_fn=self._convert_episode_best_version_to_full_if_ready,
)

self._modules = {
Expand Down Expand Up @@ -759,7 +760,7 @@ def run_best_version_check(self):
if mediainfo:
if is_full_best_version_subscribe(subscribe) and self._best_version_overdue(subscribe):
logger.info(f"洗版巡检:{format_subscribe(subscribe)} {mode_label}超过洗版时限,标记洗版完成并停止洗版")
priority.mark_complete(subscribe)
priority.mark_full_best_version_complete(subscribe)
self._notify_subscribe(
f"{format_subscribe(subscribe)} {mode_label}超过时限"
f"({self._best_version_timeout_days(subscribe)}天),已标记洗版优先级为完成",
Expand All @@ -771,15 +772,12 @@ def run_best_version_check(self):
and converter
and is_tv_episode_best_version_subscribe(subscribe)
):
satisfied, _no_exists = self._resolve_subscribe_missing(
self._convert_episode_best_version_to_full_if_ready(
subscribe.id,
subscribe,
mediainfo,
best_version_accept_downloaded=True,
trigger="洗版巡检",
)
if not satisfied:
continue
logger.info(f"洗版巡检:{format_subscribe(subscribe)} 分集洗版目标满足,转为全集洗版")
converter.convert_to_full(subscribe, mediainfo)
continue
else:
detail(
Expand Down Expand Up @@ -919,11 +917,11 @@ def run_site_evidence_scan(self):
detail("站点证据采样:开始")
for subscribe in (self._subscribe_oper.list(state="P,R") or []):
if (
getattr(subscribe, "state", None) in ("P", "R")
subscribe.state in ("P", "R")
and
resolve_subscribe_media_type(subscribe) == MediaType.TV
and not is_full_best_version_subscribe(subscribe)
and not bool(getattr(subscribe, "manual_total_episode", False))
and not bool(subscribe.manual_total_episode)
):
site_evidence.refresh_subscribe(subscribe)

Expand Down Expand Up @@ -988,7 +986,7 @@ def _last_download_date(self, subscribe) -> Optional[datetime.date]:
except Exception:
return None

def _related_download_histories(self, subscribe) -> list:
def _related_download_histories(self, subscribe, raise_on_error: bool = False) -> list:
"""获取同一订阅完成后的分集下载历史,用于判断是否应自动洗版。"""
try:
if subscribe.type == "电影":
Expand All @@ -1008,6 +1006,8 @@ def _related_download_histories(self, subscribe) -> list:
)
except Exception as err:
logger.warning(f"洗版编排:查询关联下载历史失败,跳过分集洗版判定:{err}")
if raise_on_error:
raise
return []

related = []
Expand Down Expand Up @@ -1039,6 +1039,65 @@ def _related_download_histories(self, subscribe) -> list:
related.append(history)
return related

def _convert_episode_best_version_to_full_if_ready(
self,
subscribe_id,
subscribe=None,
mediainfo=None,
trigger: str = "洗版巡检",
) -> bool:
"""在下载待定已释放且媒体库完整覆盖目标范围时,将分集洗版转为全集洗版。"""
if not self._config or not self._config.best_version_episode_to_full:
return False
if not subscribe_id or not self._subscribe_oper:
return False
subscribe = subscribe or self._subscribe_oper.get(subscribe_id)
if not subscribe or not is_tv_episode_best_version_subscribe(subscribe):
return False

try:
start_episode = int(subscribe.start_episode or 1)
total_episode = int(subscribe.total_episode or 0)
except (TypeError, ValueError):
return False
start_episode = max(start_episode, 1)
if total_episode < start_episode:
return False
target_episodes = set(range(start_episode, total_episode + 1))

download_monitor = self._modules.get("download_monitor")
if download_monitor and download_monitor.has_active_downloads(subscribe.id):
detail(f"{trigger}:{format_subscribe(subscribe)} 仍有下载待定,跳过分集转全集")
return False

existing_episodes, missing_episodes = self._detect_episode_coverage(subscribe)
if missing_episodes or not target_episodes.issubset(set(existing_episodes)):
return False

try:
episode_histories = self._related_download_histories(subscribe, raise_on_error=True)
except Exception:
return False
try:
current_priority = int(subscribe.current_priority or 0)
except (TypeError, ValueError):
current_priority = 0
full_priority = 0 if len(episode_histories) > 1 else current_priority

mediainfo = mediainfo or self._recognize_mediainfo(subscribe)
converter = self._modules.get("converter")
if not mediainfo or not converter:
return False
logger.info(
f"{trigger}:{format_subscribe(subscribe)} 媒体库已完整覆盖目标范围,"
f"分集下载历史={len(episode_histories)},转为全集洗版"
)
return converter.convert_to_full(
subscribe,
mediainfo,
current_priority=full_priority,
)

@staticmethod
def _is_full_pack_download(history, total_episode: Optional[int]) -> bool:
"""判断下载历史是否为合集/全集包;全集包不参与分集洗版触发计数。"""
Expand Down
10 changes: 6 additions & 4 deletions plugins.v2/subscribeassistantenhanced/best_version/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ def __init__(self, subscribe_oper=None, clear_tasks_fn=None, send_event_fn=None,
self._notification_image = notification_image_fn
self._plugin_name = plugin_name

def convert_to_full(self, subscribe, mediainfo=None) -> bool:
"""替换为全集洗版订阅,成功返回 True;失败时尽量恢复分集订阅。"""
def convert_to_full(self, subscribe, mediainfo=None, current_priority=None) -> bool:
"""按指定全集准入基线替换为全集洗版订阅;失败时尽量恢复分集订阅。"""
sid = subscribe.id
if not sid or not self._subscribe_oper or not mediainfo:
return False

subscribe_dict = subscribe.to_dict()
subscribe_desc = self._format_subscribe_desc(subscribe, mediainfo)
full_payload = self._build_full_payload(subscribe_dict)
full_payload = self._build_full_payload(subscribe_dict, current_priority=current_priority)

try:
if self._snapshot:
Expand Down Expand Up @@ -81,7 +81,7 @@ def convert_to_full(self, subscribe, mediainfo=None) -> bool:
self._notify_failure(subscribe, subscribe_desc, f"{err_msg}\n{restore_text}", mediainfo=mediainfo)
return False

def _build_full_payload(self, subscribe_dict: dict) -> dict:
def _build_full_payload(self, subscribe_dict: dict, current_priority=None) -> dict:
"""从订阅快照构造全集洗版 payload,并保留订阅范围字段。"""
payload = dict(subscribe_dict or {})
for field in DROP_REBUILT_FIELDS:
Expand All @@ -91,6 +91,8 @@ def _build_full_payload(self, subscribe_dict: dict) -> dict:
payload["username"] = self._plugin_name
payload["state"] = "N"
payload["manual_total_episode"] = 0
if current_priority is not None:
payload["current_priority"] = current_priority
return payload

def _format_subscribe_desc(self, subscribe, mediainfo) -> str:
Expand Down
80 changes: 37 additions & 43 deletions plugins.v2/subscribeassistantenhanced/best_version/priority.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,14 @@ def rollback(self, subscribe, baseline: Optional[dict] = None):
if media_type == MediaType.TV:
episode_priority = baseline.get("episode_priority", {})
detail(f"洗版事实:{self._format_subscribe_label(subscribe)} 已恢复到下载前剧集优先级基线")
self._update_tv_episode_priority(subscribe, episode_priority, scene="plugin_rollback")
current_priority = baseline.get("current_priority", 0) \
if is_full_best_version_subscribe(subscribe) else None
self._update_tv_episode_priority(
subscribe,
episode_priority,
scene="plugin_rollback",
current_priority=current_priority,
)
return

if media_type == MediaType.MOVIE and self._subscribe_oper:
Expand Down Expand Up @@ -124,7 +131,18 @@ def rollback_torrent(self, subscribe, torrent_id):
if ep_priority.get(ep_key, 0) == contributed:
ep_priority[ep_key] = old_value
detail(f"洗版事实:{self._format_subscribe_label(subscribe)} 已恢复种子 {torrent_id} 对应集的优先级")
self._update_tv_episode_priority(subscribe, ep_priority, scene="plugin_rollback")
current_priority = None
if is_full_best_version_subscribe(subscribe):
current = subscribe.current_priority or 0
contributed = baseline.get("contributed_priority", 0)
if current == contributed:
current_priority = baseline.get("current_priority_baseline", 0)
self._update_tv_episode_priority(
subscribe,
ep_priority,
scene="plugin_rollback",
current_priority=current_priority,
)
elif media_type == MediaType.MOVIE and self._subscribe_oper:
current = subscribe.current_priority or 0
contributed = baseline.get("contributed_priority", 0)
Expand Down Expand Up @@ -159,47 +177,13 @@ def backfill_existing(self, subscribe, existing_episodes: list, scene: str = "pl
)
return bool(summary and summary.get("updated"))

def is_complete(self, subscribe) -> bool:
"""判断洗版是否完成——所有目标集优先级达标(>=100)。"""
ep_priority = subscribe.episode_priority or {}
target_episodes = self._target_episodes(subscribe)
if not ep_priority or not target_episodes:
return False
return all(ep_priority.get(str(ep), 0) >= 100 for ep in target_episodes)

def mark_complete(self, subscribe):
"""标记洗版完成;TV 交给主程序 backfill 合同写事实,电影写整体优先级。"""
if resolve_subscribe_media_type(subscribe) == MediaType.MOVIE:
payload = {"current_priority": 100}
mode_label = self._mode_label(subscribe)
detail(f"洗版优先级:{self._format_subscribe_label(subscribe)} 标记{mode_label}完成(priority=100)")
if self._subscribe_oper:
update_subscribe(self._subscribe_oper, subscribe.id, payload)
return

target_episodes = self._target_episodes(subscribe)
def mark_full_best_version_complete(self, subscribe):
"""将超时的电影或全集洗版标记为完成,仅更新当前模式的资源准入基线。"""
payload = {"current_priority": 100}
mode_label = self._mode_label(subscribe)
detail(f"洗版优先级:{self._format_subscribe_label(subscribe)} 标记{mode_label}完成(priority=100)")
if target_episodes:
SubscribeChain().backfill_existing_episodes(
subscribe,
target_episodes,
priority=100,
scene=self._format_backfill_scene("plugin_complete"),
)

@staticmethod
def _target_episodes(subscribe) -> list:
"""读取订阅目标集范围;范围无效时返回空,避免只凭已有 priority 键误判完成。"""
try:
start_episode = int(subscribe.start_episode or 1)
total_episode = int(subscribe.total_episode or 0)
except (TypeError, ValueError):
return []
start_episode = max(start_episode, 1)
if total_episode < start_episode:
return []
return list(range(start_episode, total_episode + 1))
if self._subscribe_oper:
update_subscribe(self._subscribe_oper, subscribe.id, payload)

@staticmethod
def _format_subscribe_label(subscribe) -> str:
Expand All @@ -220,10 +204,20 @@ def _episode_priority_snapshot(subscribe) -> dict:
"""读取剧集优先级快照;无按集事实时复用主程序 current_priority 兜底口径。"""
return SubscribeChain.get_episode_priority(subscribe)

def _update_tv_episode_priority(self, subscribe, episode_priority: dict, scene: str):
def _update_tv_episode_priority(
self,
subscribe,
episode_priority: dict,
scene: str,
current_priority: Optional[int] = None,
):
"""写回 TV 剧集事实后刷新主程序进度字段。"""
if not self._subscribe_oper:
return
update_subscribe(self._subscribe_oper, subscribe.id, {"episode_priority": episode_priority})
payload = {"episode_priority": episode_priority}
if current_priority is not None:
payload["current_priority"] = current_priority
subscribe.current_priority = current_priority
update_subscribe(self._subscribe_oper, subscribe.id, payload)
subscribe.episode_priority = episode_priority
SubscribeChain().refresh_subscribe_progress(subscribe, scene=scene)
3 changes: 1 addition & 2 deletions plugins.v2/subscribeassistantenhanced/engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,4 @@ def rollback(self, subscribe, baseline) -> None: ...
def rollback_torrent(self, subscribe, torrent_id) -> None: ...
def can_backfill(self, subscribe) -> bool: ...
def backfill_existing(self, subscribe, existing_episodes, scene: str = "plugin_backfill") -> bool: ...
def is_complete(self, subscribe) -> bool: ...
def mark_complete(self, subscribe) -> None: ...
def mark_full_best_version_complete(self, subscribe) -> None: ...
34 changes: 4 additions & 30 deletions plugins.v2/subscribeassistantenhanced/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,36 +603,10 @@ def on_transfer_complete(self, event):
self._convert_episode_best_version_to_full_if_ready(subscribe_id)

def _convert_episode_best_version_to_full_if_ready(self, subscribe_id):
"""整理完成后补偿检查当前分集洗版订阅,避免目标集已齐全还要等下一次洗版巡检。"""
if not subscribe_id or not self.get("best_version_episode_to_full"):
return
subscribe_oper = self.get("subscribe_oper")
converter = self.get("converter")
detect_missing = self.get("detect_missing_episodes_fn")
resolve_missing = self.get("resolve_missing_fn")
recognize = self.get("recognize_mediainfo_fn")
if not (subscribe_oper and converter and recognize):
return
subscribe = subscribe_oper.get(subscribe_id)
if not subscribe or not is_tv_episode_best_version_subscribe(subscribe):
return
mediainfo = recognize(subscribe)
if not mediainfo:
return
if resolve_missing:
satisfied, _ = resolve_missing(
subscribe=subscribe,
mediainfo=mediainfo,
best_version_accept_downloaded=True,
)
else:
if not detect_missing or (subscribe.lack_episode or 0) > 0:
return
satisfied = not detect_missing(subscribe)
if not satisfied:
return
detail(f"TransferComplete:{format_subscribe(subscribe)} 分集洗版目标满足,立即转为全集洗版")
converter.convert_to_full(subscribe, mediainfo)
"""整理完成后委托共享 readiness 入口补偿检查分集转全集。"""
convert = self.get("convert_episode_best_version_to_full_fn")
if subscribe_id and convert:
convert(subscribe_id, trigger="TransferComplete")

@staticmethod
def _best_version_mode_label(subscribe) -> str:
Expand Down
4 changes: 2 additions & 2 deletions tests/v2/subscribeassistantenhanced/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def test_success(self):
)
media = _mediainfo()

assert conv.convert_to_full(sub, media) is True
assert conv.convert_to_full(sub, media, current_priority=0) is True

oper.add_history.assert_called_once_with(**sub.to_dict())
oper.delete.assert_called_once_with(sid=1)
Expand All @@ -73,7 +73,7 @@ def test_success(self):
assert add_payload["username"] == "订阅助手(增强版)"
assert add_payload["manual_total_episode"] == 0
assert add_payload["note"] == [1, 2]
assert add_payload["current_priority"] == 50
assert add_payload["current_priority"] == 0
assert add_payload["episode_priority"] == {"1": 100, "2": 50}
assert "id" not in add_payload
snapshot.assert_called_once_with(subscribe=sub, mediainfo=media, scope=None)
Expand Down
Loading