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

640 lines
20 KiB
Python

from typing import List, Any, Dict, Optional
from fastapi import Depends, HTTPException
from typing import Annotated
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.response import Response as _SchemaResponse
from app.schemas.site import SiteAuth as _SchemaSiteAuth
from app.schemas.site import SiteCategory as _SchemaSiteCategory
from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate
from app.schemas.site import SiteIconData as _SchemaSiteIconData
from app.schemas.site import SiteMappingData as _SchemaSiteMappingData
from app.schemas.site import SiteStatistic as _SchemaSiteStatistic
from app.schemas.site import SiteUserData as _SchemaSiteUserData
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.workflow import Site as _SchemaSite
from app.api.response import ResponseAPIRouter
from app.application.site.mutation import SiteMutationCommand
from app.application.site.query import SiteQueryService
from app.api.endpoints.plugin import register_plugin_api
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
from app.application.commands import init_commands
from app.application.plugin.runtime import get_plugin_manager
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.api.dependencies.auth import (
get_current_active_manage_user,
get_current_active_manage_user_async,
get_current_active_superuser,
get_current_active_superuser_async,
)
from app.api.dependencies.site import (
get_site_mutation_command,
get_site_query_service,
get_site_sync_query_service,
)
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.runtime.log import logger
from app.application.scheduling import get_scheduler
from app.schemas.types import SystemConfigKey, MediaType
from app.domain import site as site_rules
from app.api.context import get_background_task_registry, resolve_background_task_registry
from app.runtime.tasks import TaskRegistry
router = ResponseAPIRouter()
def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool:
"""
判断站点索引器是否支持指定媒体类型。
:param indexer: 站点索引器配置
:param media_type: 待搜索的媒体类型
:return: 是否应在该媒体类型的站点选择列表中显示
"""
declared_media_type = indexer.get("media_type")
if isinstance(declared_media_type, MediaType):
site_media_type = declared_media_type
elif isinstance(declared_media_type, str):
site_media_type = MediaType.from_agent(declared_media_type)
else:
site_media_type = None
if site_media_type:
return site_media_type == media_type
categories = indexer.get("category") or {}
if not isinstance(categories, dict):
return media_type != MediaType.MUSIC
category_key = media_type.to_agent()
if media_type == MediaType.MUSIC:
return bool(categories.get(category_key))
declared_category_keys = {
item.to_agent()
for item in (MediaType.MOVIE, MediaType.TV, MediaType.MUSIC)
if categories.get(item.to_agent())
}
if declared_category_keys:
return category_key in declared_category_keys
return True
@router.get("/", summary="所有站点", response_model=List[_SchemaSite])
async def read_sites(
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> List[dict]:
"""
获取站点列表
"""
return await query.list_ordered()
@router.get(
"/media/{media_type}",
summary="按媒体类型获取可搜索站点",
response_model=List[_SchemaSite],
)
async def read_sites_by_media_type(
media_type: str,
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> List[_SchemaSite]:
"""
获取支持指定媒体类型的已配置启用站点。
:param media_type: Agent 媒体类型名称或中文媒体类型
:param query: 站点查询服务
:return: 按优先级排序的可搜索站点
"""
target_media_type = MediaType.from_agent(media_type)
if not target_media_type:
try:
target_media_type = MediaType(media_type)
except ValueError as error:
raise HTTPException(status_code=400, detail="不支持的媒体类型") from error
if target_media_type not in (MediaType.MOVIE, MediaType.TV, MediaType.MUSIC):
raise HTTPException(status_code=400, detail="不支持的媒体类型")
supported_ids = set()
supported_domains = set()
for indexer in await SitesHelper().async_get_indexers() or []:
if not _indexer_supports_media_type(indexer, target_media_type):
continue
if indexer.get("id") is not None:
supported_ids.add(str(indexer.get("id")))
domain = site_rules.extract_domain(indexer.get("domain"))
if domain:
supported_domains.add(domain)
sites = await query.list_ordered()
return [
site
for site in sites
if site.is_active
and (str(site.id) in supported_ids or site.domain in supported_domains)
]
@router.post("/", summary="新增站点", response_model=_SchemaResponse[None])
async def add_site(
*,
site_in: _SchemaSite,
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
新增站点
"""
result = await command.create(site_in.model_dump())
return _SchemaResponse(success=result.success, message=result.message)
@router.put("/", summary="更新站点", response_model=_SchemaResponse[None])
async def update_site(
*,
site_in: _SchemaSite,
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
更新站点信息
"""
result = await command.update(site_in.model_dump())
return _SchemaResponse(success=result.success, message=result.message)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None])
async def cookie_cloud_sync(
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> Any:
"""
运行CookieCloud同步站点信息
"""
resolve_background_task_registry(task_registry).create_sync(
get_scheduler().start, job_id="cookiecloud", owner="api.site.cookiecloud_sync"
)
return _SchemaResponse(success=True, message="CookieCloud同步任务已启动!")
@router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None])
async def reset(
task_registry: Annotated[TaskRegistry, Depends(get_background_task_registry)],
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> Any:
"""
清空所有站点数据并重新同步CookieCloud站点信息
"""
result = await command.reset()
await get_configured_system_config().async_set(SystemConfigKey.IndexerSites, [])
await get_configured_system_config().async_set(SystemConfigKey.RssSites, [])
resolve_background_task_registry(task_registry).create_sync(
get_scheduler().start,
job_id="cookiecloud",
owner="api.site.reset",
manual=True,
)
return _SchemaResponse(success=result.success, message="站点已重置!")
@router.post(
"/priorities", summary="批量更新站点优先级", response_model=_SchemaResponse[None]
)
async def update_sites_priority(
priorities: List[dict],
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
批量更新站点优先级
"""
result = await command.update_priorities(priorities)
return _SchemaResponse(success=result.success, message=result.message)
def _update_site_cookie(
site_id: int,
username: str,
password: str,
code: Optional[str],
query: SiteQueryService,
) -> _SchemaResponse:
"""
执行站点 Cookie 与 UA 更新。
:param site_id: 站点编号
:param username: 站点登录用户名
:param password: 站点登录密码
:param code: 二步验证码或密钥
:param query: 站点查询服务
:return: 更新结果
"""
site_info = query.get_sync(site_id)
if not site_info:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在!",
)
logger.info(f"开始更新站点【{site_info.name}】Cookie&UA")
state, message = SiteChain().update_cookie(
site_info=site_info, username=username, password=password, two_step_code=code
)
if state:
logger.info(f"站点【{site_info.name}】Cookie&UA更新成功")
else:
logger.error(f"站点【{site_info.name}】Cookie&UA更新失败:{message}")
return _SchemaResponse(success=state, message=message)
@router.post(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None]
)
def update_cookie_by_body(
site_id: int,
site_cookie_update: _SchemaSiteCookieUpdate,
query: SiteQueryService = Depends(get_site_sync_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user),
) -> Any:
"""
使用请求体中的用户密码更新站点Cookie
"""
return _update_site_cookie(
site_id=site_id,
username=site_cookie_update.username,
password=site_cookie_update.password,
code=site_cookie_update.code,
query=query,
)
@router.get(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None]
)
def update_cookie(
site_id: int,
username: str,
password: str,
code: Optional[str] = None,
query: SiteQueryService = Depends(get_site_sync_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user),
) -> Any:
"""
使用用户密码更新站点Cookie
"""
return _update_site_cookie(
site_id=site_id,
username=username,
password=password,
code=code,
query=query,
)
@router.post(
"/userdata/{site_id}",
summary="更新站点用户数据",
response_model=_SchemaResponse[_SchemaSiteUserData],
)
def refresh_userdata(
site_id: int,
query: SiteQueryService = Depends(get_site_sync_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user),
) -> Any:
"""
刷新站点用户数据
"""
site = query.get_sync(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
indexer = SitesHelper().get_indexer(site.domain)
if not indexer:
return _SchemaResponse(
success=False, message="站点不支持索引或未通过用户认证!"
)
user_data = SiteChain().refresh_userdata(site=indexer) or {}
return _SchemaResponse(success=True, data=user_data)
@router.get(
"/userdata/latest",
summary="查询所有站点最新用户数据",
response_model=List[_SchemaSiteUserData],
)
async def read_userdata_latest(
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询所有站点最新用户数据
"""
return await query.userdata_latest()
@router.get(
"/userdata/{site_id}",
summary="查询某站点用户数据",
response_model=_SchemaResponse[list[_SchemaSiteUserData]],
)
async def read_userdata(
site_id: int,
workdate: Optional[str] = None,
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
查询站点用户数据
"""
site = await query.get(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
user_datas = await query.userdata(site.domain, workdate)
if not user_datas:
return _SchemaResponse(success=False, data=[])
return _SchemaResponse(success=True, data=user_datas)
@router.get("/test/{site_id}", summary="连接测试", response_model=_SchemaResponse[None])
def test_site(
site_id: int,
query: SiteQueryService = Depends(get_site_sync_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
测试站点是否可用
"""
site = query.get_sync(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
status, message = SiteChain().test(site.domain)
return _SchemaResponse(success=status, message=message)
@router.get(
"/icon/{site_id}",
summary="站点图标",
response_model=_SchemaResponse[_SchemaSiteIconData],
)
async def site_icon(
site_id: int,
query: SiteQueryService = Depends(get_site_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取站点图标:base64或者url
"""
site = await query.get(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
icon = await query.icon(site.domain)
if not icon:
return _SchemaResponse(success=False, message="站点图标不存在!")
return _SchemaResponse(success=True, data=icon.model_dump())
@router.get(
"/category/{site_id}", summary="站点分类", response_model=List[_SchemaSiteCategory]
)
async def site_category(
site_id: int,
query: SiteQueryService = Depends(get_site_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取站点分类
"""
site = await query.get(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
indexer = await SitesHelper().async_get_indexer(site.domain)
if not indexer:
raise HTTPException(
status_code=404,
detail=f"站点 {site.domain} 不支持",
)
category: Dict[str, List[dict]] = indexer.get("category") or []
if not category:
return []
result = []
for cats in category.values():
for cat in cats:
if cat not in result:
result.append(cat)
return result
@router.get(
"/resource/{site_id}", summary="站点资源", response_model=List[_SchemaTorrentInfo]
)
async def site_resource(
site_id: int,
keyword: Optional[str] = None,
mtype: Optional[str] = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
浏览站点资源
"""
site = await query.get(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
torrents = await TorrentsChain().async_browse(
domain=site.domain,
keyword=keyword,
cat=cat,
page=page,
mtype=MediaType.from_agent(mtype) or MediaType(mtype) if mtype else None,
)
if not torrents:
return []
return [torrent.to_dict() for torrent in torrents]
@router.get("/domain/{site_url}", summary="站点详情", response_model=_SchemaSite)
async def read_site_by_domain(
site_url: str,
query: SiteQueryService = Depends(get_site_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
通过域名获取站点信息
"""
domain = site_rules.extract_domain(site_url)
site = await query.get_by_domain(domain)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {domain} 不存在",
)
return site
@router.get(
"/statistic/{site_url}",
summary="特定站点统计信息",
response_model=_SchemaSiteStatistic,
)
async def read_statistic_by_domain(
site_url: str,
query: SiteQueryService = Depends(get_site_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
通过域名获取站点统计信息
"""
domain = site_rules.extract_domain(site_url)
return await query.statistic(domain)
@router.get(
"/statistic", summary="所有站点统计信息", response_model=List[_SchemaSiteStatistic]
)
async def read_statistics(
query: SiteQueryService = Depends(get_site_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
获取所有站点统计信息
"""
return await query.statistics()
@router.get("/rss", summary="所有订阅站点", response_model=List[_SchemaSite])
async def read_rss_sites(
query: SiteQueryService = Depends(get_site_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> List[dict]:
"""
获取站点列表
"""
# 选中的rss站点
selected_sites = get_configured_system_config().get(SystemConfigKey.RssSites) or []
# 所有站点
all_site = await query.list_ordered()
if not selected_sites:
return all_site
# 选中的rss站点
rss_sites = [site for site in all_site if site and site.id in selected_sites]
return rss_sites
@router.get("/auth", summary="查询认证站点", response_model=_SchemaJsonObject)
async def read_auth_sites(_: _SchemaTokenPayload = Depends(verify_token)) -> dict:
"""
获取可认证站点列表
"""
return SitesHelper().get_authsites()
@router.post("/auth", summary="用户站点认证", response_model=_SchemaResponse[None])
def auth_site(
auth_info: _SchemaSiteAuth, _: ApiPrincipal = Depends(get_current_active_superuser)
) -> Any:
"""
用户站点认证
"""
if not auth_info or not auth_info.site or not auth_info.params:
return _SchemaResponse(success=False, message="请输入认证站点和认证参数")
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
get_configured_system_config().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump())
# 认证成功后,重新初始化插件
get_plugin_manager().init_config()
get_scheduler().init_plugin_jobs()
init_commands()
register_plugin_api()
return _SchemaResponse(success=status, message=msg)
@router.get(
"/mapping",
summary="获取站点域名到名称的映射",
response_model=_SchemaResponse[_SchemaSiteMappingData],
)
async def site_mapping(
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_superuser_async),
):
"""
获取站点域名到名称的映射关系
"""
try:
sites = await query.list_ordered()
mapping = {}
for site in sites:
mapping[site.domain] = site.name
return _SchemaResponse(success=True, data=mapping)
except Exception as e:
return _SchemaResponse(success=False, message=f"获取映射失败:{str(e)}")
@router.get(
"/supporting",
summary="获取支持的站点列表",
response_model=_SchemaJsonObject,
)
async def support_sites(_: ApiPrincipal = Depends(get_current_active_superuser_async)):
"""
获取支持的站点列表
"""
return SitesHelper().get_indexsites()
@router.get("/{site_id}", summary="站点详情", response_model=_SchemaSite)
async def read_site(
site_id: int,
query: SiteQueryService = Depends(get_site_query_service),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
通过ID获取站点信息
"""
site = await query.get(site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
return site
@router.delete("/{site_id}", summary="删除站点", response_model=_SchemaResponse[None])
async def delete_site(
site_id: int,
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""
删除站点
"""
result = await command.delete(site_id)
return _SchemaResponse(success=result.success, message=result.message)