Files
MoviePilot/app/chain/download/submission.py
T
InfinityPacer 68880dbbf1 优化订阅执行治理与规模性能 (#6557)
* feat(subscription): govern concurrent search and match execution

* refactor(subscription): consume complete candidate snapshots

* refactor(subscription): remove download submission ledger

* feat(subscription): stagger fallback searches without site gaps

* refactor(subscription): remove unused retry capability

* test(subscription): verify complete scale execution

* fix(subscription): surface site search failures

* test(subscription): verify site pressure through search

* test(subscription): use static facade imports

* fix(subscription): preserve completed downloads on shutdown

* fix(subscription): classify execution expiry as failure

* fix(subscription): preserve fallback search staggering

* test(subscription): stabilize unfinished wrapper gate

* fix(subscription): settle expired search returns

* style(subscription): normalize governance imports

* fix(subscription): align governance type contracts

* docs(architecture): sync mypy debt metric

* docs(architecture): sync startup module counts
2026-09-03 11:26:24 +08:00

607 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""种子获取与单任务提交 owner。"""
import base64
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Set, Tuple, Union, cast
from urllib.parse import urlencode, urljoin, urlparse
from app.application.configuration import get_chain_runtime_config_snapshot
from app.application.directory import validate_download_save_path
from app.application.download.admission import SubscriptionDownloadGovernance
from app.application.torrent.download import TorrentHelper
from app.chain.download.contract import _DownloadOwnerBase
from app.chain.download.ports import (
_close_download_response,
_download_ports_snapshot,
)
from app.chain.media import MediaChain
from app.domain import episode as episode_rules
from app.domain.context import (
Context,
MediaInfo,
MusicInfo,
TorrentInfo,
)
from app.domain.meta.metabase import MetaBase
from app.runtime.cache import FileCache
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.schemas.event import ResourceDownloadEventData
from app.schemas.file import FileURI
from app.schemas.message import Message
from app.schemas.types import (
ChainEventType,
MessageType,
NotificationChannel,
)
@dataclass(frozen=True, slots=True)
class _PreparedDownload:
"""下载器调用前已经验证并规范化的本地提交事实。"""
torrent: TorrentInfo
media: Union[MediaInfo, MusicInfo]
meta: MetaBase
torrent_content: Union[str, bytes]
folder_name: str
file_list: list[str]
download_dir: Path
download_uri: str
download_episodes: Optional[str]
site_downloader: Optional[str]
class _DownloadResourceOwner(_DownloadOwnerBase):
"""种子获取、间接地址解析与资源下载事件 owner。"""
@staticmethod
def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str:
"""
将两段式下载结果约束到索引器配置的可信 API 地址。
:param url: 换票接口返回的临时下载地址
:param base_url: 索引器配置的可信 API Base URL
:return: 使用可信 API 来源的临时下载地址
"""
if not url or not base_url:
return url
base_parts = urlparse(base_url)
if not base_parts.scheme or not base_parts.netloc:
return url
url_parts = urlparse(url)
if not url_parts.netloc:
return urljoin(f"{base_url.rstrip('/')}/", url)
return url_parts._replace(
scheme=base_parts.scheme,
netloc=base_parts.netloc,
).geturl()
def _resolve_indirect_download_url(
self,
url: str,
ua: Optional[str] = None,
cookie: Optional[str] = None,
) -> Optional[str]:
"""解析索引器编码的换票请求,并返回可信来源的临时下载地址。"""
match = re.search(r"\[(.*)](.*)", url)
if not match:
return None
encoded_params, request_url = match.groups()
if not encoded_params:
return request_url
request_text = base64.b64decode(encoded_params.encode("utf-8")).decode("utf-8")
request_params = cast(Dict[str, Any], json.loads(request_text))
if not request_params.get("cookie"):
cookie = None
headers_value = request_params.get("header")
headers = (
cast(dict[str, str], headers_value)
if isinstance(headers_value, dict)
else None
)
params_value = request_params.get("params")
params = (
cast(dict[str, Any], params_value)
if isinstance(params_value, dict)
else None
)
http, _ = _download_ports_snapshot()
proxies = (
get_chain_runtime_config_snapshot().proxy
if request_params.get("proxy")
else None
)
if request_params.get("method") == "get":
response = http.get(
request_url,
ua=ua,
cookies=cookie,
headers=headers,
proxies=proxies,
params=params,
)
else:
response = http.post(
request_url,
ua=ua,
cookies=cookie,
headers=headers,
proxies=proxies,
params=params,
)
if not response:
return None
try:
if not request_params.get("result"):
return response.text
data: Any = response.json()
success_key = request_params.get("success")
if success_key and not data.get(success_key):
return None
for key in str(request_params.get("result")).split("."):
if not isinstance(data, dict):
return None
data = data.get(key)
if not data:
return None
result_path = request_params.get("result_path")
result_query_param = request_params.get("result_query_param")
if result_path and result_query_param:
if not isinstance(result_query_param, str):
return None
result_url = urljoin(
f"{str(request_params.get('result_base_url')).rstrip('/')}/",
str(result_path).lstrip("/"),
)
return f"{result_url}?{urlencode({result_query_param: data})}"
if not isinstance(data, str):
return None
base_url = request_params.get("result_base_url")
normalized_url = self._normalize_indirect_download_url(
url=data,
base_url=base_url if isinstance(base_url, str) else None,
)
logger.info("已获取到站点临时下载地址")
return normalized_url
finally:
_close_download_response(response)
def download_torrent(self, torrent: TorrentInfo,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
userid: Union[str, int, None] = None
) -> Tuple[Optional[Union[str, bytes]], str, list[str]]:
"""
下载种子文件,如果是磁力链,会返回磁力链接本身
:return: 种子内容,种子目录名,种子文件清单
"""
# 获取下载链接
if not torrent.enclosure:
return None, "", []
if torrent.enclosure.startswith("magnet:"):
return torrent.enclosure, "", []
# Cookie
site_cookie: Optional[str] = torrent.site_cookie
indirect_download = torrent.enclosure.startswith("[")
if indirect_download:
# 需要解码获取下载地址
torrent_url = self._resolve_indirect_download_url(
url=torrent.enclosure,
ua=torrent.site_ua,
cookie=site_cookie,
)
# 涉及解析地址的不使用Cookie下载种子,否则MT会出错
site_cookie = None
else:
torrent_url = torrent.enclosure
if not torrent_url:
logger.error(f"{torrent.title} 无法获取下载地址!")
return None, "", []
# 下载种子文件
_, content, download_folder, files, error_msg = cast(
Any, TorrentHelper
)().download_torrent(
url=torrent_url,
cookie=site_cookie,
ua=torrent.site_ua or self.runtime_config.user_agent,
proxy=torrent.site_proxy,
cache_invalid=not indirect_download)
if isinstance(content, str):
# 磁力链
return content, "", []
if not content:
logger.error(f"下载种子文件失败:{torrent.title}")
self.post_message(Message(
channel=channel,
source=source if channel else None,
mtype=MessageType.Manual,
title=f"{torrent.title} 种子下载失败!",
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
userid=userid))
return None, "", []
# 返回 种子文件路径,种子目录名,种子文件清单
return content, download_folder or "", files or []
@staticmethod
def _apply_resource_download_event(
context: Context,
episodes: Optional[Set[int]],
channel: Optional[NotificationChannel],
source: Optional[str],
downloader: Optional[str],
save_path: Optional[str],
userid: Union[str, int, None],
username: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
"""应用资源下载事件覆盖,并校验事件返回的下载目录。"""
meta = context.meta_info
media = context.media_info
event_data = ResourceDownloadEventData(
context=context,
episodes=episodes or (meta.episode_list if meta else []),
channel=channel,
origin=source,
downloader=downloader,
options={
"save_path": save_path,
"userid": userid,
"username": username,
"media_category": media.category if media else None,
},
)
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
if event and event.event_data:
event_data = cast(ResourceDownloadEventData, event.event_data)
if event_data.cancel:
logger.debug(
"Resource download canceled by event: %s,Reason: %s",
event_data.source,
event_data.reason,
)
return save_path, "下载被事件取消"
if event_data.options and "save_path" in event_data.options:
save_path = cast(Optional[str], event_data.options.get("save_path"))
if save_path is None:
return None, None
try:
return validate_download_save_path(save_path), None
except ValueError as err:
logger.warn(str(err))
return save_path, str(err)
class DownloadSubmissionOwner(_DownloadResourceOwner):
"""单任务下载准备、提交与结算 owner。"""
@staticmethod
def _subscription_download_cancelled(
governance: Optional[SubscriptionDownloadGovernance],
) -> bool:
"""在下载器副作用前读取订阅执行上下文的停止信号。"""
return bool(governance and governance.cancelled and governance.cancelled())
def download_single(self, context: Context,
torrent_file: Optional[Path] = None,
torrent_content: Optional[Union[str, bytes]] = None,
episodes: Optional[Set[int]] = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
downloader: Optional[str] = None,
save_path: Optional[str] = None,
userid: Union[str, int, None] = None,
username: Optional[str] = None,
label: Optional[str] = None,
return_detail: bool = False,
custom_words: Optional[str] = None,
governance: Optional[SubscriptionDownloadGovernance] = None,
) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]:
"""
下载单个资源并发送结果通知。
保持下载链、消息入口和插件使用的公开签名,实际流程委托给内部执行阶段。
"""
return self._execute_download_single(
context=context,
torrent_file=torrent_file,
torrent_content=torrent_content,
episodes=episodes,
channel=channel,
source=source,
downloader=downloader,
save_path=save_path,
userid=userid,
username=username,
label=label,
return_detail=return_detail,
custom_words=custom_words,
governance=governance,
)
def _execute_download_single(
self,
context: Context,
torrent_file: Optional[Path] = None,
torrent_content: Optional[Union[str, bytes]] = None,
episodes: Optional[Set[int]] = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
downloader: Optional[str] = None,
save_path: Optional[str] = None,
userid: Union[str, int, None] = None,
username: Optional[str] = None,
label: Optional[str] = None,
return_detail: bool = False,
custom_words: Optional[str] = None,
governance: Optional[SubscriptionDownloadGovernance] = None,
) -> Union[Optional[str], Tuple[Optional[str], Optional[str]]]:
"""准备下载事实,提交下载器并按订阅治理合同结算结果。"""
prepared, error_msg = self._prepare_download_single(
context=context,
torrent_file=torrent_file,
torrent_content=torrent_content,
episodes=episodes,
channel=channel,
source=source,
downloader=downloader,
save_path=save_path,
userid=userid,
username=username,
)
if prepared is None:
return (None, error_msg) if return_detail else None
download_hash, error_msg = self._submit_prepared_download(
prepared=prepared,
context=context,
episodes=episodes,
channel=channel,
source=source,
downloader=downloader,
userid=userid,
username=username,
label=label,
custom_words=custom_words,
governance=governance,
)
return (download_hash, error_msg) if return_detail else download_hash
def _prepare_download_single(
self,
*,
context: Context,
torrent_file: Optional[Path],
torrent_content: Optional[Union[str, bytes]],
episodes: Optional[Set[int]],
channel: Optional[NotificationChannel],
source: Optional[str],
downloader: Optional[str],
save_path: Optional[str],
userid: Union[str, int, None],
username: Optional[str],
) -> tuple[Optional[_PreparedDownload], Optional[str]]:
"""补全媒体、读取种子并解析出可信下载目录。"""
torrent, media, meta = context.torrent_info, context.media_info, context.meta_info
if torrent is None or media is None or meta is None:
return None, "下载上下文缺少媒体、元数据或种子信息"
site_downloader = torrent.site_downloader
supplemented_media = MediaChain().supplement_tmdb_info(media, meta)
if not isinstance(supplemented_media, (MediaInfo, MusicInfo)):
return None, "媒体信息补全失败"
media = supplemented_media
context.media_info = media
save_path, event_error = self._apply_resource_download_event(
context, episodes, channel, source, downloader, save_path, userid, username
)
if event_error:
return None, event_error
download_episodes = episode_rules.format_ranges(list(episodes)) if episodes else None
if episodes is not None:
context.selected_episodes = sorted(set(episodes))
elif meta.episode_list:
context.selected_episodes = sorted(set(meta.episode_list))
else:
context.selected_episodes = []
if not torrent_file and not torrent_content:
torrent_content, _, _ = self.download_torrent(
torrent, channel=channel, source=source, userid=userid
)
elif torrent_file:
torrent_content = (
torrent_file.read_bytes()
if torrent_file.exists()
else FileCache().get(torrent_file.as_posix(), region="torrents")
)
if not torrent_content:
self._record_download_failure(
context=context,
error_msg="下载种子内容为空",
downloader=downloader or site_downloader,
source=source,
episodes=episodes,
)
return None, "下载种子内容为空"
folder_name, file_list = cast(Any, TorrentHelper)().get_fileinfo_from_torrent_content(
torrent_content
)
album_error = self._validate_music_album_resource(context, file_list)
if album_error:
logger.info(f"{torrent.title} {album_error},跳过该资源")
self._record_download_failure(
context=context,
error_msg=album_error,
downloader=downloader or site_downloader,
source=source,
episodes=episodes,
)
return None, album_error
storage, download_dir, error_msg = self._resolve_media_download_dir(
media_info=media,
save_path=save_path,
)
if not download_dir or not storage:
if error_msg == "未找到下载目录":
self.messagehelper.put(
f"{media.type.value} {media.title_year} 未找到下载目录!",
title="下载失败",
role="system",
)
return None, error_msg or "未找到下载目录"
download_uri = FileURI(storage=storage, path=download_dir.as_posix()).uri
return _PreparedDownload(
torrent=torrent,
media=media,
meta=meta,
torrent_content=torrent_content,
folder_name=folder_name,
file_list=file_list,
download_dir=Path(download_uri),
download_uri=download_uri,
download_episodes=download_episodes,
site_downloader=site_downloader,
), None
def _submit_prepared_download(
self,
*,
prepared: _PreparedDownload,
context: Context,
episodes: Optional[Set[int]],
channel: Optional[NotificationChannel],
source: Optional[str],
downloader: Optional[str],
userid: Union[str, int, None],
username: Optional[str],
label: Optional[str],
custom_words: Optional[str],
governance: Optional[SubscriptionDownloadGovernance],
) -> tuple[Optional[str], Optional[str]]:
"""在可取消边界后调用下载器,并分派成功或拒绝结算。"""
if self._subscription_download_cancelled(governance):
return None, "订阅下载在提交前已取消"
if governance and governance.mark_started:
governance.mark_started()
result = self.download(
content=prepared.torrent_content,
cookie=prepared.torrent.site_cookie,
episodes=cast(Set[int], episodes),
download_dir=prepared.download_dir,
category=prepared.media.category,
label=label,
downloader=downloader or prepared.site_downloader,
)
actual_downloader, download_hash, layout, error_msg = (
result if result else (None, None, None, "未找到下载器")
)
if download_hash:
self._settle_accepted_download(
prepared=prepared,
context=context,
episodes=episodes,
channel=channel,
source=source,
userid=userid,
username=username,
custom_words=custom_words,
actual_downloader=actual_downloader,
download_hash=download_hash,
layout=layout,
)
else:
self._record_rejected_download(
prepared=prepared,
context=context,
episodes=episodes,
channel=channel,
source=source,
downloader=downloader,
userid=userid,
actual_downloader=actual_downloader,
error_msg=error_msg,
)
return download_hash, error_msg
def _settle_accepted_download(
self,
*,
prepared: _PreparedDownload,
context: Context,
episodes: Optional[Set[int]],
channel: Optional[NotificationChannel],
source: Optional[str],
userid: Union[str, int, None],
username: Optional[str],
custom_words: Optional[str],
actual_downloader: Optional[str],
download_hash: str,
layout: Optional[str],
) -> None:
"""按普通下载合同结算下载器明确返回的成功结果。"""
self._settle_download_success(
context=context,
media=prepared.media,
meta=prepared.meta,
torrent=prepared.torrent,
folder_name=prepared.folder_name,
file_list=prepared.file_list,
download_dir=prepared.download_dir,
layout=layout,
downloader=actual_downloader,
download_hash=download_hash,
download_episodes=prepared.download_episodes,
episodes=episodes,
channel=channel,
source=source,
userid=userid,
username=username,
torrent_content=prepared.torrent_content,
custom_words=custom_words,
)
def _record_rejected_download(
self,
*,
prepared: _PreparedDownload,
context: Context,
episodes: Optional[Set[int]],
channel: Optional[NotificationChannel],
source: Optional[str],
downloader: Optional[str],
userid: Union[str, int, None],
actual_downloader: Optional[str],
error_msg: str,
) -> None:
"""按普通失败合同记录下载器明确拒绝并通知原调用渠道。"""
logger.error(
f"{prepared.media.title_year} 添加下载任务失败:"
f"{prepared.torrent.title} - {prepared.torrent.enclosure}{error_msg}"
)
self._record_download_failure(
context=context,
error_msg=error_msg,
downloader=actual_downloader or downloader or prepared.site_downloader,
source=source,
episodes=episodes,
)
self.post_message(
Message(
channel=channel,
source=source if channel else None,
mtype=MessageType.Manual,
title=f"添加下载任务失败:{prepared.media.title_year} {prepared.meta.season_episode}",
text=(
f"站点:{prepared.torrent.site_name}\n"
f"种子名称:{prepared.meta.org_string}\n"
f"错误信息:{error_msg}"
),
image=prepared.media.get_message_image(),
userid=userid,
)
)