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

640 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Annotated, Any, Dict, List, Optional
from fastapi import Depends, HTTPException
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,
)
from app.api.dependencies.site import (
get_site_mutation_command,
get_site_query_service,
get_site_sync_query_service,
)
from app.api.endpoints.plugin import register_plugin_api
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.commands import init_commands
from app.application.configuration import get_configured_system_config
from app.application.plugin.runtime import get_plugin_manager
from app.application.scheduling import get_scheduler
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.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.types import MediaType, SystemConfigKey
from app.schemas.workflow import Site as _SchemaSite
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[str, JsonData]],
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)