mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
939 lines
31 KiB
Python
939 lines
31 KiB
Python
"""
|
||
提供站点配置、认证、数据查询和状态管理接口。
|
||
"""
|
||
|
||
from typing import Annotated, Any, List, Literal, Optional, cast
|
||
|
||
from fastapi import Depends, HTTPException, Query, Response
|
||
|
||
from app.adapters.web.security.access import verify_token
|
||
from app.api.context import get_background_task_registry, resolve_background_task_registry
|
||
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,
|
||
get_current_active_user_async,
|
||
)
|
||
from app.api.dependencies.site import (
|
||
get_site_mutation_command,
|
||
get_site_query_service,
|
||
get_site_sync_query_service,
|
||
)
|
||
from app.api.principal import ApiPrincipal
|
||
from app.api.response import (
|
||
COLLECTION_TOTAL_HEADER,
|
||
COLLECTION_TOTAL_OPENAPI_KEY,
|
||
CompatibleCountParam,
|
||
CompatiblePageParam,
|
||
ResponseAPIRouter,
|
||
resolve_compatible_pagination,
|
||
)
|
||
from app.application.commands import init_commands
|
||
from app.application.configuration import get_configured_system_config
|
||
from app.application.plugin.routes import register_plugin_api
|
||
from app.application.plugin.runtime import get_plugin_manager
|
||
from app.application.scheduling import get_scheduler
|
||
from app.application.site.auth import normalize_site_auth_params
|
||
from app.application.site.mutation import SiteMutationCommand
|
||
from app.application.site.query import SiteQueryService
|
||
|
||
# 站点目录由外部资源加载,静态检查无法解析其动态导出。
|
||
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
|
||
from app.chain.site import SiteChain
|
||
from app.chain.torrents import TorrentsChain
|
||
from app.domain import site as site_rules
|
||
from app.runtime.log import logger
|
||
from app.runtime.tasks import TaskRegistry
|
||
from app.schemas.common import JsonData
|
||
from app.schemas.common import JsonObject as _SchemaJsonObject
|
||
from app.schemas.context import TorrentInfo as _SchemaTorrentInfo
|
||
from app.schemas.response import Response as _SchemaResponse
|
||
from app.schemas.site import Site as _SchemaSite
|
||
from app.schemas.site import SiteAuth as _SchemaSiteAuth
|
||
from app.schemas.site import SiteCategory as _SchemaSiteCategory
|
||
from app.schemas.site import SiteCookieSet as _SchemaSiteCookieSet
|
||
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 SitePriorityUpdate as _SchemaSitePriorityUpdate
|
||
from app.schemas.site import SiteStatistic as _SchemaSiteStatistic
|
||
from app.schemas.site import SiteUserData as _SchemaSiteUserData
|
||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||
from app.schemas.types import MediaType, SystemConfigKey
|
||
|
||
router = ResponseAPIRouter()
|
||
|
||
|
||
def _project_agent_site(site: Any, *, include_secrets: bool) -> dict[str, JsonData]:
|
||
"""
|
||
构造旧 Agent 查询语义的站点安全投影,普通用户不返回认证凭据。
|
||
"""
|
||
projected: dict[str, JsonData] = {
|
||
"id": site.id,
|
||
"name": site.name,
|
||
"domain": site.domain,
|
||
"url": site.url,
|
||
"pri": site.pri,
|
||
"is_active": site.is_active,
|
||
"downloader": site.downloader,
|
||
"ua": site.ua,
|
||
"proxy": site.proxy,
|
||
"filter": site.filter,
|
||
"render": site.render,
|
||
"public": site.public,
|
||
"note": site.note,
|
||
"limit_interval": site.limit_interval,
|
||
"limit_count": site.limit_count,
|
||
"limit_seconds": site.limit_seconds,
|
||
"timeout": site.timeout,
|
||
}
|
||
if include_secrets:
|
||
projected.update(
|
||
{
|
||
"rss": site.rss,
|
||
"cookie": site.cookie,
|
||
"apikey": site.apikey,
|
||
"token": site.token,
|
||
}
|
||
)
|
||
return projected
|
||
|
||
|
||
def _project_readable_site(
|
||
site: _SchemaSite, token_payload: _SchemaTokenPayload
|
||
) -> _SchemaSite | dict[str, JsonData]:
|
||
"""
|
||
普通登录用户可读的站点信息不返回认证凭据,超级管理员保持完整字段。
|
||
"""
|
||
if token_payload.super_user:
|
||
return site
|
||
return _project_agent_site(site, include_secrets=False)
|
||
|
||
|
||
def _indexer_supports_media_type(indexer: dict[str, Any], media_type: MediaType) -> bool:
|
||
"""
|
||
判断站点索引器是否支持指定媒体类型。
|
||
|
||
:param indexer: 站点索引器配置
|
||
:param media_type: 待搜索的媒体类型
|
||
:return: 是否应在该媒体类型的站点选择列表中显示
|
||
"""
|
||
declared_media_type = indexer.get("media_type")
|
||
site_media_type: Optional[MediaType]
|
||
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
|
||
|
||
|
||
def _normalize_site_ids(values: Any) -> list[int]:
|
||
"""
|
||
把配置或索引器中的站点标识归一为可查询的整数主键。
|
||
"""
|
||
normalized: list[int] = []
|
||
for value in values or []:
|
||
try:
|
||
normalized.append(int(value))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return list(dict.fromkeys(normalized))
|
||
|
||
|
||
@router.get(
|
||
"/",
|
||
summary="所有站点",
|
||
response_model=List[_SchemaSite],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_sites(
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> List[_SchemaSite]:
|
||
"""
|
||
获取站点列表
|
||
"""
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_ordered()
|
||
)
|
||
return await query.list_ordered(page=page, count=count)
|
||
|
||
|
||
@router.get(
|
||
"/agent",
|
||
summary="查询 Agent 可用站点",
|
||
response_model=List[_SchemaJsonObject],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_agent_sites(
|
||
response: Response = None, # type: ignore[assignment]
|
||
status: Literal["active", "inactive", "all"] = "all",
|
||
name: Optional[str] = None,
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
current_user: Any = Depends(get_current_active_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> List[dict[str, JsonData]]:
|
||
"""
|
||
按旧 Agent 过滤语义返回站点,非超级管理员自动剔除认证字段。
|
||
"""
|
||
active_filter = None
|
||
if status == "active":
|
||
active_filter = True
|
||
elif status == "inactive":
|
||
active_filter = False
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_ordered(is_active=active_filter, name=name)
|
||
)
|
||
sites = await query.list_ordered(
|
||
is_active=active_filter,
|
||
name=name,
|
||
page=page,
|
||
count=count,
|
||
)
|
||
results = []
|
||
for site in sites:
|
||
results.append(
|
||
_project_agent_site(
|
||
site,
|
||
include_secrets=bool(current_user.is_superuser),
|
||
)
|
||
)
|
||
return results
|
||
|
||
|
||
@router.get(
|
||
"/media/{media_type}",
|
||
summary="按媒体类型获取可搜索站点",
|
||
response_model=List[_SchemaSite],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_sites_by_media_type(
|
||
media_type: str,
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> 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[int] = set()
|
||
supported_domains: set[str] = 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.update(_normalize_site_ids([indexer.get("id")]))
|
||
domain = site_rules.extract_domain(indexer.get("domain"))
|
||
if domain:
|
||
supported_domains.add(domain)
|
||
|
||
site_ids = sorted(supported_ids)
|
||
domains = sorted(supported_domains)
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_ordered(
|
||
is_active=True,
|
||
site_ids=site_ids,
|
||
domains=domains,
|
||
)
|
||
)
|
||
return await query.list_ordered(
|
||
is_active=True,
|
||
site_ids=site_ids,
|
||
domains=domains,
|
||
page=page,
|
||
count=count,
|
||
)
|
||
|
||
|
||
@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],
|
||
include_in_schema=False,
|
||
deprecated=True,
|
||
)
|
||
@router.post(
|
||
"/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],
|
||
include_in_schema=False,
|
||
deprecated=True,
|
||
)
|
||
@router.post(
|
||
"/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[_SchemaSitePriorityUpdate],
|
||
command: SiteMutationCommand = Depends(get_site_mutation_command),
|
||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||
) -> Any:
|
||
"""
|
||
批量更新站点优先级
|
||
"""
|
||
result = await command.update_priorities([priority.model_dump() for priority in 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[None]:
|
||
"""
|
||
执行站点 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.post(
|
||
"/cookie/{site_id}/set",
|
||
summary="直接保存站点Cookie&UA",
|
||
response_model=_SchemaResponse[None],
|
||
)
|
||
async def set_cookie_by_body(
|
||
site_id: int,
|
||
site_cookie_set: _SchemaSiteCookieSet,
|
||
command: SiteMutationCommand = Depends(get_site_mutation_command),
|
||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||
) -> Any:
|
||
"""
|
||
保存受信任浏览器会话取得的 Cookie,不改写站点其他配置。
|
||
"""
|
||
result = await command.set_cookie(
|
||
site_id=site_id,
|
||
cookie=site_cookie_set.cookie,
|
||
ua=site_cookie_set.ua,
|
||
)
|
||
return _SchemaResponse(success=result.success, message=result.message)
|
||
|
||
|
||
@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} 不存在",
|
||
)
|
||
if not site.domain:
|
||
return _SchemaResponse(success=False, message="站点域名未配置")
|
||
indexer = SitesHelper().get_indexer(site.domain)
|
||
if not indexer:
|
||
return _SchemaResponse(
|
||
success=False, message="站点不支持索引或未通过用户认证!"
|
||
)
|
||
user_data = SiteChain().refresh_userdata(site=indexer)
|
||
if not user_data or not user_data.userid:
|
||
message = (
|
||
user_data.err_msg
|
||
if user_data and user_data.err_msg
|
||
else "未获取到站点用户数据,请检查 Cookie 是否有效!"
|
||
)
|
||
return _SchemaResponse(success=False, message=message)
|
||
return _SchemaResponse(success=True, data=user_data)
|
||
|
||
|
||
@router.get(
|
||
"/userdata/latest",
|
||
summary="查询所有站点最新用户数据",
|
||
response_model=List[_SchemaSiteUserData],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_userdata_latest(
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
查询所有站点最新用户数据
|
||
"""
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_userdata_latest()
|
||
)
|
||
return await query.userdata_latest(page=page, count=count)
|
||
|
||
|
||
@router.get(
|
||
"/userdata/{site_id}",
|
||
summary="查询某站点用户数据",
|
||
response_model=_SchemaResponse[list[_SchemaSiteUserData]],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_userdata(
|
||
site_id: int,
|
||
response: Response,
|
||
workdate: Optional[str] = None,
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
latest_seeding_only: Annotated[bool, Query(description="仅窗口内最新记录返回做种明细,历史统计不变")] = False,
|
||
) -> Any:
|
||
"""
|
||
查询站点用户数据,可仅保留窗口内最新记录的做种明细。
|
||
|
||
:param site_id: 站点标识
|
||
:param response: 由 FastAPI 注入的响应,用于写入集合总数
|
||
:param workdate: 可选采集日期
|
||
:param query: 注入的站点查询服务
|
||
:param _: 已验证的管理用户
|
||
:param page: 可选页码
|
||
:param count: 可选每页数量
|
||
:param latest_seeding_only: 为真时历史记录不重复返回做种明细;默认保留旧合同
|
||
:return: 按请求窗口返回的数据,不修改持久化记录
|
||
"""
|
||
site = await query.get(site_id)
|
||
if not site:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"站点 {site_id} 不存在",
|
||
)
|
||
if not site.domain:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"站点 {site_id} 未配置域名",
|
||
)
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_userdata(site.domain, workdate)
|
||
)
|
||
user_datas = await query.userdata(
|
||
site.domain,
|
||
workdate,
|
||
page=page,
|
||
count=count,
|
||
)
|
||
if not user_datas:
|
||
return _SchemaResponse(success=False, data=[])
|
||
if latest_seeding_only:
|
||
latest = max(user_datas, key=lambda item: (item.updated_day or "", item.updated_time or ""))
|
||
user_datas = [
|
||
item if item is latest else item.model_copy(update={"seeding_info": []})
|
||
for item in user_datas
|
||
]
|
||
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} 不存在",
|
||
)
|
||
if not site.domain:
|
||
return _SchemaResponse(success=False, message="站点域名未配置")
|
||
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} 不存在",
|
||
)
|
||
if not site.domain:
|
||
return _SchemaResponse(success=False, message="站点域名未配置")
|
||
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),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
获取站点分类
|
||
"""
|
||
site = await query.get(site_id)
|
||
if not site:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"站点 {site_id} 不存在",
|
||
)
|
||
if not site.domain:
|
||
raise HTTPException(status_code=400, detail="站点域名未配置")
|
||
indexer = await SitesHelper().async_get_indexer(site.domain)
|
||
if not indexer:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"站点 {site.domain} 不支持",
|
||
)
|
||
category_value = indexer.get("category")
|
||
if not isinstance(category_value, dict):
|
||
return []
|
||
category = cast(dict[str, list[dict[str, Any]]], category_value)
|
||
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:
|
||
"""
|
||
浏览站点资源,沿用前端和索引模块从 0 开始的分页约定。
|
||
|
||
:param cat: 逗号分隔的站点分类 ID,未指定时不限制分类
|
||
:param page: 从 0 开始的页码,默认查询第一页
|
||
"""
|
||
site = await query.get(site_id)
|
||
if not site:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"站点 {site_id} 不存在",
|
||
)
|
||
if not site.domain:
|
||
raise HTTPException(status_code=400, detail="站点域名未配置")
|
||
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),
|
||
token_payload: _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 _project_readable_site(site, token_payload)
|
||
|
||
|
||
@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],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_statistics(
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
_: _SchemaTokenPayload = Depends(verify_token),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> Any:
|
||
"""
|
||
获取所有站点统计信息
|
||
"""
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_statistics()
|
||
)
|
||
return await query.statistics(page=page, count=count)
|
||
|
||
|
||
@router.get(
|
||
"/rss",
|
||
summary="所有订阅站点",
|
||
response_model=List[_SchemaSite],
|
||
openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True},
|
||
)
|
||
async def read_rss_sites(
|
||
response: Response = None, # type: ignore[assignment]
|
||
query: SiteQueryService = Depends(get_site_query_service),
|
||
token_payload: _SchemaTokenPayload = Depends(verify_token),
|
||
page: CompatiblePageParam = None,
|
||
count: CompatibleCountParam = None,
|
||
) -> List[_SchemaSite | dict[str, JsonData]]:
|
||
"""
|
||
获取站点列表
|
||
"""
|
||
# 选中的rss站点
|
||
selected_sites = get_configured_system_config().get(SystemConfigKey.RssSites) or []
|
||
|
||
site_ids = _normalize_site_ids(selected_sites) if selected_sites else None
|
||
page, count = resolve_compatible_pagination(page, count)
|
||
if response is not None:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(
|
||
await query.count_ordered(site_ids=site_ids)
|
||
)
|
||
sites = await query.list_ordered(
|
||
site_ids=site_ids,
|
||
page=page,
|
||
count=count,
|
||
)
|
||
return [_project_readable_site(site, token_payload) for site in sites]
|
||
|
||
|
||
@router.get("/auth", summary="查询认证站点", response_model=_SchemaJsonObject)
|
||
async def read_auth_sites(
|
||
_: _SchemaTokenPayload = Depends(verify_token),
|
||
) -> dict[str, Any]:
|
||
"""
|
||
获取可认证站点列表
|
||
"""
|
||
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="请输入认证站点和认证参数")
|
||
sites_helper = SitesHelper()
|
||
auth_params = normalize_site_auth_params(
|
||
auth_info.site,
|
||
auth_info.params,
|
||
sites_helper.get_authsites(),
|
||
)
|
||
status, msg = sites_helper.check_user(auth_info.site, auth_params)
|
||
if status:
|
||
get_configured_system_config().set(
|
||
SystemConfigKey.UserSiteAuthParams,
|
||
{"site": auth_info.site, "params": auth_params},
|
||
)
|
||
# 认证成功后,重新初始化插件
|
||
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),
|
||
) -> _SchemaResponse[dict[str, str]]:
|
||
"""
|
||
获取站点域名到名称的映射关系
|
||
"""
|
||
try:
|
||
sites = await query.list_ordered()
|
||
mapping: dict[str, str] = {}
|
||
for site in sites:
|
||
if site.domain and site.name:
|
||
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),
|
||
) -> dict[str, Any]:
|
||
"""
|
||
获取支持的站点列表
|
||
"""
|
||
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)
|