mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-05-22 07:26:48 +00:00
2013 lines
95 KiB
Python
2013 lines
95 KiB
Python
import importlib
|
||
import json
|
||
from dataclasses import asdict, is_dataclass
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import requests
|
||
from apscheduler.schedulers.background import BackgroundScheduler
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
from fastapi import Request
|
||
|
||
try:
|
||
from app.chain.media import MediaChain
|
||
except Exception:
|
||
MediaChain = None
|
||
|
||
from app.core.config import settings
|
||
from app.log import logger
|
||
from app.plugins import _PluginBase
|
||
|
||
try:
|
||
from app.schemas import NotificationType
|
||
except Exception:
|
||
NotificationType = None
|
||
|
||
|
||
class HdhiveOpenApi(_PluginBase):
|
||
plugin_name = "影巢 OpenAPI"
|
||
plugin_desc = "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。"
|
||
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/hdhive.ico"
|
||
plugin_version = "0.3.0"
|
||
plugin_author = "liuyuexi1987"
|
||
plugin_level = 1
|
||
author_url = "https://github.com/liuyuexi1987"
|
||
plugin_config_prefix = "hdhiveopenapi_"
|
||
plugin_order = 30
|
||
auth_level = 1
|
||
|
||
_enabled = False
|
||
_notify = True
|
||
_onlyonce = False
|
||
_cron = "0 8 * * *"
|
||
_api_key = ""
|
||
_base_url = "https://hdhive.com"
|
||
_gambler_mode = False
|
||
_timeout = 30
|
||
_history_days = 30
|
||
|
||
_search_media_type = "movie"
|
||
_search_tmdb_id = ""
|
||
_search_once = False
|
||
|
||
_unlock_slug = ""
|
||
_unlock_once = False
|
||
_transfer_115_enabled = False
|
||
_transfer_115_path = "/待整理"
|
||
_auto_transfer_115_on_unlock = False
|
||
_transfer_115_once = False
|
||
|
||
_share_action = "list"
|
||
_share_slug = ""
|
||
_share_page = 1
|
||
_share_page_size = 10
|
||
_share_payload = ""
|
||
_share_once = False
|
||
|
||
_scheduler: Optional[BackgroundScheduler] = None
|
||
|
||
_history_key = "checkin_history"
|
||
_account_key = "last_account"
|
||
_quota_key = "last_quota"
|
||
_usage_today_key = "last_usage_today"
|
||
_usage_key = "last_usage"
|
||
_weekly_quota_key = "last_weekly_quota"
|
||
_search_key = "last_resource_search"
|
||
_unlock_key = "last_resource_unlock"
|
||
_transfer_115_key = "last_transfer_115"
|
||
_check_resource_key = "last_check_resource"
|
||
_shares_list_key = "last_shares_list"
|
||
_share_detail_key = "last_share_detail"
|
||
_share_action_key = "last_share_action"
|
||
_ping_key = "last_ping"
|
||
_last_error_key = "last_error"
|
||
|
||
@staticmethod
|
||
def _safe_int(value: Any, default: int) -> int:
|
||
try:
|
||
return int(value)
|
||
except Exception:
|
||
return default
|
||
|
||
@staticmethod
|
||
def _normalize_text(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
return str(value).strip()
|
||
|
||
@staticmethod
|
||
def _normalize_slug(value: Any) -> str:
|
||
return str(value or "").strip().replace("-", "")
|
||
|
||
@staticmethod
|
||
def _normalize_pan_path(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return ""
|
||
if not text.startswith("/"):
|
||
text = f"/{text}"
|
||
return text.rstrip("/") or "/"
|
||
|
||
@staticmethod
|
||
def _media_type_text(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
raw = str(getattr(value, "value", value)).strip().lower()
|
||
mapping = {
|
||
"电影": "movie",
|
||
"movie": "movie",
|
||
"电视剧": "tv",
|
||
"tv": "tv",
|
||
}
|
||
return mapping.get(raw, raw)
|
||
|
||
@staticmethod
|
||
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||
if isinstance(value, bool):
|
||
return value
|
||
if value is None:
|
||
return default
|
||
text = str(value).strip().lower()
|
||
if text in {"1", "true", "yes", "on"}:
|
||
return True
|
||
if text in {"0", "false", "no", "off"}:
|
||
return False
|
||
return default
|
||
|
||
def _tz_now(self) -> datetime:
|
||
try:
|
||
return datetime.now(ZoneInfo(settings.TZ))
|
||
except Exception:
|
||
return datetime.now()
|
||
|
||
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
config = {
|
||
"enabled": self._enabled,
|
||
"notify": self._notify,
|
||
"onlyonce": self._onlyonce,
|
||
"cron": self._cron,
|
||
"api_key": self._api_key,
|
||
"base_url": self._base_url,
|
||
"gambler_mode": self._gambler_mode,
|
||
"timeout": self._timeout,
|
||
"history_days": self._history_days,
|
||
"search_media_type": self._search_media_type,
|
||
"search_tmdb_id": self._search_tmdb_id,
|
||
"search_once": self._search_once,
|
||
"unlock_slug": self._unlock_slug,
|
||
"unlock_once": self._unlock_once,
|
||
"transfer_115_enabled": self._transfer_115_enabled,
|
||
"transfer_115_path": self._transfer_115_path,
|
||
"auto_transfer_115_on_unlock": self._auto_transfer_115_on_unlock,
|
||
"transfer_115_once": self._transfer_115_once,
|
||
"share_action": self._share_action,
|
||
"share_slug": self._share_slug,
|
||
"share_page": self._share_page,
|
||
"share_page_size": self._share_page_size,
|
||
"share_payload": self._share_payload,
|
||
"share_once": self._share_once,
|
||
}
|
||
if overrides:
|
||
config.update(overrides)
|
||
return config
|
||
|
||
def _save_state(self, key: str, value: Any) -> None:
|
||
try:
|
||
self.save_data(key=key, value=value)
|
||
except Exception as exc:
|
||
logger.warning(f"[HdhiveOpenApi] 保存状态失败 {key}: {exc}")
|
||
|
||
def _load_state(self, key: str, default: Any = None) -> Any:
|
||
try:
|
||
value = self.get_data(key)
|
||
return default if value is None else value
|
||
except Exception as exc:
|
||
logger.warning(f"[HdhiveOpenApi] 读取状态失败 {key}: {exc}")
|
||
return default
|
||
|
||
def _mask_secret(self, value: str, prefix: int = 4, suffix: int = 4) -> str:
|
||
if not value:
|
||
return ""
|
||
if len(value) <= prefix + suffix:
|
||
return "*" * len(value)
|
||
return f"{value[:prefix]}{'*' * (len(value) - prefix - suffix)}{value[-suffix:]}"
|
||
|
||
def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None:
|
||
self._save_state(
|
||
self._last_error_key,
|
||
{
|
||
"action": action,
|
||
"message": message,
|
||
"payload": payload or {},
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
},
|
||
)
|
||
|
||
def _is_115_share_url(self, url: str) -> bool:
|
||
host = urlparse(url).netloc.lower()
|
||
return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host
|
||
|
||
def _ensure_115_share_url(self, url: str, access_code: str = "") -> str:
|
||
clean_url = self._normalize_text(url)
|
||
if not clean_url:
|
||
return ""
|
||
access_code = self._normalize_text(access_code)
|
||
parsed = urlparse(clean_url)
|
||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||
if access_code and "password" not in query:
|
||
query["password"] = access_code
|
||
clean_url = urlunparse(parsed._replace(query=urlencode(query)))
|
||
return clean_url
|
||
|
||
@staticmethod
|
||
def _jsonable(value: Any) -> Any:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (str, int, float, bool, list, dict)):
|
||
return value
|
||
if is_dataclass(value):
|
||
return asdict(value)
|
||
if hasattr(value, "model_dump"):
|
||
try:
|
||
return value.model_dump()
|
||
except Exception:
|
||
pass
|
||
if hasattr(value, "__dict__"):
|
||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||
return str(value)
|
||
|
||
def _get_p115_share_helper(self) -> Tuple[Optional[Any], Optional[str]]:
|
||
try:
|
||
service_module = importlib.import_module("app.plugins.p115strmhelper.service")
|
||
except Exception as exc:
|
||
return None, f"P115StrmHelper 未安装或无法导入: {exc}"
|
||
|
||
servicer = getattr(service_module, "servicer", None)
|
||
if not servicer:
|
||
return None, "P115StrmHelper 未初始化"
|
||
if not getattr(servicer, "client", None):
|
||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||
helper = getattr(servicer, "sharetransferhelper", None)
|
||
if not helper:
|
||
return None, "P115StrmHelper 分享转存模块不可用"
|
||
return helper, None
|
||
|
||
def _base_headers(self) -> Dict[str, str]:
|
||
return {
|
||
"X-API-Key": self._api_key,
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
"User-Agent": getattr(settings, "USER_AGENT", "MoviePilot"),
|
||
}
|
||
|
||
def _api_url(self, path: str) -> str:
|
||
return f"{self._base_url.rstrip('/')}{path}"
|
||
|
||
def _request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
params: Optional[Dict[str, Any]] = None,
|
||
payload: Optional[Dict[str, Any]] = None,
|
||
timeout: Optional[int] = None,
|
||
) -> Tuple[bool, Dict[str, Any], str, int]:
|
||
if not self._api_key:
|
||
return False, {}, "未配置影巢 API Key", 400
|
||
|
||
try:
|
||
response = requests.request(
|
||
method=method.upper(),
|
||
url=self._api_url(path),
|
||
headers=self._base_headers(),
|
||
params=params,
|
||
json=payload if payload is not None else None,
|
||
timeout=timeout or self._timeout,
|
||
proxies=getattr(settings, "PROXY", None),
|
||
)
|
||
except Exception as exc:
|
||
return False, {}, f"请求异常: {exc}", 0
|
||
|
||
try:
|
||
result = response.json()
|
||
except Exception:
|
||
result = {
|
||
"success": False,
|
||
"message": response.text[:300] if response.text else f"HTTP {response.status_code}",
|
||
"description": "接口未返回有效 JSON",
|
||
}
|
||
|
||
if response.ok and isinstance(result, dict) and result.get("success", True):
|
||
return True, result, "", response.status_code
|
||
|
||
message = ""
|
||
if isinstance(result, dict):
|
||
message = (
|
||
result.get("description")
|
||
or result.get("message")
|
||
or result.get("code")
|
||
or f"HTTP {response.status_code}"
|
||
)
|
||
if not message:
|
||
message = f"HTTP {response.status_code}"
|
||
return False, result if isinstance(result, dict) else {}, message, response.status_code
|
||
|
||
def _notify_message(self, title: str, text: str) -> None:
|
||
if not self._notify:
|
||
return
|
||
if not hasattr(self, "post_message"):
|
||
return
|
||
try:
|
||
if NotificationType is not None:
|
||
self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text)
|
||
else:
|
||
self.post_message(title=title, text=text)
|
||
except Exception as exc:
|
||
logger.warning(f"[HdhiveOpenApi] 发送通知失败: {exc}")
|
||
|
||
def _append_history(self, record: Dict[str, Any]) -> None:
|
||
history = self._load_state(self._history_key, default=[]) or []
|
||
history.append(record)
|
||
now = self._tz_now()
|
||
valid_history: List[Dict[str, Any]] = []
|
||
for item in history:
|
||
date_text = str(item.get("time") or item.get("date") or "").strip()
|
||
if not date_text:
|
||
continue
|
||
try:
|
||
item_dt = datetime.strptime(date_text, "%Y-%m-%d %H:%M:%S")
|
||
except Exception:
|
||
valid_history.append(item)
|
||
continue
|
||
if (now.replace(tzinfo=None) - item_dt).days < self._history_days:
|
||
valid_history.append(item)
|
||
self._save_state(self._history_key, valid_history[-100:])
|
||
|
||
def _refresh_snapshots(self, silent: bool = False) -> None:
|
||
ok, data, message = self.ping(remember=True)
|
||
if not ok and not silent:
|
||
self._remember_error("ping", message, data)
|
||
return
|
||
self.fetch_me(remember=True)
|
||
self.fetch_quota(remember=True)
|
||
self.fetch_usage_today(remember=True)
|
||
self.fetch_weekly_free_quota(remember=True)
|
||
|
||
def init_plugin(self, config: dict = None):
|
||
self.stop_service()
|
||
|
||
config = config or {}
|
||
self._enabled = bool(config.get("enabled"))
|
||
self._notify = bool(config.get("notify", True))
|
||
self._onlyonce = bool(config.get("onlyonce"))
|
||
self._cron = self._normalize_text(config.get("cron")) or "0 8 * * *"
|
||
self._api_key = self._normalize_text(config.get("api_key"))
|
||
self._base_url = (self._normalize_text(config.get("base_url")) or "https://hdhive.com").rstrip("/")
|
||
self._gambler_mode = bool(config.get("gambler_mode"))
|
||
self._timeout = self._safe_int(config.get("timeout"), 30)
|
||
self._history_days = self._safe_int(config.get("history_days"), 30)
|
||
|
||
self._search_media_type = self._normalize_text(config.get("search_media_type")) or "movie"
|
||
if self._search_media_type not in {"movie", "tv"}:
|
||
self._search_media_type = "movie"
|
||
self._search_tmdb_id = self._normalize_text(config.get("search_tmdb_id"))
|
||
self._search_once = bool(config.get("search_once"))
|
||
|
||
self._unlock_slug = self._normalize_slug(config.get("unlock_slug"))
|
||
self._unlock_once = bool(config.get("unlock_once"))
|
||
self._transfer_115_enabled = bool(config.get("transfer_115_enabled"))
|
||
self._transfer_115_path = self._normalize_pan_path(config.get("transfer_115_path")) or "/待整理"
|
||
self._auto_transfer_115_on_unlock = bool(config.get("auto_transfer_115_on_unlock"))
|
||
self._transfer_115_once = bool(config.get("transfer_115_once"))
|
||
|
||
self._share_action = self._normalize_text(config.get("share_action")) or "list"
|
||
if self._share_action not in {"list", "detail", "create", "update", "delete"}:
|
||
self._share_action = "list"
|
||
self._share_slug = self._normalize_slug(config.get("share_slug"))
|
||
self._share_page = max(1, self._safe_int(config.get("share_page"), 1))
|
||
self._share_page_size = min(100, max(1, self._safe_int(config.get("share_page_size"), 10)))
|
||
self._share_payload = str(config.get("share_payload") or "").strip()
|
||
self._share_once = bool(config.get("share_once"))
|
||
|
||
if self._enabled and self._api_key:
|
||
self._refresh_snapshots(silent=True)
|
||
|
||
scheduled_jobs: List[Tuple[str, Any]] = []
|
||
reset_config: Dict[str, Any] = {}
|
||
if self._onlyonce:
|
||
scheduled_jobs.append(("影巢 OpenAPI 立即签到", self._run_checkin_once))
|
||
reset_config["onlyonce"] = False
|
||
self._onlyonce = False
|
||
if self._search_once:
|
||
scheduled_jobs.append(("影巢 OpenAPI 资源查询", self._run_search_once))
|
||
reset_config["search_once"] = False
|
||
self._search_once = False
|
||
if self._unlock_once:
|
||
scheduled_jobs.append(("影巢 OpenAPI 资源解锁", self._run_unlock_once))
|
||
reset_config["unlock_once"] = False
|
||
self._unlock_once = False
|
||
if self._transfer_115_once:
|
||
scheduled_jobs.append(("影巢 OpenAPI 转存到115", self._run_transfer_115_once))
|
||
reset_config["transfer_115_once"] = False
|
||
self._transfer_115_once = False
|
||
if self._share_once:
|
||
scheduled_jobs.append(("影巢 OpenAPI 分享操作", self._run_share_once))
|
||
reset_config["share_once"] = False
|
||
self._share_once = False
|
||
|
||
if scheduled_jobs:
|
||
self._scheduler = BackgroundScheduler(timezone=getattr(settings, "TZ", "Asia/Shanghai"))
|
||
base_time = self._tz_now()
|
||
for index, (job_name, func) in enumerate(scheduled_jobs):
|
||
self._scheduler.add_job(
|
||
func=func,
|
||
trigger="date",
|
||
run_date=base_time + timedelta(seconds=3 + index),
|
||
name=job_name,
|
||
)
|
||
self._scheduler.start()
|
||
|
||
if reset_config:
|
||
self.update_config(self._build_config(reset_config))
|
||
|
||
def get_state(self) -> bool:
|
||
return self._enabled and bool(self._api_key)
|
||
|
||
@staticmethod
|
||
def get_command() -> List[Dict[str, Any]]:
|
||
return []
|
||
|
||
def get_service(self) -> List[Dict[str, Any]]:
|
||
if not self._enabled or not self._api_key or not self._cron:
|
||
return []
|
||
return [
|
||
{
|
||
"id": "hdhiveopenapi_checkin",
|
||
"name": "影巢 OpenAPI 每日签到",
|
||
"trigger": CronTrigger.from_crontab(self._cron),
|
||
"func": self._scheduled_checkin,
|
||
"kwargs": {},
|
||
}
|
||
]
|
||
|
||
def stop_service(self):
|
||
try:
|
||
if self._scheduler:
|
||
self._scheduler.remove_all_jobs()
|
||
if self._scheduler.running:
|
||
self._scheduler.shutdown(wait=False)
|
||
except Exception as exc:
|
||
logger.warning(f"[HdhiveOpenApi] 停止调度器失败: {exc}")
|
||
finally:
|
||
self._scheduler = None
|
||
|
||
def ping(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
ok, payload, message, status_code = self._request("GET", "/api/open/ping")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._ping_key, result)
|
||
if not ok:
|
||
self._remember_error("ping", message, payload)
|
||
return ok, result, message
|
||
|
||
def fetch_me(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
ok, payload, message, status_code = self._request("GET", "/api/open/me")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._account_key, result)
|
||
if not ok:
|
||
self._remember_error("me", message, payload)
|
||
return ok, result, message
|
||
|
||
def fetch_quota(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
ok, payload, message, status_code = self._request("GET", "/api/open/quota")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._quota_key, result)
|
||
if not ok:
|
||
self._remember_error("quota", message, payload)
|
||
return ok, result, message
|
||
|
||
def fetch_usage(self, start_date: str = "", end_date: str = "", remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
params: Dict[str, Any] = {}
|
||
if start_date:
|
||
params["start_date"] = start_date
|
||
if end_date:
|
||
params["end_date"] = end_date
|
||
ok, payload, message, status_code = self._request("GET", "/api/open/usage", params=params or None)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"query": params,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._usage_key, result)
|
||
if not ok:
|
||
self._remember_error("usage", message, payload)
|
||
return ok, result, message
|
||
|
||
def fetch_usage_today(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
ok, payload, message, status_code = self._request("GET", "/api/open/usage/today")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._usage_today_key, result)
|
||
if not ok:
|
||
self._remember_error("usage_today", message, payload)
|
||
return ok, result, message
|
||
|
||
def fetch_weekly_free_quota(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
ok, payload, message, status_code = self._request("GET", "/api/open/vip/weekly-free-quota")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._weekly_quota_key, result)
|
||
if not ok:
|
||
self._remember_error("weekly_free_quota", message, payload)
|
||
return ok, result, message
|
||
|
||
def perform_checkin(
|
||
self,
|
||
*,
|
||
is_gambler: Optional[bool] = None,
|
||
remember: bool = True,
|
||
trigger: str = "手动",
|
||
) -> Tuple[bool, Dict[str, Any], str]:
|
||
gambler_mode = self._gambler_mode if is_gambler is None else bool(is_gambler)
|
||
payload = {"is_gambler": gambler_mode} if gambler_mode else None
|
||
ok, result_payload, message, status_code = self._request("POST", "/api/open/checkin", payload=payload)
|
||
data = result_payload.get("data") if isinstance(result_payload, dict) else {}
|
||
checked_in = bool((data or {}).get("checked_in")) if ok else False
|
||
status_text = "签到成功" if checked_in else "今日已签到"
|
||
if not ok:
|
||
status_text = "签到失败"
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"trigger": trigger,
|
||
"is_gambler": gambler_mode,
|
||
"status": status_text,
|
||
"message": (data or {}).get("message") or result_payload.get("message") or message,
|
||
"data": data or {},
|
||
}
|
||
if remember:
|
||
self._append_history(result)
|
||
if ok:
|
||
self.fetch_me(remember=True)
|
||
self.fetch_weekly_free_quota(remember=True)
|
||
else:
|
||
self._remember_error("checkin", message, result_payload)
|
||
|
||
if ok:
|
||
title = "【影巢 OpenAPI 签到】"
|
||
text = (
|
||
f"时间:{result['time']}\n"
|
||
f"方式:{trigger}\n"
|
||
f"模式:{'赌狗签到' if gambler_mode else '普通签到'}\n"
|
||
f"结果:{result['status']}\n"
|
||
f"详情:{result['message']}"
|
||
)
|
||
self._notify_message(title, text)
|
||
return ok, result, message
|
||
|
||
def search_resources(self, media_type: str, tmdb_id: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
media_type = (media_type or "").strip().lower()
|
||
tmdb_id = self._normalize_text(tmdb_id)
|
||
if media_type not in {"movie", "tv"}:
|
||
return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv"
|
||
if not tmdb_id:
|
||
return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空"
|
||
|
||
ok, payload, message, status_code = self._request("GET", f"/api/open/resources/{media_type}/{tmdb_id}")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"query": {"media_type": media_type, "tmdb_id": tmdb_id},
|
||
"data": payload.get("data") if isinstance(payload, dict) else [],
|
||
"meta": payload.get("meta") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._search_key, result)
|
||
if not ok:
|
||
self._remember_error("resources_search", message, payload)
|
||
return ok, result, message
|
||
|
||
def _resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]:
|
||
pan = str(item.get("pan_type") or "").lower()
|
||
points = item.get("unlock_points")
|
||
try:
|
||
points_value = int(points) if points is not None and str(points) != "" else 0
|
||
except Exception:
|
||
points_value = 9999
|
||
validate = str(item.get("validate_status") or "").lower()
|
||
resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])]
|
||
sources = [str(v) for v in (item.get("source") or [])]
|
||
pan_rank = 0 if pan == "115" else 1
|
||
points_rank = 0 if points_value <= 0 else 1
|
||
validate_rank = 0 if validate in {"valid", ""} else 1
|
||
resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2
|
||
source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2
|
||
return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or ""))
|
||
|
||
async def search_resources_by_keyword(
|
||
self,
|
||
keyword: str,
|
||
media_type: str = "movie",
|
||
year: str = "",
|
||
candidate_limit: int = 5,
|
||
result_limit: int = 10,
|
||
remember: bool = True,
|
||
) -> Tuple[bool, Dict[str, Any], str]:
|
||
keyword = self._normalize_text(keyword)
|
||
media_type = self._normalize_text(media_type).lower() or "movie"
|
||
year = self._normalize_text(year)
|
||
candidate_limit = min(10, max(1, self._safe_int(candidate_limit, 5)))
|
||
result_limit = min(50, max(1, self._safe_int(result_limit, 10)))
|
||
|
||
if not keyword:
|
||
return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空"
|
||
if media_type not in {"movie", "tv"}:
|
||
return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie 或 tv"
|
||
if MediaChain is None:
|
||
return False, {"message": "MoviePilot MediaChain 不可用", "query": {"keyword": keyword, "media_type": media_type}}, "MoviePilot MediaChain 不可用"
|
||
|
||
try:
|
||
_, medias = await MediaChain().async_search(title=keyword)
|
||
except Exception as exc:
|
||
return False, {"message": f"TMDB 解析失败: {exc}", "query": {"keyword": keyword, "media_type": media_type}}, f"TMDB 解析失败: {exc}"
|
||
|
||
candidates: List[Dict[str, Any]] = []
|
||
for media in medias or []:
|
||
item_type = self._media_type_text(getattr(media, "type", ""))
|
||
item_year = self._normalize_text(getattr(media, "year", ""))
|
||
if media_type and item_type and item_type != media_type:
|
||
continue
|
||
if year and item_year and item_year != year:
|
||
continue
|
||
tmdb_id = getattr(media, "tmdb_id", None)
|
||
if not tmdb_id:
|
||
continue
|
||
candidates.append(
|
||
{
|
||
"title": getattr(media, "title", "") or getattr(media, "en_title", "") or "",
|
||
"year": item_year,
|
||
"media_type": item_type or media_type,
|
||
"tmdb_id": tmdb_id,
|
||
"poster_path": getattr(media, "poster_path", "") or "",
|
||
}
|
||
)
|
||
if len(candidates) >= candidate_limit:
|
||
break
|
||
|
||
if not candidates:
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": False,
|
||
"status_code": 404,
|
||
"message": "未找到可用于影巢搜索的 TMDB 候选",
|
||
"query": {"keyword": keyword, "media_type": media_type, "year": year},
|
||
"candidates": [],
|
||
"data": [],
|
||
"meta": {"total": 0},
|
||
}
|
||
if remember:
|
||
self._save_state(self._search_key, result)
|
||
return False, result, result["message"]
|
||
|
||
merged_items: List[Dict[str, Any]] = []
|
||
seen_slugs: set[str] = set()
|
||
last_status = 200
|
||
|
||
for candidate in candidates:
|
||
ok, payload, message = self.search_resources(
|
||
media_type=candidate["media_type"] or media_type,
|
||
tmdb_id=str(candidate["tmdb_id"]),
|
||
remember=False,
|
||
)
|
||
last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status
|
||
if not ok:
|
||
continue
|
||
for resource in payload.get("data") or []:
|
||
slug = self._normalize_slug(resource.get("slug"))
|
||
if not slug or slug in seen_slugs:
|
||
continue
|
||
seen_slugs.add(slug)
|
||
annotated = dict(resource)
|
||
annotated["matched_tmdb_id"] = candidate["tmdb_id"]
|
||
annotated["matched_title"] = candidate["title"]
|
||
annotated["matched_year"] = candidate["year"]
|
||
merged_items.append(annotated)
|
||
|
||
merged_items.sort(key=self._resource_sort_key)
|
||
merged_items = merged_items[:result_limit]
|
||
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": bool(merged_items),
|
||
"status_code": last_status,
|
||
"message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源",
|
||
"query": {"keyword": keyword, "media_type": media_type, "year": year},
|
||
"candidates": candidates,
|
||
"data": merged_items,
|
||
"meta": {"total": len(merged_items), "candidate_count": len(candidates)},
|
||
}
|
||
if remember:
|
||
self._save_state(self._search_key, result)
|
||
if not merged_items:
|
||
self._remember_error("resources_search_keyword", result["message"], result)
|
||
return bool(merged_items), result, result["message"]
|
||
|
||
def unlock_resource(
|
||
self,
|
||
slug: str,
|
||
remember: bool = True,
|
||
*,
|
||
transfer_115: bool = False,
|
||
transfer_path: str = "",
|
||
) -> Tuple[bool, Dict[str, Any], str]:
|
||
slug = self._normalize_slug(slug)
|
||
if not slug:
|
||
return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空"
|
||
|
||
ok, payload, message, status_code = self._request(
|
||
"POST",
|
||
"/api/open/resources/unlock",
|
||
payload={"slug": slug},
|
||
)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"slug": slug,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
should_transfer = bool(ok and transfer_115)
|
||
if should_transfer:
|
||
unlock_data = result.get("data") or {}
|
||
transfer_ok, transfer_result, transfer_message = self.transfer_115_share(
|
||
url=unlock_data.get("full_url") or unlock_data.get("url") or "",
|
||
access_code=unlock_data.get("access_code") or "",
|
||
path=transfer_path or self._transfer_115_path,
|
||
remember=True,
|
||
trigger="解锁后自动转存",
|
||
)
|
||
result["transfer_115"] = transfer_result
|
||
if not transfer_ok:
|
||
result["transfer_115_message"] = transfer_message
|
||
if remember:
|
||
self._save_state(self._unlock_key, result)
|
||
if ok:
|
||
self.fetch_me(remember=True)
|
||
else:
|
||
self._remember_error("resources_unlock", message, payload)
|
||
return ok, result, message
|
||
|
||
def transfer_115_share(
|
||
self,
|
||
*,
|
||
url: str = "",
|
||
access_code: str = "",
|
||
path: str = "",
|
||
remember: bool = True,
|
||
trigger: str = "手动转存",
|
||
) -> Tuple[bool, Dict[str, Any], str]:
|
||
transfer_path = self._normalize_pan_path(path) or self._transfer_115_path or "/待整理"
|
||
unlock_snapshot = self._load_state(self._unlock_key, {}) or {}
|
||
unlock_data = unlock_snapshot.get("data") or {}
|
||
share_url = self._ensure_115_share_url(
|
||
url or unlock_data.get("full_url") or unlock_data.get("url") or "",
|
||
access_code or unlock_data.get("access_code") or "",
|
||
)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": False,
|
||
"trigger": trigger,
|
||
"path": transfer_path,
|
||
"url": share_url,
|
||
"message": "",
|
||
"data": {},
|
||
}
|
||
if not share_url:
|
||
result["message"] = "没有可用于 115 转存的解锁链接"
|
||
if remember:
|
||
self._save_state(self._transfer_115_key, result)
|
||
self._remember_error("transfer_115", result["message"], result)
|
||
return False, result, result["message"]
|
||
if not self._is_115_share_url(share_url):
|
||
result["message"] = "当前解锁结果不是 115 分享链接,无法直接转存到 115"
|
||
if remember:
|
||
self._save_state(self._transfer_115_key, result)
|
||
return False, result, result["message"]
|
||
|
||
helper, helper_error = self._get_p115_share_helper()
|
||
if helper_error or not helper:
|
||
result["message"] = helper_error or "P115StrmHelper 不可用"
|
||
if remember:
|
||
self._save_state(self._transfer_115_key, result)
|
||
self._remember_error("transfer_115", result["message"], result)
|
||
return False, result, result["message"]
|
||
|
||
try:
|
||
transfer_result = helper.add_share_115(
|
||
share_url,
|
||
notify=False,
|
||
pan_path=transfer_path,
|
||
)
|
||
except Exception as exc:
|
||
result["message"] = f"调用 P115StrmHelper 转存失败: {exc}"
|
||
if remember:
|
||
self._save_state(self._transfer_115_key, result)
|
||
self._remember_error("transfer_115", result["message"], result)
|
||
return False, result, result["message"]
|
||
|
||
if not transfer_result or not transfer_result[0]:
|
||
error_message = ""
|
||
if isinstance(transfer_result, tuple):
|
||
if len(transfer_result) > 2:
|
||
error_message = self._normalize_text(transfer_result[2])
|
||
elif len(transfer_result) > 1:
|
||
error_message = self._normalize_text(transfer_result[1])
|
||
result["message"] = error_message or "115 转存失败"
|
||
result["data"] = {"raw": self._jsonable(transfer_result)}
|
||
if remember:
|
||
self._save_state(self._transfer_115_key, result)
|
||
self._remember_error("transfer_115", result["message"], result)
|
||
return False, result, result["message"]
|
||
|
||
media_info = transfer_result[1] if len(transfer_result) > 1 else None
|
||
save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path
|
||
parent_id = transfer_result[3] if len(transfer_result) > 3 else None
|
||
result.update(
|
||
{
|
||
"ok": True,
|
||
"message": "115 转存成功",
|
||
"data": {
|
||
"media_info": self._jsonable(media_info),
|
||
"save_parent": save_parent,
|
||
"parent_id": parent_id,
|
||
},
|
||
}
|
||
)
|
||
if remember:
|
||
self._save_state(self._transfer_115_key, result)
|
||
return True, result, result["message"]
|
||
|
||
def check_resource(self, url: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
clean_url = self._normalize_text(url)
|
||
if not clean_url:
|
||
return False, {"message": "url 不能为空", "url": ""}, "url 不能为空"
|
||
|
||
ok, payload, message, status_code = self._request(
|
||
"POST",
|
||
"/api/open/check/resource",
|
||
payload={"url": clean_url},
|
||
)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"url": clean_url,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._check_resource_key, result)
|
||
if not ok:
|
||
self._remember_error("check_resource", message, payload)
|
||
return ok, result, message
|
||
|
||
def list_shares(self, page: int = 1, page_size: int = 20, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
page = max(1, self._safe_int(page, 1))
|
||
page_size = min(100, max(1, self._safe_int(page_size, 20)))
|
||
ok, payload, message, status_code = self._request(
|
||
"GET",
|
||
"/api/open/shares",
|
||
params={"page": page, "page_size": page_size},
|
||
)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"query": {"page": page, "page_size": page_size},
|
||
"data": payload.get("data") if isinstance(payload, dict) else [],
|
||
"meta": payload.get("meta") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._shares_list_key, result)
|
||
if not ok:
|
||
self._remember_error("shares_list", message, payload)
|
||
return ok, result, message
|
||
|
||
def get_share_detail(self, slug: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
slug = self._normalize_slug(slug)
|
||
if not slug:
|
||
return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空"
|
||
|
||
ok, payload, message, status_code = self._request("GET", f"/api/open/shares/{slug}")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"message": payload.get("message") if ok else message,
|
||
"slug": slug,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._share_detail_key, result)
|
||
if not ok:
|
||
self._remember_error("shares_detail", message, payload)
|
||
return ok, result, message
|
||
|
||
def create_share(self, share_payload: Dict[str, Any], remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
ok, payload, message, status_code = self._request("POST", "/api/open/shares", payload=share_payload)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"action": "create",
|
||
"message": payload.get("message") if ok else message,
|
||
"payload": share_payload,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._share_action_key, result)
|
||
if ok:
|
||
self.fetch_me(remember=True)
|
||
else:
|
||
self._remember_error("shares_create", message, payload)
|
||
return ok, result, message
|
||
|
||
def update_share(self, slug: str, share_payload: Dict[str, Any], remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
slug = self._normalize_slug(slug)
|
||
if not slug:
|
||
return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空"
|
||
ok, payload, message, status_code = self._request("PATCH", f"/api/open/shares/{slug}", payload=share_payload)
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"action": "update",
|
||
"slug": slug,
|
||
"message": payload.get("message") if ok else message,
|
||
"payload": share_payload,
|
||
"data": payload.get("data") if isinstance(payload, dict) else {},
|
||
}
|
||
if remember:
|
||
self._save_state(self._share_action_key, result)
|
||
if not ok:
|
||
self._remember_error("shares_update", message, payload)
|
||
return ok, result, message
|
||
|
||
def delete_share(self, slug: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]:
|
||
slug = self._normalize_slug(slug)
|
||
if not slug:
|
||
return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空"
|
||
ok, payload, message, status_code = self._request("DELETE", f"/api/open/shares/{slug}")
|
||
result = {
|
||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"ok": ok,
|
||
"status_code": status_code,
|
||
"action": "delete",
|
||
"slug": slug,
|
||
"message": payload.get("message") if ok else message,
|
||
"data": payload.get("data") if isinstance(payload, dict) else None,
|
||
}
|
||
if remember:
|
||
self._save_state(self._share_action_key, result)
|
||
if ok:
|
||
self.fetch_me(remember=True)
|
||
else:
|
||
self._remember_error("shares_delete", message, payload)
|
||
return ok, result, message
|
||
|
||
def _scheduled_checkin(self) -> None:
|
||
self.perform_checkin(trigger="定时任务", remember=True)
|
||
|
||
def _run_checkin_once(self) -> None:
|
||
self.perform_checkin(trigger="配置页立即运行", remember=True)
|
||
|
||
def _run_search_once(self) -> None:
|
||
ok, result, message = self.search_resources(self._search_media_type, self._search_tmdb_id, remember=True)
|
||
if ok:
|
||
logger.info(
|
||
"[HdhiveOpenApi] 一次性资源查询完成: %s/%s, 返回 %s 条",
|
||
self._search_media_type,
|
||
self._search_tmdb_id,
|
||
len(result.get("data") or []),
|
||
)
|
||
else:
|
||
logger.warning("[HdhiveOpenApi] 一次性资源查询失败: %s", message)
|
||
|
||
def _run_unlock_once(self) -> None:
|
||
ok, _, message = self.unlock_resource(
|
||
self._unlock_slug,
|
||
remember=True,
|
||
transfer_115=self._auto_transfer_115_on_unlock,
|
||
transfer_path=self._transfer_115_path,
|
||
)
|
||
if ok:
|
||
logger.info("[HdhiveOpenApi] 一次性资源解锁完成: %s", self._unlock_slug)
|
||
else:
|
||
logger.warning("[HdhiveOpenApi] 一次性资源解锁失败: %s", message)
|
||
|
||
def _run_transfer_115_once(self) -> None:
|
||
ok, _, message = self.transfer_115_share(
|
||
path=self._transfer_115_path,
|
||
remember=True,
|
||
trigger="配置页立即转存",
|
||
)
|
||
if ok:
|
||
logger.info("[HdhiveOpenApi] 一次性 115 转存完成: %s", self._transfer_115_path)
|
||
else:
|
||
logger.warning("[HdhiveOpenApi] 一次性 115 转存失败: %s", message)
|
||
|
||
def _parse_share_payload(self) -> Tuple[bool, Dict[str, Any], str]:
|
||
if not self._share_payload.strip():
|
||
return True, {}, ""
|
||
try:
|
||
payload = json.loads(self._share_payload)
|
||
except Exception as exc:
|
||
return False, {}, f"分享请求 JSON 解析失败: {exc}"
|
||
if not isinstance(payload, dict):
|
||
return False, {}, "分享请求 JSON 必须是对象"
|
||
return True, payload, ""
|
||
|
||
def _run_share_once(self) -> None:
|
||
ok, payload, message = self._parse_share_payload()
|
||
if not ok:
|
||
self._remember_error("share_payload", message, {})
|
||
logger.warning("[HdhiveOpenApi] 一次性分享操作失败: %s", message)
|
||
return
|
||
|
||
action = self._share_action
|
||
if action == "list":
|
||
self.list_shares(page=self._share_page, page_size=self._share_page_size, remember=True)
|
||
return
|
||
if action == "detail":
|
||
self.get_share_detail(self._share_slug, remember=True)
|
||
return
|
||
if action == "create":
|
||
self.create_share(payload, remember=True)
|
||
return
|
||
if action == "update":
|
||
self.update_share(self._share_slug, payload, remember=True)
|
||
return
|
||
if action == "delete":
|
||
self.delete_share(self._share_slug, remember=True)
|
||
return
|
||
|
||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||
return [
|
||
{
|
||
"component": "VForm",
|
||
"content": [
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "notify", "label": "签到发送通知"}}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "gambler_mode", "label": "默认赌狗签到"}}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "onlyonce", "label": "立即签到一次"}}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "api_key",
|
||
"label": "影巢 Open API Key",
|
||
"placeholder": "请输入影巢 API Key",
|
||
"type": "password",
|
||
},
|
||
}
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 6},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "base_url",
|
||
"label": "影巢站点地址",
|
||
"placeholder": "https://hdhive.com",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{
|
||
"component": "VCronField",
|
||
"props": {
|
||
"model": "cron",
|
||
"label": "每日签到周期",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "timeout",
|
||
"label": "接口超时(秒)",
|
||
"type": "number",
|
||
"placeholder": "30",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 4},
|
||
"content": [
|
||
{
|
||
"component": "VSelect",
|
||
"props": {
|
||
"model": "search_media_type",
|
||
"label": "资源查询类型",
|
||
"items": [
|
||
{"title": "电影 movie", "value": "movie"},
|
||
{"title": "剧集 tv", "value": "tv"},
|
||
],
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 5},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "search_tmdb_id",
|
||
"label": "查询 TMDB ID(可留空)",
|
||
"placeholder": "例如 550;留空时可直接用 API keyword 搜索",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "search_once", "label": "立即查询资源"}}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 6},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "unlock_slug",
|
||
"label": "解锁资源 slug",
|
||
"placeholder": "请输入 32 位资源 slug",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "unlock_once", "label": "立即解锁资源"}}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "transfer_115_enabled", "label": "启用 115 转存"}}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 6},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "transfer_115_path",
|
||
"label": "115 固定目录",
|
||
"placeholder": "/待整理/影巢",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "auto_transfer_115_on_unlock", "label": "解锁后自动转存"}}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "transfer_115_once", "label": "转存最近一次 115 链接"}}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{
|
||
"component": "VSelect",
|
||
"props": {
|
||
"model": "share_action",
|
||
"label": "分享操作",
|
||
"items": [
|
||
{"title": "list 列表", "value": "list"},
|
||
{"title": "detail 详情", "value": "detail"},
|
||
{"title": "create 创建", "value": "create"},
|
||
{"title": "update 更新", "value": "update"},
|
||
{"title": "delete 删除", "value": "delete"},
|
||
],
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 3},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "share_slug",
|
||
"label": "分享 slug",
|
||
"placeholder": "detail/update/delete 时填写",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 2},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "share_page",
|
||
"label": "列表页码",
|
||
"type": "number",
|
||
"placeholder": "1",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 2},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "share_page_size",
|
||
"label": "每页条数",
|
||
"type": "number",
|
||
"placeholder": "10",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 2},
|
||
"content": [
|
||
{"component": "VSwitch", "props": {"model": "share_once", "label": "立即执行分享操作"}}
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VTextarea",
|
||
"props": {
|
||
"model": "share_payload",
|
||
"label": "分享请求 JSON",
|
||
"rows": 8,
|
||
"placeholder": "{\"tmdb_id\":\"550\",\"media_type\":\"movie\",\"title\":\"Fight Club 4K REMUX\",\"url\":\"https://pan.example.com/s/abc123\",\"access_code\":\"x1y2\",\"unlock_points\":10}",
|
||
"hint": "create/update 时填写 JSON。list/detail/delete 可留空。",
|
||
"persistent-hint": True,
|
||
},
|
||
}
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VAlert",
|
||
"props": {
|
||
"type": "info",
|
||
"variant": "tonal",
|
||
"text": (
|
||
"核心能力已覆盖:用户信息、每日签到、资源查询与解锁、分享管理、用量与配额。\\n"
|
||
"新增:支持把解锁出来的 115 分享链接直接转存到固定目录。\\n"
|
||
"注意:只有解锁结果本身是 115 分享链接时才能直接转存,天翼/夸克/阿里等链接不会自动塞进 115。\\n"
|
||
"页面内的一次性操作适合联调;真正对外集成时,建议直接调用插件 API。\\n"
|
||
"插件 API 示例:\\n"
|
||
"GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&tmdb_id=550\\n"
|
||
"GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&keyword=超级马里奥兄弟大电影\\n"
|
||
"POST /api/v1/plugin/HdhiveOpenApi/resources/unlock\\n"
|
||
"POST /api/v1/plugin/HdhiveOpenApi/transfer/115\\n"
|
||
"GET /api/v1/plugin/HdhiveOpenApi/shares\\n"
|
||
"POST /api/v1/plugin/HdhiveOpenApi/shares/create"
|
||
),
|
||
},
|
||
}
|
||
],
|
||
}
|
||
],
|
||
},
|
||
],
|
||
}
|
||
], self._build_config()
|
||
|
||
def _build_key_value_card(self, title: str, rows: List[Tuple[str, Any]], md: int = 6) -> dict:
|
||
return {
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": md},
|
||
"content": [
|
||
{
|
||
"component": "VCard",
|
||
"props": {"flat": True, "border": True},
|
||
"content": [
|
||
{"component": "VCardTitle", "text": title},
|
||
{
|
||
"component": "VCardText",
|
||
"content": [
|
||
{
|
||
"component": "div",
|
||
"props": {"class": "text-body-2 py-1"},
|
||
"text": f"{label}:{value if value not in (None, '') else '—'}",
|
||
}
|
||
for label, value in rows
|
||
],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
|
||
def _build_resource_rows(self, items: List[Dict[str, Any]]) -> List[dict]:
|
||
rows: List[dict] = []
|
||
for item in items[:20]:
|
||
rows.append(
|
||
{
|
||
"component": "tr",
|
||
"content": [
|
||
{"component": "td", "text": item.get("slug", "")},
|
||
{"component": "td", "text": item.get("title", "—")},
|
||
{"component": "td", "text": item.get("share_size", "—")},
|
||
{"component": "td", "text": "/".join(item.get("source") or []) or "—"},
|
||
{"component": "td", "text": "/".join(item.get("video_resolution") or []) or "—"},
|
||
{"component": "td", "text": str(item.get("unlock_points", "0"))},
|
||
{"component": "td", "text": "是" if item.get("is_unlocked") else "否"},
|
||
{"component": "td", "text": "是" if item.get("is_official") else "否"},
|
||
],
|
||
}
|
||
)
|
||
return rows
|
||
|
||
def _build_share_rows(self, items: List[Dict[str, Any]]) -> List[dict]:
|
||
rows: List[dict] = []
|
||
for item in items[:20]:
|
||
rows.append(
|
||
{
|
||
"component": "tr",
|
||
"content": [
|
||
{"component": "td", "text": item.get("slug", "")},
|
||
{"component": "td", "text": item.get("title", "—")},
|
||
{"component": "td", "text": item.get("share_size", "—")},
|
||
{"component": "td", "text": str(item.get("unlock_points", "0"))},
|
||
{"component": "td", "text": str(item.get("unlocked_users_count", "0"))},
|
||
{"component": "td", "text": item.get("created_at", "—")},
|
||
],
|
||
}
|
||
)
|
||
return rows
|
||
|
||
def get_page(self) -> List[dict]:
|
||
ping = self._load_state(self._ping_key, {}) or {}
|
||
account = self._load_state(self._account_key, {}) or {}
|
||
quota = self._load_state(self._quota_key, {}) or {}
|
||
usage_today = self._load_state(self._usage_today_key, {}) or {}
|
||
weekly_quota = self._load_state(self._weekly_quota_key, {}) or {}
|
||
search_result = self._load_state(self._search_key, {}) or {}
|
||
unlock_result = self._load_state(self._unlock_key, {}) or {}
|
||
transfer_115_result = self._load_state(self._transfer_115_key, {}) or {}
|
||
shares_list = self._load_state(self._shares_list_key, {}) or {}
|
||
share_detail = self._load_state(self._share_detail_key, {}) or {}
|
||
share_action = self._load_state(self._share_action_key, {}) or {}
|
||
last_error = self._load_state(self._last_error_key, {}) or {}
|
||
history = list(reversed(self._load_state(self._history_key, []) or []))[:20]
|
||
|
||
user = (account.get("data") or {}) if isinstance(account, dict) else {}
|
||
user_meta = (user.get("user_meta") or {}) if isinstance(user, dict) else {}
|
||
quota_data = quota.get("data") or {}
|
||
usage_today_data = usage_today.get("data") or {}
|
||
weekly_data = weekly_quota.get("data") or {}
|
||
resource_items = search_result.get("data") or []
|
||
share_items = shares_list.get("data") or []
|
||
unlock_data = unlock_result.get("data") or {}
|
||
transfer_115_data = transfer_115_result.get("data") or {}
|
||
share_detail_data = share_detail.get("data") or {}
|
||
share_action_data = share_action.get("data") or {}
|
||
|
||
history_rows = [
|
||
{
|
||
"component": "tr",
|
||
"content": [
|
||
{"component": "td", "text": item.get("time", "")},
|
||
{"component": "td", "text": item.get("trigger", "—")},
|
||
{"component": "td", "text": "赌狗" if item.get("is_gambler") else "普通"},
|
||
{"component": "td", "text": item.get("status", "—")},
|
||
{"component": "td", "text": item.get("message", "—")},
|
||
],
|
||
}
|
||
for item in history
|
||
]
|
||
|
||
page_content: List[dict] = [
|
||
{
|
||
"component": "VContainer",
|
||
"content": [
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
self._build_key_value_card(
|
||
"连接状态",
|
||
[
|
||
("启用", "是" if self._enabled else "否"),
|
||
("API Key", self._mask_secret(self._api_key) or "未填写"),
|
||
("Base URL", self._base_url),
|
||
("最近 Ping", ping.get("time", "—")),
|
||
("Ping 状态", "成功" if ping.get("ok") else (ping.get("message") or "未执行")),
|
||
],
|
||
),
|
||
self._build_key_value_card(
|
||
"用户信息",
|
||
[
|
||
("昵称", user.get("nickname", "—")),
|
||
("用户名", user.get("username", "—")),
|
||
("积分", user_meta.get("points", "—")),
|
||
("VIP", "是" if user.get("is_vip") else "否"),
|
||
("VIP 到期", user.get("vip_expiration_date", "—")),
|
||
("累计签到", user_meta.get("signin_days_total", "—")),
|
||
],
|
||
),
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
self._build_key_value_card(
|
||
"配额与今日用量",
|
||
[
|
||
("配额重置", quota_data.get("daily_reset", "—")),
|
||
("接口上限", quota_data.get("endpoint_limit", "—")),
|
||
("剩余配额", quota_data.get("endpoint_remaining", "—")),
|
||
("今日总调用", usage_today_data.get("total_calls", "—")),
|
||
("今日成功", usage_today_data.get("success_calls", "—")),
|
||
("平均耗时(ms)", usage_today_data.get("avg_latency", "—")),
|
||
],
|
||
),
|
||
self._build_key_value_card(
|
||
"每周免费解锁额度",
|
||
[
|
||
("永久 VIP", "是" if weekly_data.get("is_forever_vip") else "否"),
|
||
("周额度", weekly_data.get("limit", "—")),
|
||
("本周已用", weekly_data.get("used", "—")),
|
||
("剩余额度", weekly_data.get("remaining", "—")),
|
||
("无限额度", "是" if weekly_data.get("unlimited") else "否"),
|
||
("累积额度", weekly_data.get("bonus_quota", "—")),
|
||
],
|
||
),
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VCard",
|
||
"props": {"flat": True, "border": True},
|
||
"content": [
|
||
{"component": "VCardTitle", "text": "签到历史"},
|
||
{
|
||
"component": "VCardText",
|
||
"content": [
|
||
{
|
||
"component": "VTable",
|
||
"props": {"density": "compact", "hover": True},
|
||
"content": [
|
||
{
|
||
"component": "thead",
|
||
"content": [
|
||
{
|
||
"component": "tr",
|
||
"content": [
|
||
{"component": "th", "text": "时间"},
|
||
{"component": "th", "text": "触发方式"},
|
||
{"component": "th", "text": "模式"},
|
||
{"component": "th", "text": "状态"},
|
||
{"component": "th", "text": "说明"},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "tbody",
|
||
"content": history_rows
|
||
or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 5}, "text": "暂无签到记录"}]}],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VCard",
|
||
"props": {"flat": True, "border": True},
|
||
"content": [
|
||
{"component": "VCardTitle", "text": "最近一次资源查询"},
|
||
{
|
||
"component": "VCardSubtitle",
|
||
"text": (
|
||
f"{search_result.get('time', '未执行')} | "
|
||
f"{(search_result.get('query') or {}).get('media_type', '—')} / "
|
||
f"{(search_result.get('query') or {}).get('tmdb_id', '—')} | "
|
||
f"{search_result.get('message', '—')}"
|
||
),
|
||
},
|
||
{
|
||
"component": "VCardText",
|
||
"content": [
|
||
{
|
||
"component": "VTable",
|
||
"props": {"density": "compact", "hover": True},
|
||
"content": [
|
||
{
|
||
"component": "thead",
|
||
"content": [
|
||
{
|
||
"component": "tr",
|
||
"content": [
|
||
{"component": "th", "text": "slug"},
|
||
{"component": "th", "text": "标题"},
|
||
{"component": "th", "text": "大小"},
|
||
{"component": "th", "text": "片源"},
|
||
{"component": "th", "text": "分辨率"},
|
||
{"component": "th", "text": "解锁积分"},
|
||
{"component": "th", "text": "已解锁"},
|
||
{"component": "th", "text": "官方"},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "tbody",
|
||
"content": self._build_resource_rows(resource_items)
|
||
or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 8}, "text": "暂无资源查询结果"}]}],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
self._build_key_value_card(
|
||
"最近一次资源解锁",
|
||
[
|
||
("时间", unlock_result.get("time", "—")),
|
||
("slug", unlock_result.get("slug", "—")),
|
||
("结果", unlock_result.get("message", "—")),
|
||
("链接", unlock_data.get("url", "—")),
|
||
("提取码", unlock_data.get("access_code", "—")),
|
||
("完整链接", unlock_data.get("full_url", "—")),
|
||
],
|
||
),
|
||
self._build_key_value_card(
|
||
"最近一次 115 转存",
|
||
[
|
||
("时间", transfer_115_result.get("time", "—")),
|
||
("触发方式", transfer_115_result.get("trigger", "—")),
|
||
("结果", transfer_115_result.get("message", "—")),
|
||
("目录", transfer_115_result.get("path", "—")),
|
||
("保存位置", transfer_115_data.get("save_parent", "—")),
|
||
("父目录 ID", transfer_115_data.get("parent_id", "—")),
|
||
],
|
||
),
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
self._build_key_value_card(
|
||
"最近一次分享详情/操作",
|
||
[
|
||
("详情时间", share_detail.get("time", "—")),
|
||
("详情标题", share_detail_data.get("title", "—")),
|
||
("详情媒体", ((share_detail_data.get("media") or {}).get("title") if isinstance(share_detail_data.get("media"), dict) else "—") or "—"),
|
||
("操作时间", share_action.get("time", "—")),
|
||
("操作类型", share_action.get("action", "—")),
|
||
("操作结果", share_action.get("message", "—")),
|
||
("操作标题", share_action_data.get("title", "—") if isinstance(share_action_data, dict) else "—"),
|
||
],
|
||
),
|
||
],
|
||
},
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VCard",
|
||
"props": {"flat": True, "border": True},
|
||
"content": [
|
||
{"component": "VCardTitle", "text": "最近一次分享列表"},
|
||
{
|
||
"component": "VCardSubtitle",
|
||
"text": (
|
||
f"{shares_list.get('time', '未执行')} | "
|
||
f"page={(shares_list.get('query') or {}).get('page', '—')} | "
|
||
f"size={(shares_list.get('query') or {}).get('page_size', '—')} | "
|
||
f"{shares_list.get('message', '—')}"
|
||
),
|
||
},
|
||
{
|
||
"component": "VCardText",
|
||
"content": [
|
||
{
|
||
"component": "VTable",
|
||
"props": {"density": "compact", "hover": True},
|
||
"content": [
|
||
{
|
||
"component": "thead",
|
||
"content": [
|
||
{
|
||
"component": "tr",
|
||
"content": [
|
||
{"component": "th", "text": "slug"},
|
||
{"component": "th", "text": "标题"},
|
||
{"component": "th", "text": "大小"},
|
||
{"component": "th", "text": "解锁积分"},
|
||
{"component": "th", "text": "已解锁人数"},
|
||
{"component": "th", "text": "创建时间"},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "tbody",
|
||
"content": self._build_share_rows(share_items)
|
||
or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 6}, "text": "暂无分享列表结果"}]}],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
],
|
||
},
|
||
],
|
||
}
|
||
]
|
||
|
||
if last_error:
|
||
page_content[0]["content"].append(
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12},
|
||
"content": [
|
||
{
|
||
"component": "VAlert",
|
||
"props": {
|
||
"type": "warning",
|
||
"variant": "tonal",
|
||
"text": f"最近一次错误:{last_error.get('action', '—')} | {last_error.get('time', '—')} | {last_error.get('message', '—')}",
|
||
},
|
||
}
|
||
],
|
||
}
|
||
],
|
||
}
|
||
)
|
||
|
||
return page_content
|
||
|
||
def get_api(self) -> List[Dict[str, Any]]:
|
||
return [
|
||
{"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查插件与 API Key 状态", "auth": "bear"},
|
||
{"path": "/account", "endpoint": self.api_account, "methods": ["GET"], "summary": "获取当前用户信息", "auth": "bear"},
|
||
{"path": "/checkin", "endpoint": self.api_checkin, "methods": ["POST"], "summary": "执行普通或赌狗签到", "auth": "bear"},
|
||
{"path": "/quota", "endpoint": self.api_quota, "methods": ["GET"], "summary": "获取配额信息", "auth": "bear"},
|
||
{"path": "/usage", "endpoint": self.api_usage, "methods": ["GET"], "summary": "获取用量统计", "auth": "bear"},
|
||
{"path": "/usage/today", "endpoint": self.api_usage_today, "methods": ["GET"], "summary": "获取今日用量", "auth": "bear"},
|
||
{"path": "/vip/weekly-free-quota", "endpoint": self.api_weekly_quota, "methods": ["GET"], "summary": "获取每周免费解锁额度", "auth": "bear"},
|
||
{"path": "/resources/search", "endpoint": self.api_search_resources, "methods": ["GET"], "summary": "按 TMDB ID 或关键词搜索资源", "auth": "bear"},
|
||
{"path": "/resources/unlock", "endpoint": self.api_unlock_resource, "methods": ["POST"], "summary": "按 slug 解锁资源", "auth": "bear"},
|
||
{"path": "/transfer/115", "endpoint": self.api_transfer_115, "methods": ["POST"], "summary": "把 115 分享链接转存到固定目录", "auth": "bear"},
|
||
{"path": "/resource/check", "endpoint": self.api_check_resource, "methods": ["POST"], "summary": "检测资源链接类型", "auth": "bear"},
|
||
{"path": "/shares", "endpoint": self.api_list_shares, "methods": ["GET"], "summary": "获取我的分享列表", "auth": "bear"},
|
||
{"path": "/shares/detail", "endpoint": self.api_share_detail, "methods": ["GET"], "summary": "获取分享详情", "auth": "bear"},
|
||
{"path": "/shares/create", "endpoint": self.api_share_create, "methods": ["POST"], "summary": "创建分享", "auth": "bear"},
|
||
{"path": "/shares/update", "endpoint": self.api_share_update, "methods": ["POST"], "summary": "更新分享", "auth": "bear"},
|
||
{"path": "/shares/delete", "endpoint": self.api_share_delete, "methods": ["POST"], "summary": "删除分享", "auth": "bear"},
|
||
]
|
||
|
||
async def api_health(self) -> Dict[str, Any]:
|
||
ok, result, message = self.ping(remember=False)
|
||
return {
|
||
"success": ok,
|
||
"message": result.get("message") or message or "success",
|
||
"data": {
|
||
"plugin_enabled": self._enabled,
|
||
"api_key_configured": bool(self._api_key),
|
||
"base_url": self._base_url,
|
||
"ping": result,
|
||
},
|
||
}
|
||
|
||
async def api_account(self) -> Dict[str, Any]:
|
||
ok, result, message = self.fetch_me(remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}}
|
||
|
||
async def api_checkin(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
ok, result, message = self.perform_checkin(
|
||
is_gambler=self._coerce_bool(body.get("is_gambler"), self._gambler_mode),
|
||
remember=True,
|
||
trigger="插件 API",
|
||
)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_quota(self) -> Dict[str, Any]:
|
||
ok, result, message = self.fetch_quota(remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}}
|
||
|
||
async def api_usage(self, request: Request) -> Dict[str, Any]:
|
||
start_date = request.query_params.get("start_date", "")
|
||
end_date = request.query_params.get("end_date", "")
|
||
ok, result, message = self.fetch_usage(start_date=start_date, end_date=end_date, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_usage_today(self) -> Dict[str, Any]:
|
||
ok, result, message = self.fetch_usage_today(remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}}
|
||
|
||
async def api_weekly_quota(self) -> Dict[str, Any]:
|
||
ok, result, message = self.fetch_weekly_free_quota(remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}}
|
||
|
||
async def api_search_resources(self, request: Request) -> Dict[str, Any]:
|
||
media_type = request.query_params.get("type") or request.query_params.get("media_type") or "movie"
|
||
tmdb_id = request.query_params.get("tmdb_id", "")
|
||
keyword = request.query_params.get("keyword", "")
|
||
year = request.query_params.get("year", "")
|
||
candidate_limit = request.query_params.get("candidate_limit", "5")
|
||
result_limit = request.query_params.get("limit", "10")
|
||
if tmdb_id:
|
||
ok, result, message = self.search_resources(media_type=media_type, tmdb_id=tmdb_id, remember=True)
|
||
else:
|
||
ok, result, message = await self.search_resources_by_keyword(
|
||
keyword=keyword,
|
||
media_type=media_type,
|
||
year=year,
|
||
candidate_limit=self._safe_int(candidate_limit, 5),
|
||
result_limit=self._safe_int(result_limit, 10),
|
||
remember=True,
|
||
)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_unlock_resource(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
slug = body.get("slug") or ""
|
||
transfer_115 = self._coerce_bool(
|
||
body.get("transfer_115"),
|
||
self._transfer_115_enabled and self._auto_transfer_115_on_unlock,
|
||
)
|
||
transfer_path = body.get("path") or body.get("transfer_path") or self._transfer_115_path
|
||
ok, result, message = self.unlock_resource(
|
||
slug=slug,
|
||
remember=True,
|
||
transfer_115=transfer_115,
|
||
transfer_path=transfer_path,
|
||
)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_transfer_115(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
ok, result, message = self.transfer_115_share(
|
||
url=body.get("url") or "",
|
||
access_code=body.get("access_code") or "",
|
||
path=body.get("path") or body.get("transfer_path") or self._transfer_115_path,
|
||
remember=True,
|
||
trigger="插件 API",
|
||
)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_check_resource(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
url = body.get("url") or ""
|
||
ok, result, message = self.check_resource(url=url, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_list_shares(self, request: Request) -> Dict[str, Any]:
|
||
page = self._safe_int(request.query_params.get("page"), 1)
|
||
page_size = self._safe_int(request.query_params.get("page_size"), 20)
|
||
ok, result, message = self.list_shares(page=page, page_size=page_size, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_share_detail(self, request: Request) -> Dict[str, Any]:
|
||
slug = request.query_params.get("slug", "")
|
||
ok, result, message = self.get_share_detail(slug=slug, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_share_create(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
ok, result, message = self.create_share(body or {}, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_share_update(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
slug = body.pop("slug", "") if isinstance(body, dict) else ""
|
||
ok, result, message = self.update_share(slug=slug, share_payload=body or {}, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|
||
|
||
async def api_share_delete(self, request: Request) -> Dict[str, Any]:
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
body = {}
|
||
slug = body.get("slug", "") if isinstance(body, dict) else ""
|
||
ok, result, message = self.delete_share(slug=slug, remember=True)
|
||
return {"success": ok, "message": result.get("message") or message or "success", "data": result}
|