Files
MoviePilot/app/api/endpoints/download.py
T

407 lines
14 KiB
Python

from typing import Any, List, Annotated, Optional, Union
from fastapi import Depends, Body
from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo
from app.schemas.download import DownloadAddedData as _SchemaDownloadAddedData
from app.schemas.download import DownloadDirectory as _SchemaDownloadDirectory
from app.schemas.download import SubtitleDownloadData as _SchemaSubtitleDownloadData
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.response import Response as _SchemaResponse
from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.transfer import DownloaderTorrent as _SchemaDownloaderTorrent
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.api.response import ResponseAPIRouter
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.adapters.web.security.access import verify_token
from app.api.principal import ApiPrincipal
from app.application.configuration import get_configured_system_config
from app.application.site.query import (
SiteQueryService,
get_configured_site_query_service,
)
from app.api.dependencies.auth import get_current_active_user
from app.api.dependencies.site import get_site_sync_query_service
from app.application.directory import DirectoryHelper
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaType,
MusicTargetEntityType,
SystemConfigKey,
)
from app.domain.media import is_music_media_source, normalize_music_type
from app.application.security.url import SecurityUtils
router = ResponseAPIRouter()
def _prepare_subtitle_download(
subtitle: SubtitleInfo,
query: SiteQueryService | None = None,
) -> tuple[bool, str]:
"""
校验字幕下载签名,并用服务端站点配置覆盖请求凭据。
"""
if subtitle.site is None:
return False, "字幕站点信息为空"
clean_url = SecurityUtils.verify_signed_url(
subtitle.enclosure,
purpose=SecurityUtils.subtitle_download_purpose(subtitle.site),
)
if not clean_url:
return False, "字幕下载链接签名无效"
site_query = query or get_configured_site_query_service()
site = site_query.get_sync(subtitle.site)
if not site:
return False, "字幕站点信息不存在"
subtitle.enclosure = clean_url
subtitle.site_cookie = site.cookie
subtitle.site_ua = site.ua
subtitle.site_proxy = bool(site.proxy)
return True, ""
def _build_unrecognized_media_info(
torrent: _SchemaTorrentInfo,
metainfo: MetaBase,
is_music: bool = False,
music_type: Optional[str] = None,
) -> MediaInfo | MusicInfo:
"""
为用户确认的未识别资源构造最小下载上下文,影视与音乐统一处理。
影视以种子分类兜底媒体类型并保留标题年份,音乐按解析标题构造音乐信息,
两者都不再要求识别出统一媒体信息即可继续下载。
"""
if is_music:
return MusicInfo(
title=metainfo.title or torrent.title,
year=metainfo.year,
music_type=music_type or MUSIC_ENTITY_RECORDING,
)
try:
media_type = MediaType(torrent.category)
except (TypeError, ValueError):
media_type = MediaType.from_agent(torrent.category)
if media_type == MediaType.COLLECTION:
media_type = MediaType.MOVIE
if media_type not in (MediaType.MOVIE, MediaType.TV):
media_type = metainfo.type
# 合集类型在回退到元数据后同样归一为电影,避免落到 UNKNOWN
if media_type == MediaType.COLLECTION:
media_type = MediaType.MOVIE
if media_type not in (MediaType.MOVIE, MediaType.TV):
media_type = MediaType.UNKNOWN
return MediaInfo(
type=media_type,
title=metainfo.name or torrent.title,
year=metainfo.year,
)
def _resolve_add_media(
torrent_in: _SchemaTorrentInfo,
media_source: MediaSource | None,
media_id: str | None,
music_type: MusicTargetEntityType | None,
allow_unrecognized: bool,
) -> tuple[MetaBase | None, MediaInfo | MusicInfo | None, _SchemaResponse | None]:
"""校验媒体身份并为无媒体信息下载构建识别上下文。"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return None, None, _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
if (media_source is None) != (media_id is None):
return None, None, _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return None, None, _SchemaResponse(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
metainfo = (
MetaMusic.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
if media_source and media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
media_source=media_source,
media_id=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
media_source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if mediainfo:
return metainfo, mediainfo, None
if not allow_unrecognized:
return metainfo, None, _SchemaResponse(
success=False,
message="无法识别媒体信息",
data=_SchemaDownloadAddedData(requires_confirmation=True),
)
return metainfo, _build_unrecognized_media_info(
torrent_in,
metainfo,
is_music=is_music,
music_type=normalized_music_type,
), None
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
def current(
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
) -> Any:
"""
查询正在下载的任务
"""
return DownloadChain().downloading(name)
@router.post(
"/",
summary="添加下载(含媒体信息)",
response_model=_SchemaResponse[_SchemaDownloadAddedData],
)
def download(
media_in: Union[_SchemaMusicInfo, _SchemaMediaInfo],
torrent_in: _SchemaTorrentInfo,
downloader: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
current_user: ApiPrincipal = Depends(get_current_active_user),
) -> Any:
"""
添加下载任务(含媒体信息)
"""
if isinstance(media_in, _SchemaMusicInfo):
mediainfo = MusicInfo.from_dict(media_in.model_dump())
metainfo = MetaMusic.from_music_info(mediainfo)
metainfo.org_string = torrent_in.title
else:
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.model_dump())
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())
# 手动下载始终使用选择的下载器
torrentinfo.site_downloader = downloader
# 上下文
context = Context(
meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo
)
did = DownloadChain().download_single(
context=context,
username=current_user.name,
save_path=save_path,
source="Manual",
)
if not did:
return _SchemaResponse(success=False, message="任务添加失败")
return _SchemaResponse(success=True, data={"download_id": did})
@router.post(
"/add",
summary="添加下载(不含媒体信息)",
response_model=_SchemaResponse[_SchemaDownloadAddedData],
)
def add(
torrent_in: _SchemaTorrentInfo,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
allow_unrecognized: Annotated[bool, Body()] = False,
downloader: Annotated[str | None, Body()] = None,
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
save_path: Annotated[str | None, Body()] = None,
current_user: ApiPrincipal = Depends(get_current_active_user),
) -> Any:
"""
添加下载任务(不含媒体信息)
"""
metainfo, mediainfo, error = _resolve_add_media(
torrent_in,
media_source,
media_id,
music_type,
allow_unrecognized,
)
if error:
return error
if metainfo is None or mediainfo is None:
return _SchemaResponse(success=False, message="无法识别媒体信息")
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())
# 上下文
context = Context(
meta_info=metainfo, media_info=mediainfo, torrent_info=torrentinfo
)
did = DownloadChain().download_single(
context=context,
username=current_user.name,
downloader=downloader,
save_path=save_path,
source="Manual",
)
if not did:
return _SchemaResponse(success=False, message="任务添加失败")
return _SchemaResponse(success=True, data={"download_id": did})
@router.post(
"/subtitle",
summary="下载字幕",
response_model=_SchemaResponse[_SchemaSubtitleDownloadData],
)
def download_subtitle(
subtitle_in: _SchemaSubtitleInfo,
media_source: Annotated[MediaSource, Body()],
media_id: Annotated[str, Body()],
save_path: Annotated[str | None, Body()] = None,
current_user: ApiPrincipal = Depends(get_current_active_user),
query: SiteQueryService = Depends(get_site_sync_query_service),
) -> Any:
"""
下载字幕资源。
"""
subtitle_info = SubtitleInfo()
subtitle_info.from_dict(subtitle_in.model_dump())
# 直接调用 endpoint 的旧测试/插件入口不会经过 FastAPI 依赖解析;此时让
# 应用查询端口自行提供服务,仍保留真实请求中的注入对象。
if not hasattr(query, "get_sync"):
valid, message = _prepare_subtitle_download(subtitle_info)
else:
valid, message = _prepare_subtitle_download(subtitle_info, query)
if not valid:
return _SchemaResponse(success=False, message=message)
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
media_source=media_source,
media_id=media_id,
save_path=save_path,
username=current_user.name,
)
return _SchemaResponse(
success=success,
message=message,
data={"files": saved_files} if saved_files else None,
)
@router.get("/start/{hashString}", summary="开始任务", response_model=_SchemaResponse[None])
def start(
hashString: str,
name: Optional[str] = None,
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
开如下载任务
"""
ret = DownloadChain().set_downloading(hashString, "start", name=name)
return _SchemaResponse(success=True if ret else False)
@router.get("/stop/{hashString}", summary="暂停任务", response_model=_SchemaResponse[None])
def stop(
hashString: str,
name: Optional[str] = None,
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
暂停下载任务
"""
ret = DownloadChain().set_downloading(hashString, "stop", name=name)
return _SchemaResponse(success=True if ret else False)
@router.get(
"/clients",
summary="查询可用下载器",
response_model=List[_SchemaServiceClientInfo],
)
async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询可用下载器
"""
downloaders: List[dict] = get_configured_system_config().get(SystemConfigKey.Downloaders)
if downloaders:
return [
{"name": d.get("name"), "type": d.get("type")}
for d in downloaders
if d.get("enabled")
]
return []
@router.get(
"/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory]
)
def paths(_: _SchemaTokenPayload = Depends(verify_token)) -> Any:
"""
查询可直接用于下载接口 save_path 参数的下载路径
"""
return [
_SchemaDownloadDirectory(
name=dir_info.name,
storage=dir_info.storage or "local",
download_path=dir_info.download_path,
save_path=_SchemaFileURI(
storage=dir_info.storage or "local",
path=dir_info.download_path,
).uri,
priority=dir_info.priority,
media_type=dir_info.media_type,
media_category=dir_info.media_category,
)
for dir_info in DirectoryHelper().get_download_dirs()
if dir_info.download_path
]
@router.delete("/{hashString}", summary="删除下载任务", response_model=_SchemaResponse[None])
def delete(
hashString: str,
name: Optional[str] = None,
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
删除下载任务
"""
ret = DownloadChain().remove_downloading(hashString, name=name)
return _SchemaResponse(success=True if ret else False)