from typing import Annotated, Any, List, Optional, Union from fastapi import Body, Depends from app.adapters.web.security.access import verify_token from app.api.dependencies.auth import get_current_active_user from app.api.dependencies.site import get_site_sync_query_service from app.api.principal import ApiPrincipal from app.api.response import ResponseAPIRouter from app.application.configuration import get_configured_system_config from app.application.directory import DirectoryHelper from app.application.security.url import SecurityUtils from app.application.site.query import ( SiteQueryService, get_configured_site_query_service, ) 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.media import is_music_media_source, normalize_music_type from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo 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.types import ( MUSIC_ENTITY_RECORDING, MediaSource, MediaType, MusicTargetEntityType, SystemConfigKey, ) from app.schemas.workflow import MediaInfo as _SchemaMediaInfo 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, # 保存路径, 支持:, 如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)