mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-03-27 10:05:57 +00:00
321 lines
16 KiB
Python
321 lines
16 KiB
Python
import asyncio
|
|
import json
|
|
import secrets
|
|
from typing import Any, Dict, List, Callable, Optional, Literal
|
|
|
|
import websockets
|
|
import yaml
|
|
from fastapi import HTTPException, Request, status, Response, Body
|
|
from fastapi.responses import PlainTextResponse
|
|
from sse_starlette.sse import EventSourceResponse
|
|
|
|
from app import schemas
|
|
from app.core.config import settings
|
|
from app.log import logger
|
|
|
|
from .config import PluginConfig
|
|
from .models import ProxyGroup, Proxy, HostData, RuleData, RuleProvider, RuleProviderData
|
|
from .models.api import Connectivity, SubscriptionSetting, ConfigRequest
|
|
from .models.metadata import Metadata
|
|
from .models.types import RuleSet, DataSource
|
|
from .services import ClashRuleProviderService
|
|
|
|
|
|
class ApiCollection:
|
|
def __init__(self):
|
|
self.route_definitions = []
|
|
|
|
def register(self, path: str,
|
|
methods: List[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']],
|
|
allow_anonymous: Optional[bool] = None,
|
|
auth: Optional[str] = None,
|
|
summary: Optional[str] = '',
|
|
**kwargs):
|
|
|
|
def decorator(func: Callable):
|
|
route_meta: Dict[str, Any] = {
|
|
'path': path,
|
|
'methods': methods,
|
|
'summary': summary,
|
|
'endpoint': func,
|
|
**kwargs
|
|
}
|
|
if allow_anonymous is not None:
|
|
route_meta['allow_anonymous'] = allow_anonymous
|
|
if auth is not None:
|
|
route_meta['auth'] = auth
|
|
self.route_definitions.append(route_meta)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
def get_routes(self, instance: Any) -> List[Dict[str, Any]]:
|
|
bound_routes = []
|
|
for route in self.route_definitions:
|
|
func_name = route['endpoint'].__name__
|
|
bound_method = getattr(instance, func_name)
|
|
bound_routes.append({**route, 'endpoint': bound_method})
|
|
return bound_routes
|
|
|
|
|
|
apis = ApiCollection()
|
|
|
|
|
|
class ClashRuleProviderApi:
|
|
|
|
def __init__(self, services: ClashRuleProviderService, config: PluginConfig):
|
|
self.services: ClashRuleProviderService = services
|
|
self.config = config
|
|
|
|
@apis.register(path="/connectivity", methods=["POST"], auth="bear", summary="测试连接")
|
|
async def test_connectivity(self, item: Connectivity) -> schemas.Response:
|
|
success, message = await self.services.test_connectivity(item.clash_apis, item.sub_links)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/clash-outbound", methods=["GET"], auth="bear", summary="获取所有出站")
|
|
def get_clash_outbound(self) -> schemas.Response:
|
|
outbound = self.services.clash_outbound()
|
|
return schemas.Response(success=True, data=outbound)
|
|
|
|
@apis.register(path="/status", methods=["GET"], auth="bear", summary="插件状态")
|
|
def get_status(self) -> schemas.Response:
|
|
data = self.services.get_status()
|
|
return schemas.Response(success=True, data=data)
|
|
|
|
@apis.register(path="/rules/{ruleset}", methods=["GET"], auth="bear", summary="获取指定集合中的规则")
|
|
def get_rules(self, ruleset: RuleSet) -> schemas.Response:
|
|
data = self.services.get_rules(ruleset)
|
|
return schemas.Response(success=True, data=data)
|
|
|
|
@apis.register(path="/reorder-rules/{ruleset}/{target}", methods=["PUT"], auth="bear", summary="重新排序规则")
|
|
def reorder_rules(self, ruleset: RuleSet, target: int,
|
|
moved_priority: int = Body(..., embed=True)) -> schemas.Response:
|
|
success, message = self.services.reorder_rules(ruleset, moved_priority, target)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rules/{ruleset}/{priority}", methods=["PATCH"], auth="bear", summary="更新规则")
|
|
def update_rule(self, ruleset: RuleSet, priority: int, rule_data: RuleData) -> schemas.Response:
|
|
success, message = self.services.update_rule(ruleset, priority, rule_data)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rules/{ruleset}", methods=["POST"], auth="bear", summary="添加规则")
|
|
def add_rule(self, ruleset: RuleSet, rule_data: RuleData = Body(...)) -> schemas.Response:
|
|
success, message = self.services.add_rule(ruleset, rule_data)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rules/{ruleset}/{priority}/meta", methods=["PATCH"], auth="bear", summary="更新规则元数据")
|
|
def update_rule_meta(self, ruleset: RuleSet, priority: int, meta: Metadata = Body(...)) -> schemas.Response:
|
|
success, message = self.services.update_rule_meta(ruleset, priority, meta)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rules/{ruleset}/metadata/disabled", methods=["POST"], auth="bear", summary="设置规则状态")
|
|
def set_rules_status(self, ruleset: RuleSet, priorities: dict[int, bool] = Body(...)):
|
|
self.services.set_rules_status(ruleset, priorities)
|
|
|
|
@apis.register(path="/rules/{ruleset}/{priority}", methods=["DELETE"], auth="bear", summary="删除规则")
|
|
def delete_rule(self, ruleset: RuleSet, priority: int) -> schemas.Response:
|
|
self.services.delete_rule(ruleset, priority)
|
|
return schemas.Response(success=True)
|
|
|
|
@apis.register(path="/rules/{ruleset}", methods=["DELETE"], auth="bear", summary="批量删除规则")
|
|
def delete_rules(self, ruleset: RuleSet, priority: list[int] = Body(...)) -> schemas.Response:
|
|
self.services.delete_rules(ruleset, priority)
|
|
return schemas.Response(success=True)
|
|
|
|
@apis.register(path="/refresh", methods=["PUT"], auth="bear", summary="更新订阅")
|
|
async def refresh_subscription(self, url: str = Body(..., embed=True)) -> schemas.Response:
|
|
success, message = await self.services.refresh_subscription(url)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合",
|
|
response_model=schemas.Response, response_model_exclude_none=True)
|
|
def get_rule_providers(self) -> schemas.Response:
|
|
return schemas.Response(success=True, data=self.services.state.all_rule_providers)
|
|
|
|
@apis.register(path="/rule-providers/{name}", methods=["POST"], auth="bear", summary="添加规则集合")
|
|
def add_rule_provider(self, name: str, item: RuleProvider):
|
|
success, message = self.services.add_rule_provider(name, item)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rule-providers/{name}", methods=["PATCH"], auth="bear", summary="更新规则集合")
|
|
def update_rule_provider(self, name: str, item: RuleProviderData):
|
|
success, message = self.services.update_rule_provider(name, item)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rule-providers/{name}/meta", methods=["PATCH"], auth="bear", summary="更新规则集元数据")
|
|
def update_rule_providers_meta(self, name: str, meta: Metadata):
|
|
success, message = self.services.update_rule_providers_meta(name, meta)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/rule-providers/{name}", methods=["DELETE"], auth="bear", summary="删除规则集合")
|
|
def delete_rule_provider(self, name: str):
|
|
self.services.delete_rule_provider(name)
|
|
return schemas.Response(success=True)
|
|
|
|
@apis.register(path="/proxies", methods=["GET"], auth="bear", summary="获取代理",
|
|
response_model=schemas.Response, response_model_exclude_none=True)
|
|
def get_proxies(self):
|
|
proxies = self.services.get_proxies()
|
|
return schemas.Response(success=True, data=proxies)
|
|
|
|
@apis.register(path="/proxies/{name}", methods=["DELETE"], auth="bear", summary="删除出站代理")
|
|
def delete_proxy(self, name: str):
|
|
self.services.delete_proxy(name)
|
|
return schemas.Response(success=True)
|
|
|
|
@apis.register(path="/proxies", methods=["PUT"], auth="bear", summary="添加出站代理")
|
|
def import_proxies(self, vehicle: Literal["YAML", "LINK"] = Body(...), payload: str = Body(...)):
|
|
success, message = self.services.import_proxies(vehicle, payload)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxies/{name}", methods=["PATCH"], auth="bear", summary="更新出站代理")
|
|
def update_proxy(self, name: str, source: DataSource = Body(...), proxy: Proxy = Body(...)) -> schemas.Response:
|
|
success, message = self.services.update_proxy(name, source, proxy)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxies/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
|
|
def update_proxy_meta(self, name: str, meta: Metadata):
|
|
success, message = self.services.update_proxy_meta(name, meta)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxies/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理补丁")
|
|
def delete_proxy_patch(self, name: str):
|
|
success, message = self.services.delete_proxy_patch(name)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组",
|
|
response_model=schemas.Response, response_model_exclude_none=True)
|
|
def get_proxy_groups(self):
|
|
proxy_groups = self.services.get_proxy_groups()
|
|
return schemas.Response(success=True, data=proxy_groups)
|
|
|
|
@apis.register(path="/proxy-groups/{name}", methods=["DELETE"], auth="bear", summary="删除代理组")
|
|
def delete_proxy_group(self, name: str):
|
|
success, message = self.services.delete_proxy_group(name)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxy-groups/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
|
|
def update_proxy_group_meta(self, name: str, meta: Metadata):
|
|
success, message = self.services.update_proxy_group_meta(name, meta)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxy-groups/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理组补丁")
|
|
def delete_proxy_group_patch(self, name: str):
|
|
success, message = self.services.delete_proxy_group_patch(name)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxy-groups", methods=["POST"], auth="bear", summary="添加代理组")
|
|
def add_proxy_group(self, item: ProxyGroup):
|
|
success, message = self.services.add_proxy_group(item)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxy-groups/{name}", methods=["PATCH"], auth="bear", summary="更新代理组")
|
|
def update_proxy_group(self, name: str, source: DataSource = Body(...), proxy_group: ProxyGroup = Body(...)):
|
|
success, message = self.services.update_proxy_group(name, source, proxy_group)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合",
|
|
response_model=schemas.Response, response_model_exclude_none=True)
|
|
def get_proxy_providers(self):
|
|
proxy_providers = self.services.state.all_proxy_providers
|
|
return schemas.Response(success=True, data=proxy_providers)
|
|
|
|
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=True, summary="获取规则集规则")
|
|
def get_ruleset(self, name: str, apikey: str) -> PlainTextResponse:
|
|
_apikey = self.config.apikey or settings.API_TOKEN
|
|
if not secrets.compare_digest(_apikey, apikey):
|
|
raise HTTPException(status_code=403, detail="Invalid API Key")
|
|
res = self.services.get_ruleset(name)
|
|
if not res:
|
|
raise HTTPException(status_code=404, detail=f"Ruleset {name!r} not found")
|
|
return PlainTextResponse(content=res, media_type="application/x-yaml")
|
|
|
|
@apis.register(path="/import", methods=["POST"], auth="bear", summary="导入规则")
|
|
def import_rules(self, vehicle: Literal["YAML"] = Body(...), payload: str = Body(...)):
|
|
self.services.import_rules(vehicle, payload)
|
|
return schemas.Response(success=True)
|
|
|
|
@apis.register(path="/hosts", methods=["GET"], auth="bear", summary="获取 Hosts")
|
|
def get_hosts(self):
|
|
return schemas.Response(success=True, data=self.services.state.hosts.model_dump(mode='json'))
|
|
|
|
@apis.register(path="/hosts", methods=["POST"], auth="bear", summary="更新 Hosts")
|
|
def update_hosts(self, domain: str = Body(..., embed=True), host: HostData = Body(...)):
|
|
success, message = self.services.update_hosts(domain, host)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/hosts/{domain}", methods=["DELETE"], auth="bear", summary="删除 Hosts")
|
|
def delete_host(self, domain: str):
|
|
success, message = self.services.delete_host(domain)
|
|
return schemas.Response(success=success, message=message)
|
|
|
|
@apis.register(path="/subscription-info", methods=["POST"], auth="bear", summary="更新订阅信息")
|
|
def update_subscription_info(self, sub_info: SubscriptionSetting):
|
|
self.services.update_subscription_info(sub_info)
|
|
return schemas.Response(success=True)
|
|
|
|
@apis.register(path="/config", methods=["GET"], allow_anonymous=bool(True), summary="获取 Clash 配置")
|
|
def get_clash_config(self, apikey: str, request: Request, identifier: str | None = None):
|
|
_apikey = self.config.apikey or settings.API_TOKEN
|
|
param = ConfigRequest(
|
|
url=str(request.url),
|
|
client_host=request.client.host,
|
|
identifier=identifier,
|
|
user_agent=request.headers.get("user-agent")
|
|
)
|
|
if not secrets.compare_digest(apikey, _apikey):
|
|
raise HTTPException(status_code=403, detail="Invalid API Key")
|
|
logger.info(f"{request.client.host} 正在获取配置")
|
|
config = self.services.build_clash_config(param=param)
|
|
if not config:
|
|
raise HTTPException(status_code=500, detail="配置不可用")
|
|
|
|
config_dict = config.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
res = yaml.dump(config_dict, allow_unicode=True, sort_keys=False)
|
|
sub_info = self.services.get_subscription_user_info()
|
|
headers = {'Subscription-Userinfo': sub_info.header}
|
|
return Response(headers=headers, content=res, media_type="text/yaml")
|
|
|
|
@apis.register(path="/clash/proxy/{path:path}", methods=["GET"], auth="bear", summary="转发 Clash API 请求")
|
|
async def clash_proxy(self, path: str):
|
|
return await self.services.fetch_clash_data(path)
|
|
|
|
@apis.register(path="/clash/ws/{endpoint}", methods=["GET"], allow_anonymous=True,
|
|
summary="转发 Clash API Websocket 请求")
|
|
async def clash_websocket(self, request: Request, endpoint: str, secret: str):
|
|
if not secrets.compare_digest(secret, self.config.dashboard_secret):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Secret 校验不通过")
|
|
if endpoint not in ['traffic', 'connections', 'memory']:
|
|
raise HTTPException(status_code=400, detail="Invalid endpoint")
|
|
|
|
# This logic is highly coupled with the web framework, so it stays here.
|
|
queue = asyncio.Queue()
|
|
ws_base = self.config.dashboard_url.replace(
|
|
'http://', 'ws://').replace('https://', 'wss://')
|
|
url = f"{ws_base}/{endpoint}?token={self.config.dashboard_secret}"
|
|
|
|
async def clash_ws_listener():
|
|
try:
|
|
async with websockets.connect(url, ping_interval=None) as ws:
|
|
async for message in ws:
|
|
await queue.put(json.loads(message))
|
|
except Exception as e:
|
|
await queue.put({"error": str(e)})
|
|
|
|
listener_task = asyncio.create_task(clash_ws_listener())
|
|
|
|
async def event_generator():
|
|
try:
|
|
while True:
|
|
if await request.is_disconnected():
|
|
break
|
|
try:
|
|
data = await queue.get()
|
|
yield {'event': endpoint, 'data': json.dumps(data)}
|
|
except asyncio.CancelledError:
|
|
break
|
|
finally:
|
|
listener_task.cancel()
|
|
|
|
return EventSourceResponse(event_generator())
|