mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 20:54:32 +08:00
407 lines
14 KiB
Python
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)
|