Implement full media crawler workflow with Flask backend and Vue frontend.
Add TMDB search and media detail pages, HDHive resource ingestion flow, unified error handling, Docker single-container runtime, and project docs/config updates for local deployment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
docs
|
||||||
|
sdk
|
||||||
|
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.venv
|
||||||
|
**/*.pyc
|
||||||
|
|
||||||
|
backend/.env
|
||||||
|
backend/media_crawler.db
|
||||||
|
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -174,3 +174,32 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# --- Node / Vite ---
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# --- Project runtime files ---
|
||||||
|
backend/media_crawler.db
|
||||||
|
backend/media_crawler.db-shm
|
||||||
|
backend/media_crawler.db-wal
|
||||||
|
|
||||||
|
# --- Env files ---
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
backend/.env
|
||||||
|
frontend/.env
|
||||||
|
|
||||||
|
# --- OS / editor temp ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# --- Frontend cache ---
|
||||||
|
frontend/.vite
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
|||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 python3-pip ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install backend dependencies.
|
||||||
|
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||||
|
RUN python3 -m pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||||
|
|
||||||
|
# Install frontend dependencies and build.
|
||||||
|
COPY frontend/package*.json /app/frontend/
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend /app/frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Copy backend source and runtime launcher.
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend /app/backend
|
||||||
|
COPY docker/start.sh /app/docker/start.sh
|
||||||
|
RUN chmod +x /app/docker/start.sh
|
||||||
|
|
||||||
|
EXPOSE 14620 14621
|
||||||
|
|
||||||
|
CMD ["/app/docker/start.sh"]
|
||||||
79
README.md
79
README.md
@@ -1,2 +1,81 @@
|
|||||||
# media_crawler
|
# media_crawler
|
||||||
|
|
||||||
|
资源爬取自动入库项目,采用前后端分层:
|
||||||
|
|
||||||
|
- `frontend/`: Vue + JavaScript 页面
|
||||||
|
- `backend/`: Python Flask API(编排 TMDB -> HDHIVE -> Emby -> CMS)
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
### 1) 启动后端(Flask)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认端口:
|
||||||
|
- 后端:`14620`
|
||||||
|
- 前端:`14621`
|
||||||
|
|
||||||
|
后端 `.env` 中 `HDHIVE` 相关参数按 OpenAPI 文档配置:
|
||||||
|
|
||||||
|
- `HDHIVE_BASE_URL` 例如 `https://hdhive.com`
|
||||||
|
- `HDHIVE_API_KEY` 对应请求头 `X-API-Key`(必填)
|
||||||
|
- `HDHIVE_ACCESS_TOKEN` 对应 `Authorization: Bearer ...`(按授权场景可选)
|
||||||
|
|
||||||
|
后端 `CMS` 入库参数支持两种模式:
|
||||||
|
|
||||||
|
- 直接给固定 token:`CMS_TOKEN`
|
||||||
|
- 自动登录获取 token:`CMS_LOGIN_URL` + `CMS_USERNAME` + `CMS_PASSWORD`
|
||||||
|
|
||||||
|
入库接口地址使用 `CMS_ADD_SHARE_URL`(或 `CMS_BASE_URL` 自动拼接 `/api/cloud/add_share_down`)。
|
||||||
|
|
||||||
|
### 2) 启动前端(Vite)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面能力
|
||||||
|
|
||||||
|
- 主页面支持按关键词在 TMDB 搜索影视
|
||||||
|
- 点击影视封面进入详情页,展示 HDHive 资源列表
|
||||||
|
- 点击资源链接触发入库任务(按资源 slug 入库)
|
||||||
|
- 任务中心位于二级页面 `/tasks`
|
||||||
|
- 前端调用 Flask API:`POST /api/tasks`、`GET /api/tasks`、`GET /api/tasks/{taskId}/logs`
|
||||||
|
- 前端新增接口:`GET /api/media/search`、`GET /api/media/{type}/{tmdbId}`
|
||||||
|
- 任务状态、结果、错误和步骤日志展示
|
||||||
|
- 后端使用 SQLite 保存任务、资源和日志,并按 `tmdb_id` 做幂等控制
|
||||||
|
- 后端统一错误分类:`validation`、`authentication`、`authorization`、`rate_limit`、`not_found`、`business_rule`、`network`、`timeout`、`upstream`、`internal`
|
||||||
|
|
||||||
|
## Docker 单容器运行
|
||||||
|
|
||||||
|
项目提供了单容器运行前后端的 `Dockerfile`,容器内会同时启动:
|
||||||
|
|
||||||
|
- Flask 后端:`14620`
|
||||||
|
- 前端预览服务:`14621`
|
||||||
|
|
||||||
|
### 构建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t media-crawler:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it \
|
||||||
|
-p 14620:14620 \
|
||||||
|
-p 14621:14621 \
|
||||||
|
--env-file backend/.env \
|
||||||
|
media-crawler:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
24
backend/.env.example
Normal file
24
backend/.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FLASK_RUN_PORT=14620
|
||||||
|
FLASK_DEBUG=1
|
||||||
|
|
||||||
|
TMDB_BASE_URL=https://api.themoviedb.org/3
|
||||||
|
TMDB_TOKEN=
|
||||||
|
|
||||||
|
HDHIVE_BASE_URL=https://hdhive.com
|
||||||
|
HDHIVE_API_KEY=
|
||||||
|
HDHIVE_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
CMS_BASE_URL=
|
||||||
|
CMS_TOKEN=
|
||||||
|
CMS_LOGIN_URL=
|
||||||
|
CMS_ADD_SHARE_URL=
|
||||||
|
CMS_USERNAME=
|
||||||
|
CMS_PASSWORD=
|
||||||
|
|
||||||
|
EMBY_BASE_URL=
|
||||||
|
EMBY_TOKEN=
|
||||||
|
|
||||||
|
TLS_INSECURE_SKIP_VERIFY=0
|
||||||
|
|
||||||
|
MAX_RETRY=3
|
||||||
|
RETRY_DELAY_MS=500
|
||||||
0
backend/adapters/__init__.py
Normal file
0
backend/adapters/__init__.py
Normal file
146
backend/adapters/cms_adapter.py
Normal file
146
backend/adapters/cms_adapter.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from http_client import request_json
|
||||||
|
from error_handling import AppServiceError
|
||||||
|
|
||||||
|
_CMS_TOKEN_CACHE = {"token": "", "expires_at": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_login_url():
|
||||||
|
if Config.CMS_LOGIN_URL:
|
||||||
|
return Config.CMS_LOGIN_URL
|
||||||
|
if Config.CMS_BASE_URL:
|
||||||
|
return f"{Config.CMS_BASE_URL.rstrip('/')}/api/auth/login"
|
||||||
|
raise AppServiceError(
|
||||||
|
"CMS login url is not configured",
|
||||||
|
category="validation",
|
||||||
|
code="CMS_CONFIG_MISSING",
|
||||||
|
provider="cms",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_add_share_url():
|
||||||
|
if Config.CMS_ADD_SHARE_URL:
|
||||||
|
return Config.CMS_ADD_SHARE_URL
|
||||||
|
if Config.CMS_BASE_URL:
|
||||||
|
return f"{Config.CMS_BASE_URL.rstrip('/')}/api/cloud/add_share_down"
|
||||||
|
raise AppServiceError(
|
||||||
|
"CMS add share url is not configured",
|
||||||
|
category="validation",
|
||||||
|
code="CMS_CONFIG_MISSING",
|
||||||
|
provider="cms",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token):
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cms_token(login_result):
|
||||||
|
data = login_result.get("data") or {}
|
||||||
|
token = (data.get("data") or {}).get("token")
|
||||||
|
if not token:
|
||||||
|
raise AppServiceError(
|
||||||
|
"CMS login succeeded but token missing",
|
||||||
|
category="upstream",
|
||||||
|
code="CMS_TOKEN_MISSING",
|
||||||
|
provider="cms",
|
||||||
|
detail={"response": data},
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _login_and_get_token():
|
||||||
|
if Config.CMS_TOKEN:
|
||||||
|
return Config.CMS_TOKEN
|
||||||
|
if not Config.CMS_USERNAME or not Config.CMS_PASSWORD:
|
||||||
|
raise AppServiceError(
|
||||||
|
"CMS username/password is required when CMS_TOKEN is not provided",
|
||||||
|
category="validation",
|
||||||
|
code="CMS_CONFIG_MISSING",
|
||||||
|
provider="cms",
|
||||||
|
)
|
||||||
|
login_result = request_json(
|
||||||
|
_resolve_login_url(),
|
||||||
|
method="POST",
|
||||||
|
payload={"username": Config.CMS_USERNAME, "password": Config.CMS_PASSWORD},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="cms",
|
||||||
|
)
|
||||||
|
token = _extract_cms_token(login_result)
|
||||||
|
_CMS_TOKEN_CACHE["token"] = token
|
||||||
|
_CMS_TOKEN_CACHE["expires_at"] = int(time.time()) + 3500
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cached_token():
|
||||||
|
if Config.CMS_TOKEN:
|
||||||
|
return Config.CMS_TOKEN
|
||||||
|
if _CMS_TOKEN_CACHE["token"] and _CMS_TOKEN_CACHE["expires_at"] > int(time.time()):
|
||||||
|
return _CMS_TOKEN_CACHE["token"]
|
||||||
|
return _login_and_get_token()
|
||||||
|
|
||||||
|
|
||||||
|
def _should_refresh_token(response_data):
|
||||||
|
code = (response_data or {}).get("code")
|
||||||
|
msg = (response_data or {}).get("msg") or (response_data or {}).get("message") or ""
|
||||||
|
return code != 200 and msg != "提取分享链接失败"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_share(url_value, token):
|
||||||
|
return request_json(
|
||||||
|
_resolve_add_share_url(),
|
||||||
|
method="POST",
|
||||||
|
payload={"url": url_value},
|
||||||
|
headers=_headers(token),
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="cms",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_resource(payload):
|
||||||
|
resource = payload.get("resource") or {}
|
||||||
|
share_url = resource.get("unlockUrl") or payload.get("url")
|
||||||
|
if not share_url:
|
||||||
|
raise AppServiceError(
|
||||||
|
"CMS ingest requires unlockUrl",
|
||||||
|
category="validation",
|
||||||
|
code="CMS_INPUT_INVALID",
|
||||||
|
provider="cms",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = _get_cached_token()
|
||||||
|
first_result = _add_share(share_url, token)
|
||||||
|
first_data = first_result.get("data") or {}
|
||||||
|
if _should_refresh_token(first_data):
|
||||||
|
refreshed_token = _login_and_get_token()
|
||||||
|
second_result = _add_share(share_url, refreshed_token)
|
||||||
|
second_data = second_result.get("data") or {}
|
||||||
|
if (second_data.get("code") or 0) != 200:
|
||||||
|
raise AppServiceError(
|
||||||
|
second_data.get("msg") or second_data.get("message") or "CMS ingest failed",
|
||||||
|
category="upstream",
|
||||||
|
code=str(second_data.get("code") or "CMS_INGEST_FAILED"),
|
||||||
|
provider="cms",
|
||||||
|
detail={"response": second_data},
|
||||||
|
)
|
||||||
|
return second_result
|
||||||
|
|
||||||
|
if (first_data.get("code") or 0) != 200:
|
||||||
|
raise AppServiceError(
|
||||||
|
first_data.get("msg") or first_data.get("message") or "CMS ingest failed",
|
||||||
|
category="business_rule"
|
||||||
|
if (first_data.get("msg") == "提取分享链接失败")
|
||||||
|
else "upstream",
|
||||||
|
code=str(first_data.get("code") or "CMS_INGEST_FAILED"),
|
||||||
|
provider="cms",
|
||||||
|
detail={"response": first_data},
|
||||||
|
)
|
||||||
|
return first_result
|
||||||
23
backend/adapters/emby_adapter.py
Normal file
23
backend/adapters/emby_adapter.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from config import Config
|
||||||
|
from http_client import request_json
|
||||||
|
|
||||||
|
|
||||||
|
def _headers():
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if Config.EMBY_TOKEN:
|
||||||
|
headers["X-Emby-Token"] = Config.EMBY_TOKEN
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def exists_by_tmdb_id(tmdb_id):
|
||||||
|
url = f"{Config.EMBY_BASE_URL}/Items?AnyProviderIdEquals=Tmdb.{tmdb_id}&Recursive=true&Limit=1"
|
||||||
|
result = request_json(
|
||||||
|
url,
|
||||||
|
headers=_headers(),
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="emby",
|
||||||
|
)
|
||||||
|
total = ((result.get("data") or {}).get("TotalRecordCount")) or 0
|
||||||
|
result["exists"] = total > 0
|
||||||
|
return result
|
||||||
60
backend/adapters/hdhive_adapter.py
Normal file
60
backend/adapters/hdhive_adapter.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from config import Config
|
||||||
|
from http_client import request_json
|
||||||
|
|
||||||
|
|
||||||
|
def _headers():
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-API-Key": Config.HDHIVE_API_KEY,
|
||||||
|
}
|
||||||
|
if Config.HDHIVE_ACCESS_TOKEN:
|
||||||
|
headers["Authorization"] = f"Bearer {Config.HDHIVE_ACCESS_TOKEN}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def search_resource(media_type, tmdb_id):
|
||||||
|
url = f"{Config.HDHIVE_BASE_URL}/api/open/resources/{media_type}/{tmdb_id}"
|
||||||
|
return request_json(
|
||||||
|
url,
|
||||||
|
headers=_headers(),
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="hdhive",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unlock_link(slug):
|
||||||
|
url = f"{Config.HDHIVE_BASE_URL}/api/open/resources/unlock"
|
||||||
|
return request_json(
|
||||||
|
url,
|
||||||
|
method="POST",
|
||||||
|
payload={"slug": slug},
|
||||||
|
headers={**_headers(), "Content-Type": "application/json"},
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="hdhive",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_resource(search_data, unlock_data):
|
||||||
|
resolution = (search_data or {}).get("video_resolution")
|
||||||
|
source = (search_data or {}).get("source")
|
||||||
|
subtitle_language = (search_data or {}).get("subtitle_language")
|
||||||
|
return {
|
||||||
|
"resourceTitle": (search_data or {}).get("title", ""),
|
||||||
|
"quality": ", ".join(resolution) if isinstance(resolution, list) else "",
|
||||||
|
"size": (search_data or {}).get("share_size", ""),
|
||||||
|
"diskType": (search_data or {}).get("pan_type", ""),
|
||||||
|
"source": ", ".join(source) if isinstance(source, list) else "",
|
||||||
|
"subtitleLanguage": ", ".join(subtitle_language)
|
||||||
|
if isinstance(subtitle_language, list)
|
||||||
|
else "",
|
||||||
|
"slug": (search_data or {}).get("slug", ""),
|
||||||
|
"unlockUrl": (unlock_data or {}).get("full_url")
|
||||||
|
or (unlock_data or {}).get("url")
|
||||||
|
or "",
|
||||||
|
"availability": "available"
|
||||||
|
if ((unlock_data or {}).get("full_url") or (unlock_data or {}).get("url"))
|
||||||
|
else "unknown",
|
||||||
|
"raw": {"searchData": search_data, "unlockData": unlock_data},
|
||||||
|
}
|
||||||
56
backend/adapters/tmdb_adapter.py
Normal file
56
backend/adapters/tmdb_adapter.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from config import Config
|
||||||
|
from http_client import request_json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
def _headers():
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if Config.TMDB_TOKEN:
|
||||||
|
headers["Authorization"] = f"Bearer {Config.TMDB_TOKEN}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def search_media(query, media_type="movie", page=1):
|
||||||
|
normalized_type = "tv" if media_type == "tv" else "movie"
|
||||||
|
url = (
|
||||||
|
f"{Config.TMDB_BASE_URL}/search/{normalized_type}"
|
||||||
|
f"?language=zh-CN&query={quote(str(query))}&page={page}"
|
||||||
|
)
|
||||||
|
result = request_json(
|
||||||
|
url,
|
||||||
|
headers=_headers(),
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="tmdb",
|
||||||
|
)
|
||||||
|
data = result.get("data") or {}
|
||||||
|
result["items"] = data.get("results") if isinstance(data, dict) else []
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_detail(tmdb_id, media_type):
|
||||||
|
normalized_type = "tv" if media_type == "tv" else "movie"
|
||||||
|
url = f"{Config.TMDB_BASE_URL}/{normalized_type}/{tmdb_id}?language=zh-CN"
|
||||||
|
result = request_json(
|
||||||
|
url,
|
||||||
|
headers=_headers(),
|
||||||
|
max_retry=Config.MAX_RETRY,
|
||||||
|
retry_delay_ms=Config.RETRY_DELAY_MS,
|
||||||
|
provider="tmdb",
|
||||||
|
)
|
||||||
|
data = result.get("data") or {}
|
||||||
|
normalized = {
|
||||||
|
"tmdbId": tmdb_id,
|
||||||
|
"type": normalized_type,
|
||||||
|
"title": data.get("title") or data.get("name") or "",
|
||||||
|
"originalTitle": data.get("original_title") or data.get("original_name") or "",
|
||||||
|
"overview": data.get("overview") or "",
|
||||||
|
"year": (data.get("release_date") or data.get("first_air_date") or "")[:4],
|
||||||
|
"rating": data.get("vote_average"),
|
||||||
|
"posterPath": data.get("poster_path") or "",
|
||||||
|
"genres": [g.get("name") for g in data.get("genres", []) if g.get("name")],
|
||||||
|
"seasons": len(data.get("seasons", [])) if isinstance(data.get("seasons"), list) else 0,
|
||||||
|
"raw": data,
|
||||||
|
}
|
||||||
|
result["normalized"] = normalized
|
||||||
|
return result
|
||||||
102
backend/app.py
Normal file
102
backend/app.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from error_handling import AppServiceError, normalize_exception
|
||||||
|
from services.media_service import (
|
||||||
|
get_media_resources,
|
||||||
|
search_media_by_keyword,
|
||||||
|
validate_media_query,
|
||||||
|
validate_media_type,
|
||||||
|
)
|
||||||
|
from services.orchestrator import run_ingest_task
|
||||||
|
from storage import init_db, list_logs, list_tasks
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
def error_response(error, fallback_status=400):
|
||||||
|
normalized = normalize_exception(error)
|
||||||
|
status = normalized.status or fallback_status
|
||||||
|
return jsonify({"error": normalized.to_dict()}), status
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@app.post("/api/tasks")
|
||||||
|
def create_task():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
tmdb_id = str(data.get("tmdbId", "")).strip()
|
||||||
|
media_type = data.get("type", "movie")
|
||||||
|
keyword = str(data.get("keyword", "")).strip()
|
||||||
|
if not tmdb_id:
|
||||||
|
return error_response(
|
||||||
|
AppServiceError(
|
||||||
|
"tmdbId is required",
|
||||||
|
category="validation",
|
||||||
|
code="INVALID_INPUT",
|
||||||
|
status=400,
|
||||||
|
provider="api",
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
if media_type not in ("movie", "tv"):
|
||||||
|
return error_response(
|
||||||
|
AppServiceError(
|
||||||
|
"type must be movie or tv",
|
||||||
|
category="validation",
|
||||||
|
code="INVALID_INPUT",
|
||||||
|
status=400,
|
||||||
|
provider="api",
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = run_ingest_task(
|
||||||
|
{
|
||||||
|
"tmdbId": tmdb_id,
|
||||||
|
"type": media_type,
|
||||||
|
"keyword": keyword,
|
||||||
|
"slug": str(data.get("slug", "")).strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return jsonify(task)
|
||||||
|
|
||||||
|
@app.get("/api/media/search")
|
||||||
|
def search_media_handler():
|
||||||
|
query = str(request.args.get("query", "")).strip()
|
||||||
|
media_type = str(request.args.get("type", "movie")).strip()
|
||||||
|
try:
|
||||||
|
validate_media_query(query, media_type)
|
||||||
|
return jsonify(search_media_by_keyword(query, media_type))
|
||||||
|
except Exception as error:
|
||||||
|
return error_response(error)
|
||||||
|
|
||||||
|
@app.get("/api/media/<media_type>/<tmdb_id>")
|
||||||
|
def media_detail_handler(media_type, tmdb_id):
|
||||||
|
try:
|
||||||
|
validate_media_type(media_type)
|
||||||
|
return jsonify(get_media_resources(media_type, str(tmdb_id)))
|
||||||
|
except Exception as error:
|
||||||
|
return error_response(error)
|
||||||
|
|
||||||
|
@app.get("/api/tasks")
|
||||||
|
def get_tasks():
|
||||||
|
return jsonify({"items": list_tasks()})
|
||||||
|
|
||||||
|
@app.get("/api/tasks/<task_id>/logs")
|
||||||
|
def get_task_logs(task_id):
|
||||||
|
return jsonify({"items": list_logs(task_id)})
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=Config.FLASK_RUN_PORT, debug=Config.FLASK_DEBUG)
|
||||||
31
backend/config.py
Normal file
31
backend/config.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
FLASK_RUN_PORT = int(os.getenv("FLASK_RUN_PORT", "14620"))
|
||||||
|
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1"
|
||||||
|
|
||||||
|
TMDB_BASE_URL = os.getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3")
|
||||||
|
TMDB_TOKEN = os.getenv("TMDB_TOKEN", "")
|
||||||
|
|
||||||
|
HDHIVE_BASE_URL = os.getenv("HDHIVE_BASE_URL", "https://hdhive.com")
|
||||||
|
HDHIVE_API_KEY = os.getenv("HDHIVE_API_KEY", "")
|
||||||
|
HDHIVE_ACCESS_TOKEN = os.getenv("HDHIVE_ACCESS_TOKEN", "")
|
||||||
|
|
||||||
|
CMS_BASE_URL = os.getenv("CMS_BASE_URL", "")
|
||||||
|
CMS_TOKEN = os.getenv("CMS_TOKEN", "")
|
||||||
|
CMS_LOGIN_URL = os.getenv("CMS_LOGIN_URL", "")
|
||||||
|
CMS_ADD_SHARE_URL = os.getenv("CMS_ADD_SHARE_URL", "")
|
||||||
|
CMS_USERNAME = os.getenv("CMS_USERNAME", "")
|
||||||
|
CMS_PASSWORD = os.getenv("CMS_PASSWORD", "")
|
||||||
|
|
||||||
|
EMBY_BASE_URL = os.getenv("EMBY_BASE_URL", "")
|
||||||
|
EMBY_TOKEN = os.getenv("EMBY_TOKEN", "")
|
||||||
|
|
||||||
|
TLS_INSECURE_SKIP_VERIFY = os.getenv("TLS_INSECURE_SKIP_VERIFY", "0") == "1"
|
||||||
|
|
||||||
|
MAX_RETRY = int(os.getenv("MAX_RETRY", "3"))
|
||||||
|
RETRY_DELAY_MS = int(os.getenv("RETRY_DELAY_MS", "500"))
|
||||||
64
backend/error_handling.py
Normal file
64
backend/error_handling.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
class AppServiceError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message,
|
||||||
|
*,
|
||||||
|
category="internal",
|
||||||
|
code="INTERNAL_ERROR",
|
||||||
|
status=None,
|
||||||
|
retryable=False,
|
||||||
|
provider="system",
|
||||||
|
detail=None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.category = category
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
self.retryable = retryable
|
||||||
|
self.provider = provider
|
||||||
|
self.detail = detail or {}
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"message": str(self),
|
||||||
|
"category": self.category,
|
||||||
|
"code": self.code,
|
||||||
|
"status": self.status,
|
||||||
|
"retryable": self.retryable,
|
||||||
|
"provider": self.provider,
|
||||||
|
"detail": self.detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_http_error(status, code, retryable=False):
|
||||||
|
code = str(code or "").upper()
|
||||||
|
if status == 429 or code in {"RATE_LIMIT_EXCEEDED"}:
|
||||||
|
return "rate_limit"
|
||||||
|
if status == 401 or code in {
|
||||||
|
"MISSING_API_KEY",
|
||||||
|
"INVALID_API_KEY",
|
||||||
|
"DISABLED_API_KEY",
|
||||||
|
"EXPIRED_API_KEY",
|
||||||
|
"INVALID_OPENAPI_USER_TOKEN",
|
||||||
|
"OPENAPI_TOKEN_APP_MISMATCH",
|
||||||
|
}:
|
||||||
|
return "authentication"
|
||||||
|
if status == 403 or code in {"OPENAPI_USER_REQUIRED", "SCOPE_NOT_ALLOWED", "USER_SCOPE_NOT_ALLOWED"}:
|
||||||
|
return "authorization"
|
||||||
|
if status == 404:
|
||||||
|
return "not_found"
|
||||||
|
if status == 400:
|
||||||
|
return "validation"
|
||||||
|
if status == 402 or code in {"INSUFFICIENT_POINTS", "VIP_REQUIRED"}:
|
||||||
|
return "business_rule"
|
||||||
|
if status and status >= 500:
|
||||||
|
return "upstream"
|
||||||
|
if retryable:
|
||||||
|
return "upstream"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_exception(error):
|
||||||
|
if isinstance(error, AppServiceError):
|
||||||
|
return error
|
||||||
|
return AppServiceError(str(error), category="internal", code="INTERNAL_ERROR")
|
||||||
96
backend/http_client.py
Normal file
96
backend/http_client.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import time
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from error_handling import AppServiceError, classify_http_error
|
||||||
|
|
||||||
|
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
if Config.TLS_INSECURE_SKIP_VERIFY:
|
||||||
|
requests.packages.urllib3.disable_warnings() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
url,
|
||||||
|
method="GET",
|
||||||
|
headers=None,
|
||||||
|
payload=None,
|
||||||
|
max_retry=3,
|
||||||
|
retry_delay_ms=500,
|
||||||
|
provider="external",
|
||||||
|
):
|
||||||
|
attempt = 0
|
||||||
|
headers = headers or {}
|
||||||
|
|
||||||
|
while attempt <= max_retry:
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=20,
|
||||||
|
verify=not Config.TLS_INSECURE_SKIP_VERIFY,
|
||||||
|
)
|
||||||
|
cost_ms = round((time.perf_counter() - start) * 1000)
|
||||||
|
data = None
|
||||||
|
if response.text:
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = response.text
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
retryable = response.status_code in RETRYABLE_STATUS
|
||||||
|
code = str((data or {}).get("code", response.status_code)) if isinstance(data, dict) else str(response.status_code)
|
||||||
|
message = (data or {}).get("message", f"HTTP {response.status_code}") if isinstance(data, dict) else f"HTTP {response.status_code}"
|
||||||
|
category = classify_http_error(response.status_code, code, retryable=retryable)
|
||||||
|
raise AppServiceError(
|
||||||
|
message,
|
||||||
|
category=category,
|
||||||
|
code=code,
|
||||||
|
status=response.status_code,
|
||||||
|
retryable=retryable,
|
||||||
|
provider=provider,
|
||||||
|
detail={"data": data, "costMs": cost_ms, "url": url},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"data": data, "status": response.status_code, "cost_ms": cost_ms}
|
||||||
|
except requests.Timeout as error:
|
||||||
|
retryable = True
|
||||||
|
if not retryable or attempt == max_retry:
|
||||||
|
raise AppServiceError(
|
||||||
|
"request timeout",
|
||||||
|
category="timeout",
|
||||||
|
code="REQUEST_TIMEOUT",
|
||||||
|
status=None,
|
||||||
|
retryable=True,
|
||||||
|
provider=provider,
|
||||||
|
detail={"url": url, "reason": str(error)},
|
||||||
|
) from error
|
||||||
|
delay = (2**attempt) * retry_delay_ms / 1000.0
|
||||||
|
time.sleep(delay)
|
||||||
|
attempt += 1
|
||||||
|
except requests.RequestException as error:
|
||||||
|
retryable = True
|
||||||
|
if attempt == max_retry:
|
||||||
|
raise AppServiceError(
|
||||||
|
"network request failed",
|
||||||
|
category="network",
|
||||||
|
code="NETWORK_ERROR",
|
||||||
|
status=None,
|
||||||
|
retryable=True,
|
||||||
|
provider=provider,
|
||||||
|
detail={"url": url, "reason": str(error)},
|
||||||
|
) from error
|
||||||
|
delay = (2**attempt) * retry_delay_ms / 1000.0
|
||||||
|
time.sleep(delay)
|
||||||
|
attempt += 1
|
||||||
|
except AppServiceError as error:
|
||||||
|
retryable = error.retryable
|
||||||
|
if not retryable or attempt == max_retry:
|
||||||
|
raise
|
||||||
|
delay = (2**attempt) * retry_delay_ms / 1000.0
|
||||||
|
time.sleep(delay)
|
||||||
|
attempt += 1
|
||||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-Cors==4.0.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
requests==2.32.3
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
73
backend/services/media_service.py
Normal file
73
backend/services/media_service.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from adapters.hdhive_adapter import normalize_resource, search_resource, unlock_link
|
||||||
|
from adapters.tmdb_adapter import get_media_detail, search_media
|
||||||
|
from error_handling import AppServiceError
|
||||||
|
|
||||||
|
|
||||||
|
def search_media_by_keyword(query, media_type):
|
||||||
|
result = search_media(query, media_type)
|
||||||
|
raw_items = result.get("items") or []
|
||||||
|
items = []
|
||||||
|
for item in raw_items:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": item.get("id"),
|
||||||
|
"type": media_type,
|
||||||
|
"title": item.get("title") or item.get("name"),
|
||||||
|
"overview": item.get("overview") or "",
|
||||||
|
"posterPath": item.get("poster_path") or "",
|
||||||
|
"releaseDate": item.get("release_date") or item.get("first_air_date") or "",
|
||||||
|
"voteAverage": item.get("vote_average"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_resources(media_type, tmdb_id):
|
||||||
|
detail = get_media_detail(tmdb_id, media_type)
|
||||||
|
hdhive = search_resource(media_type, tmdb_id)
|
||||||
|
search_data = hdhive.get("data") or []
|
||||||
|
if isinstance(search_data, dict):
|
||||||
|
search_data = search_data.get("items") or []
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
for item in search_data:
|
||||||
|
slug = (item or {}).get("slug")
|
||||||
|
unlock_data = {}
|
||||||
|
unlock_error = None
|
||||||
|
if slug:
|
||||||
|
try:
|
||||||
|
unlock = unlock_link(slug)
|
||||||
|
unlock_data = unlock.get("data") or {}
|
||||||
|
except Exception as error:
|
||||||
|
unlock_error = str(error)
|
||||||
|
normalized = normalize_resource(item, unlock_data)
|
||||||
|
normalized["unlockError"] = unlock_error
|
||||||
|
resources.append(normalized)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"media": detail.get("normalized"),
|
||||||
|
"resources": resources,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_media_query(query, media_type):
|
||||||
|
if not query:
|
||||||
|
raise AppServiceError(
|
||||||
|
"query is required",
|
||||||
|
category="validation",
|
||||||
|
code="INVALID_INPUT",
|
||||||
|
status=400,
|
||||||
|
provider="api",
|
||||||
|
)
|
||||||
|
validate_media_type(media_type)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_media_type(media_type):
|
||||||
|
if media_type not in ("movie", "tv"):
|
||||||
|
raise AppServiceError(
|
||||||
|
"type must be movie or tv",
|
||||||
|
category="validation",
|
||||||
|
code="INVALID_INPUT",
|
||||||
|
status=400,
|
||||||
|
provider="api",
|
||||||
|
)
|
||||||
176
backend/services/orchestrator.py
Normal file
176
backend/services/orchestrator.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from adapters.cms_adapter import create_resource
|
||||||
|
from adapters.emby_adapter import exists_by_tmdb_id
|
||||||
|
from adapters.hdhive_adapter import normalize_resource, search_resource, unlock_link
|
||||||
|
from adapters.tmdb_adapter import get_media_detail
|
||||||
|
from error_handling import AppServiceError, normalize_exception
|
||||||
|
from storage import find_media_item, insert_log, upsert_media_item, upsert_task
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso():
|
||||||
|
return datetime.utcnow().isoformat() + "Z"
|
||||||
|
|
||||||
|
|
||||||
|
def new_task_id():
|
||||||
|
return f"task_{int(time.time() * 1000)}"
|
||||||
|
|
||||||
|
|
||||||
|
def log(task_id, step, level, message, detail=None):
|
||||||
|
insert_log(task_id, step, level, message, detail or {}, now_iso())
|
||||||
|
|
||||||
|
|
||||||
|
def run_ingest_task(payload):
|
||||||
|
task_id = new_task_id()
|
||||||
|
trace_id = f"{task_id}_{hex(int(time.time() * 1000))[-6:]}"
|
||||||
|
task = {
|
||||||
|
"taskId": task_id,
|
||||||
|
"traceId": trace_id,
|
||||||
|
"status": "RUNNING",
|
||||||
|
"inputPayload": payload,
|
||||||
|
"startedAt": now_iso(),
|
||||||
|
"finishedAt": None,
|
||||||
|
"summary": None,
|
||||||
|
}
|
||||||
|
upsert_task(task)
|
||||||
|
log(task_id, "START", "INFO", "任务开始", {"payload": payload, "traceId": trace_id})
|
||||||
|
|
||||||
|
try:
|
||||||
|
deduped = find_media_item(payload["tmdbId"])
|
||||||
|
if deduped and deduped.get("ingest_status") == "SUCCESS":
|
||||||
|
task["status"] = "SUCCESS"
|
||||||
|
task["finishedAt"] = now_iso()
|
||||||
|
task["summary"] = {
|
||||||
|
"result": "SKIPPED_ALREADY_EXISTS",
|
||||||
|
"tmdbId": payload["tmdbId"],
|
||||||
|
"cmsId": deduped.get("cms_id"),
|
||||||
|
}
|
||||||
|
upsert_task(task)
|
||||||
|
log(task_id, "DEDUPE", "INFO", "命中本地幂等,跳过", task["summary"])
|
||||||
|
return task
|
||||||
|
|
||||||
|
tmdb_result = get_media_detail(payload["tmdbId"], payload["type"])
|
||||||
|
log(task_id, "TMDB_DETAIL", "INFO", "TMDB 元数据获取成功", {"status": tmdb_result["status"]})
|
||||||
|
|
||||||
|
hdhive_search = search_resource(payload["type"], payload["tmdbId"])
|
||||||
|
hdhive_first = None
|
||||||
|
search_data = hdhive_search.get("data") or {}
|
||||||
|
preferred_slug = str(payload.get("slug") or "").strip()
|
||||||
|
if isinstance(search_data, list) and search_data:
|
||||||
|
if preferred_slug:
|
||||||
|
hdhive_first = next(
|
||||||
|
(item for item in search_data if (item or {}).get("slug") == preferred_slug),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not hdhive_first:
|
||||||
|
hdhive_first = search_data[0]
|
||||||
|
elif isinstance(search_data, dict):
|
||||||
|
items = search_data.get("items") or []
|
||||||
|
if preferred_slug:
|
||||||
|
hdhive_first = next(
|
||||||
|
(item for item in items if (item or {}).get("slug") == preferred_slug),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if items:
|
||||||
|
hdhive_first = hdhive_first or items[0]
|
||||||
|
if not hdhive_first:
|
||||||
|
raise AppServiceError(
|
||||||
|
"HDHIVE 未检索到可用资源",
|
||||||
|
category="not_found",
|
||||||
|
code="HDHIVE_RESOURCE_NOT_FOUND",
|
||||||
|
provider="hdhive",
|
||||||
|
)
|
||||||
|
log(task_id, "HDHIVE_SEARCH", "INFO", "HDHIVE 检索成功")
|
||||||
|
|
||||||
|
slug = hdhive_first.get("slug")
|
||||||
|
if not slug:
|
||||||
|
raise AppServiceError(
|
||||||
|
"HDHIVE 返回资源缺少 slug,无法解锁",
|
||||||
|
category="validation",
|
||||||
|
code="HDHIVE_INVALID_RESOURCE",
|
||||||
|
provider="hdhive",
|
||||||
|
)
|
||||||
|
hdhive_unlock = unlock_link(slug)
|
||||||
|
normalized_resource = normalize_resource(hdhive_first, hdhive_unlock.get("data"))
|
||||||
|
log(task_id, "HDHIVE_UNLOCK", "INFO", "HDHIVE 解锁成功", {"unlockUrl": normalized_resource["unlockUrl"]})
|
||||||
|
|
||||||
|
emby_exists = exists_by_tmdb_id(payload["tmdbId"])
|
||||||
|
log(task_id, "EMBY_EXISTS", "INFO", "Emby 查询完成", {"exists": emby_exists["exists"]})
|
||||||
|
if emby_exists["exists"]:
|
||||||
|
upsert_media_item(
|
||||||
|
{
|
||||||
|
"tmdbId": payload["tmdbId"],
|
||||||
|
"type": payload["type"],
|
||||||
|
"title": tmdb_result["normalized"]["title"],
|
||||||
|
"year": tmdb_result["normalized"]["year"],
|
||||||
|
"tmdbRaw": tmdb_result["data"],
|
||||||
|
"hdhiveRaw": hdhive_search["data"],
|
||||||
|
"cmsId": None,
|
||||||
|
"ingestStatus": "SKIPPED_ALREADY_EXISTS",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
task["status"] = "SUCCESS"
|
||||||
|
task["finishedAt"] = now_iso()
|
||||||
|
task["summary"] = {"result": "SKIPPED_ALREADY_EXISTS", "source": "EMBY"}
|
||||||
|
upsert_task(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
cms_payload = {
|
||||||
|
"tmdbId": payload["tmdbId"],
|
||||||
|
"mediaType": payload["type"],
|
||||||
|
"title": tmdb_result["normalized"]["title"],
|
||||||
|
"originalTitle": tmdb_result["normalized"]["originalTitle"],
|
||||||
|
"year": tmdb_result["normalized"]["year"],
|
||||||
|
"overview": tmdb_result["normalized"]["overview"],
|
||||||
|
"posterPath": tmdb_result["normalized"]["posterPath"],
|
||||||
|
"rating": tmdb_result["normalized"]["rating"],
|
||||||
|
"genres": tmdb_result["normalized"]["genres"],
|
||||||
|
"resource": {
|
||||||
|
"title": normalized_resource["resourceTitle"],
|
||||||
|
"quality": normalized_resource["quality"],
|
||||||
|
"size": normalized_resource["size"],
|
||||||
|
"diskType": normalized_resource["diskType"],
|
||||||
|
"slug": normalized_resource["slug"],
|
||||||
|
"source": normalized_resource["source"],
|
||||||
|
"subtitleLanguage": normalized_resource["subtitleLanguage"],
|
||||||
|
"unlockUrl": normalized_resource["unlockUrl"],
|
||||||
|
},
|
||||||
|
"traceId": trace_id,
|
||||||
|
}
|
||||||
|
cms_result = create_resource(cms_payload)
|
||||||
|
cms_data = cms_result.get("data") or {}
|
||||||
|
cms_id = (
|
||||||
|
cms_data.get("id")
|
||||||
|
or cms_data.get("resourceId")
|
||||||
|
or (cms_data.get("data") or {}).get("id")
|
||||||
|
or (cms_data.get("data") or {}).get("resourceId")
|
||||||
|
or normalized_resource["slug"]
|
||||||
|
)
|
||||||
|
log(task_id, "CMS_CREATE", "INFO", "CMS 入库成功", {"cmsId": cms_id})
|
||||||
|
|
||||||
|
upsert_media_item(
|
||||||
|
{
|
||||||
|
"tmdbId": payload["tmdbId"],
|
||||||
|
"type": payload["type"],
|
||||||
|
"title": tmdb_result["normalized"]["title"],
|
||||||
|
"year": tmdb_result["normalized"]["year"],
|
||||||
|
"tmdbRaw": tmdb_result["data"],
|
||||||
|
"hdhiveRaw": hdhive_search["data"],
|
||||||
|
"cmsId": cms_id,
|
||||||
|
"ingestStatus": "SUCCESS",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
task["status"] = "SUCCESS"
|
||||||
|
task["finishedAt"] = now_iso()
|
||||||
|
task["summary"] = {"result": "CREATED", "cmsId": cms_id}
|
||||||
|
upsert_task(task)
|
||||||
|
return task
|
||||||
|
except Exception as error:
|
||||||
|
normalized_error = normalize_exception(error)
|
||||||
|
log(task_id, "FAILED", "ERROR", str(normalized_error), normalized_error.to_dict())
|
||||||
|
task["status"] = "FAILED"
|
||||||
|
task["finishedAt"] = now_iso()
|
||||||
|
task["summary"] = {"error": normalized_error.to_dict()}
|
||||||
|
upsert_task(task)
|
||||||
|
return task
|
||||||
182
backend/storage.py
Normal file
182
backend/storage.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).resolve().parent / "media_crawler.db"
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
task_id TEXT PRIMARY KEY,
|
||||||
|
trace_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
input_payload TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
summary TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS media_items (
|
||||||
|
tmdb_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
year TEXT,
|
||||||
|
tmdb_raw TEXT,
|
||||||
|
hdhive_raw TEXT,
|
||||||
|
cms_id TEXT,
|
||||||
|
ingest_status TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS task_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_task(task):
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tasks(task_id, trace_id, status, input_payload, started_at, finished_at, summary)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(task_id) DO UPDATE SET
|
||||||
|
status=excluded.status,
|
||||||
|
finished_at=excluded.finished_at,
|
||||||
|
summary=excluded.summary
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task["taskId"],
|
||||||
|
task["traceId"],
|
||||||
|
task["status"],
|
||||||
|
json.dumps(task["inputPayload"], ensure_ascii=False),
|
||||||
|
task["startedAt"],
|
||||||
|
task.get("finishedAt"),
|
||||||
|
json.dumps(task.get("summary"), ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def insert_log(task_id, step, level, message, detail, created_at):
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO task_logs(task_id, step, level, message, detail, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task_id,
|
||||||
|
step,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
json.dumps(detail, ensure_ascii=False),
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_media_item(item):
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO media_items(tmdb_id, type, title, year, tmdb_raw, hdhive_raw, cms_id, ingest_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(tmdb_id) DO UPDATE SET
|
||||||
|
type=excluded.type,
|
||||||
|
title=excluded.title,
|
||||||
|
year=excluded.year,
|
||||||
|
tmdb_raw=excluded.tmdb_raw,
|
||||||
|
hdhive_raw=excluded.hdhive_raw,
|
||||||
|
cms_id=excluded.cms_id,
|
||||||
|
ingest_status=excluded.ingest_status
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(item["tmdbId"]),
|
||||||
|
item["type"],
|
||||||
|
item.get("title"),
|
||||||
|
item.get("year"),
|
||||||
|
json.dumps(item.get("tmdbRaw"), ensure_ascii=False),
|
||||||
|
json.dumps(item.get("hdhiveRaw"), ensure_ascii=False),
|
||||||
|
item.get("cmsId"),
|
||||||
|
item["ingestStatus"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def find_media_item(tmdb_id):
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM media_items WHERE tmdb_id = ?",
|
||||||
|
(str(tmdb_id),),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks(limit=50):
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM tasks ORDER BY started_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["inputPayload"] = json.loads(item.pop("input_payload") or "{}")
|
||||||
|
item["startedAt"] = item.pop("started_at")
|
||||||
|
item["finishedAt"] = item.pop("finished_at")
|
||||||
|
item["taskId"] = item.pop("task_id")
|
||||||
|
item["traceId"] = item.pop("trace_id")
|
||||||
|
item["summary"] = json.loads(item["summary"]) if item.get("summary") else None
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(task_id):
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT task_id, step, level, message, detail, created_at FROM task_logs WHERE task_id = ? ORDER BY id ASC",
|
||||||
|
(task_id,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
logs = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["taskId"] = item.pop("task_id")
|
||||||
|
item["createdAt"] = item.pop("created_at")
|
||||||
|
item["detail"] = json.loads(item["detail"]) if item.get("detail") else {}
|
||||||
|
logs.append(item)
|
||||||
|
return logs
|
||||||
21
docker/start.sh
Normal file
21
docker/start.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BACKEND_PORT="${FLASK_RUN_PORT:-14620}"
|
||||||
|
FRONTEND_PORT="${FRONTEND_PORT:-14621}"
|
||||||
|
|
||||||
|
cd /app/backend
|
||||||
|
python3 app.py &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
cd /app/frontend
|
||||||
|
npm run preview -- --host 0.0.0.0 --port "${FRONTEND_PORT}" &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
kill "${BACKEND_PID}" "${FRONTEND_PID}" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup INT TERM EXIT
|
||||||
|
|
||||||
|
wait "${BACKEND_PID}" "${FRONTEND_PID}"
|
||||||
505
docs/cms/[MEDIA]CMS入库115链接.json
Normal file
505
docs/cms/[MEDIA]CMS入库115链接.json
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
{
|
||||||
|
"name": "[MEDIA]CMS入库115链接",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"workflowInputs": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "url"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "fa49a99a-e52d-48ee-a126-c7ad2a128f35",
|
||||||
|
"name": "When Executed by Another Workflow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "get",
|
||||||
|
"propertyName": "token",
|
||||||
|
"key": "CMS_TOKEN",
|
||||||
|
"keyType": "string",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.redis",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
224,
|
||||||
|
-80
|
||||||
|
],
|
||||||
|
"id": "01719d30-77b8-411d-a7d7-0116ffde8725",
|
||||||
|
"name": "Redis",
|
||||||
|
"credentials": {
|
||||||
|
"redis": {
|
||||||
|
"id": "0auGFn4EkjXee2ul",
|
||||||
|
"name": "Redis account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "166573d5-cf9c-43a1-9337-39acf4338cb7",
|
||||||
|
"leftValue": "={{ $json.token }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "empty",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.3,
|
||||||
|
"position": [
|
||||||
|
432,
|
||||||
|
-80
|
||||||
|
],
|
||||||
|
"id": "490b5aa2-2ba7-4037-af2a-f5407e51984e",
|
||||||
|
"name": "If"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"workflowId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "R9CpsJ3lkHFdx-Z3XMNnE",
|
||||||
|
"mode": "list",
|
||||||
|
"cachedResultUrl": "/workflow/R9CpsJ3lkHFdx-Z3XMNnE",
|
||||||
|
"cachedResultName": "CMS获取Token"
|
||||||
|
},
|
||||||
|
"workflowInputs": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [],
|
||||||
|
"attemptToConvertTypes": false,
|
||||||
|
"convertFieldsToString": true
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.executeWorkflow",
|
||||||
|
"typeVersion": 1.3,
|
||||||
|
"position": [
|
||||||
|
640,
|
||||||
|
-176
|
||||||
|
],
|
||||||
|
"id": "aa9f03a2-4640-4b23-bc83-4b679a308daa",
|
||||||
|
"name": "Call 'CMS获取Token'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "raw",
|
||||||
|
"jsonOutput": "={\n \"token\": \"{{ $json.output }}\"\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
848,
|
||||||
|
-176
|
||||||
|
],
|
||||||
|
"id": "06971068-c7c4-414f-931c-e8cd4035e3c9",
|
||||||
|
"name": "Edit Fields"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"numberInputs": 3
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3.2,
|
||||||
|
"position": [
|
||||||
|
976,
|
||||||
|
-48
|
||||||
|
],
|
||||||
|
"id": "dc5ab1a6-e58c-4c7a-8d36-bf3c79465eb2",
|
||||||
|
"name": "Merge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nlet token = \"\";\nlet url = \"\";\nfor (const item of $input.all()) {\n if (\"token\" in item.json) {\n token = item.json.token;\n }\n if (\"url\" in item.json) {\n url = item.json.url;\n }\n}\n\nreturn {\n \"token\": token,\n \"url\": url\n};"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1168,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "a06796c0-0572-438e-a13f-12a216bc283b",
|
||||||
|
"name": "Code in JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://cms.rc707blog.top/api/cloud/add_share_down",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "User-Agent",
|
||||||
|
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json, text/plain, */*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept-Language",
|
||||||
|
"value": "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept-Encoding",
|
||||||
|
"value": "gzip, deflate, br, zstd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "=Bearer {{ $json.token }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Origin",
|
||||||
|
"value": "https://cms.rc707blog.top"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DNT",
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alt-Used",
|
||||||
|
"value": "cms.rc707blog.top"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Connection",
|
||||||
|
"value": "keep-alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Referer",
|
||||||
|
"value": "https://cms.rc707blog.top/incremental-sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Priority",
|
||||||
|
"value": "u=0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "url",
|
||||||
|
"value": "={{ $json.url }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.3,
|
||||||
|
"position": [
|
||||||
|
1376,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "a57cdbfc-dfea-4516-88ae-559dba2d71d8",
|
||||||
|
"name": "HTTP Request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nlet success = $input.first().json.code === 200;\nreturn {\n \"output\": success\n};"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2224,
|
||||||
|
32
|
||||||
|
],
|
||||||
|
"id": "3161bf30-aa02-46a3-aa27-5e93f51083a6",
|
||||||
|
"name": "Code in JavaScript1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "e738a07c-9884-4ca3-a8de-079818a95c90",
|
||||||
|
"leftValue": "={{ $json.code }}",
|
||||||
|
"rightValue": 200,
|
||||||
|
"operator": {
|
||||||
|
"type": "number",
|
||||||
|
"operation": "notEquals"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "f2ec4f79-1c9e-47be-a39c-48dfe19fbd7b",
|
||||||
|
"leftValue": "={{ $json.msg }}",
|
||||||
|
"rightValue": "提取分享链接失败",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "notEquals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.3,
|
||||||
|
"position": [
|
||||||
|
1552,
|
||||||
|
128
|
||||||
|
],
|
||||||
|
"id": "737632fb-47ed-4108-8bb2-560cb5daeb86",
|
||||||
|
"name": "If1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "delete",
|
||||||
|
"key": "CMS_TOKEN"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.redis",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1776,
|
||||||
|
112
|
||||||
|
],
|
||||||
|
"id": "8287fa40-9f93-49a4-87f3-8b653ac2785d",
|
||||||
|
"name": "Redis1",
|
||||||
|
"credentials": {
|
||||||
|
"redis": {
|
||||||
|
"id": "0auGFn4EkjXee2ul",
|
||||||
|
"name": "Redis account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "set",
|
||||||
|
"key": "CMS_TOKEN",
|
||||||
|
"value": "={{ $json.output }}",
|
||||||
|
"keyType": "string",
|
||||||
|
"expire": true,
|
||||||
|
"ttl": 3600
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.redis",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
848,
|
||||||
|
-336
|
||||||
|
],
|
||||||
|
"id": "1534f994-2207-402d-814a-5eda492e9437",
|
||||||
|
"name": "Redis2",
|
||||||
|
"credentials": {
|
||||||
|
"redis": {
|
||||||
|
"id": "0auGFn4EkjXee2ul",
|
||||||
|
"name": "Redis account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3.2,
|
||||||
|
"position": [
|
||||||
|
2000,
|
||||||
|
32
|
||||||
|
],
|
||||||
|
"id": "72d832a4-f7ae-4d91-9d55-0dddd1f4c878",
|
||||||
|
"name": "Merge1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {
|
||||||
|
"When Executed by Another Workflow": [
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"url": "https://115cdn.com/s/swfl9s73zfr?password=0000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"connections": {
|
||||||
|
"When Executed by Another Workflow": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Redis",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Merge",
|
||||||
|
"type": "main",
|
||||||
|
"index": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Redis": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "If",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"If": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Call 'CMS获取Token'",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Merge",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Call 'CMS获取Token'": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Edit Fields",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Redis2",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Edit Fields": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Merge",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Merge": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Code in JavaScript",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Code in JavaScript": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HTTP Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "If1",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Merge1",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"If1": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Redis1",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Code in JavaScript1": {
|
||||||
|
"main": [
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Redis1": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Merge1",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Merge1": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Code in JavaScript1",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate",
|
||||||
|
"availableInMCP": false
|
||||||
|
},
|
||||||
|
"versionId": "b2e168e5-9970-4116-ac54-b2644ac5c4dc",
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true,
|
||||||
|
"instanceId": "66cf2dfa889c11bef16af6baad527d2d3bcad1ee4e7726db9de95c31e876ed5e"
|
||||||
|
},
|
||||||
|
"id": "pH51j4qlAcf_gNk2gYEgH",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
147
docs/cms/[MEDIA]CMS获取Token.json
Normal file
147
docs/cms/[MEDIA]CMS获取Token.json
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"name": "[MEDIA]CMS获取Token",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"inputSource": "passthrough"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "8b0db7d2-9dfa-43f6-abc1-04751f022c67",
|
||||||
|
"name": "When Executed by Another Workflow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://cms.rc707blog.top/api/auth/login",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "User-Agent",
|
||||||
|
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"value": "application/json, text/plain, */*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept-Language",
|
||||||
|
"value": "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept-Encoding",
|
||||||
|
"value": "gzip, deflate, br, zstd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Origin",
|
||||||
|
"value": "https://cms.rc707blog.top"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DNT",
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alt-Used",
|
||||||
|
"value": "cms.rc707blog.top"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Connection",
|
||||||
|
"value": "keep-alive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Referer",
|
||||||
|
"value": "https://cms.rc707blog.top/login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Priority",
|
||||||
|
"value": "u=0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TE",
|
||||||
|
"value": "trailers"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"value": "rose_cat707"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password",
|
||||||
|
"value": "ro983364"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.3,
|
||||||
|
"position": [
|
||||||
|
208,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "06e9804f-6bbd-43d7-a192-081394f9ee9e",
|
||||||
|
"name": "HTTP Request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "raw",
|
||||||
|
"jsonOutput": "={\n\"output\": \"{{ $json.data.token }}\"\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
416,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"id": "c93efa65-9381-403a-8d2c-3d3446e79bfa",
|
||||||
|
"name": "Edit Fields"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"When Executed by Another Workflow": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HTTP Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Edit Fields",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate",
|
||||||
|
"availableInMCP": false
|
||||||
|
},
|
||||||
|
"versionId": "683c7ff7-4b28-454f-8215-98f5d6b055aa",
|
||||||
|
"meta": {
|
||||||
|
"instanceId": "66cf2dfa889c11bef16af6baad527d2d3bcad1ee4e7726db9de95c31e876ed5e"
|
||||||
|
},
|
||||||
|
"id": "R9CpsJ3lkHFdx-Z3XMNnE",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
19
docs/hdhive-openapi-docs/README.md
Normal file
19
docs/hdhive-openapi-docs/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# HDHive OpenAPI 公开文档
|
||||||
|
|
||||||
|
本压缩包由当前前端 OpenAPI 文档内容生成,面向个人脚本、内部工具和第三方应用接入方。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
- [概述](./overview.md)
|
||||||
|
- [认证与授权](./authentication.md)
|
||||||
|
- [限制与错误处理](./limits.md)
|
||||||
|
- [通用响应格式](./response-format.md)
|
||||||
|
- [接口列表](./endpoints.md)
|
||||||
|
|
||||||
|
## SDK 下载
|
||||||
|
|
||||||
|
SDK 下载页:`/api-docs/sdk`
|
||||||
|
|
||||||
|
## 限制说明
|
||||||
|
|
||||||
|
本文档不公开具体 QPS 或每日额度。接入方应按 `429`、`Retry-After`、`limit_scope` 和 `retry_after_seconds` 做退避处理。
|
||||||
112
docs/hdhive-openapi-docs/authentication.md
Normal file
112
docs/hdhive-openapi-docs/authentication.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 认证与授权
|
||||||
|
|
||||||
|
## API Key 认证
|
||||||
|
|
||||||
|
所有 OpenAPI 请求都必须携带 `X-API-Key`。
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-api-key" https://hdhive.com/api/open/ping
|
||||||
|
```
|
||||||
|
|
||||||
|
## 用户身份来源
|
||||||
|
|
||||||
|
业务接口需要能确定调用用户。系统按以下顺序识别用户:
|
||||||
|
|
||||||
|
1. `Authorization: Bearer <OpenAPI 用户 Access Token>`:第三方应用授权后获得,必须属于当前应用。
|
||||||
|
2. `Authorization: Bearer <站内用户 JWT>`:站内登录态 Token。
|
||||||
|
3. API Key 绑定用户:个人 API Key 或管理员绑定用户的 OpenAPI 应用可回退到绑定用户。
|
||||||
|
|
||||||
|
非 `meta` 类接口如果无法确定用户,会返回 `OPENAPI_USER_REQUIRED`。
|
||||||
|
|
||||||
|
## 第三方应用授权流程
|
||||||
|
|
||||||
|
第三方应用应使用管理员创建的 OpenAPI 应用完成授权:
|
||||||
|
|
||||||
|
1. 管理员创建 OpenAPI 应用,配置应用名称、绑定用户、scope、IP 策略、回调地址等。
|
||||||
|
2. 第三方应用引导用户进入前端授权页 `/openapi/authorize`,并传入 `client_id`、`redirect_uri`、`scope`、`state`。
|
||||||
|
3. 用户确认授权后,HDHive 前端调用 `POST /api/public/openapi/oauth/authorize`,后端以 JSON 返回一次性授权码。
|
||||||
|
4. HDHive 前端把用户重定向到第三方 `redirect_uri`,并在 query 中追加 `code` 和原始 `state`。
|
||||||
|
5. 第三方应用服务端用授权码调用 `POST /api/public/openapi/oauth/token` 换取用户 Access Token 和 Refresh Token。
|
||||||
|
6. 调用业务接口时同时传入应用 Secret 和用户 Access Token。
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: app-secret
|
||||||
|
Authorization: Bearer user-access-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户授权页
|
||||||
|
|
||||||
|
第三方应用应把用户跳转到站点前端授权页,而不是直接展示后端 API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hdhive.com/openapi/authorize?client_id=app_xxx&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&scope=query%20unlock&state=opaque-state
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `client_id` | 是 | OpenAPI 应用公开 Client ID,不是 Secret |
|
||||||
|
| `redirect_uri` | 是 | 回调地址,必须在应用配置的回调地址列表内 |
|
||||||
|
| `scope` | 否 | 请求的用户授权 scope,空格分隔;为空时按应用允许范围处理 |
|
||||||
|
| `state` | 否 | 第三方应用自定义状态,建议用于 CSRF 防护和回跳上下文 |
|
||||||
|
|
||||||
|
### 授权码换 Token
|
||||||
|
|
||||||
|
后端换取 Token 时必须使用应用 Secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hdhive.com/api/public/openapi/oauth/token \\
|
||||||
|
-H "X-API-Key: app-secret" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "authorization-code",
|
||||||
|
"redirect_uri": "https://client.example.com/callback"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回的 `access_token` 用于业务 OpenAPI 调用,`refresh_token` 用于刷新用户授权 Token。
|
||||||
|
|
||||||
|
`redirect_uri` 在换取 Token 时不会触发跳转,它是授权码安全校验字段,必须与创建授权码时传入的 `redirect_uri` 完全一致。后端会用它确认授权码确实来自同一次授权请求,避免授权码被截获后在其他回调上下文中兑换。
|
||||||
|
|
||||||
|
### 授权码回调形式
|
||||||
|
|
||||||
|
用户确认授权后,第三方应用收到的是浏览器跳转请求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://client.example.com/callback?code=authorization-code&state=opaque-state
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` 是一次性授权码,第三方应用应该在自己的服务端用应用 Secret 换取 Token,不要在浏览器里暴露应用 Secret。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
| scope | 说明 | 典型能力 |
|
||||||
|
|---|---|---|
|
||||||
|
| `meta` | 元信息接口 | 健康检查、配额、用量查询 |
|
||||||
|
| `query` | 查询接口 | 资源查询、分享详情、资源链接检查 |
|
||||||
|
| `write` | 写入接口 | 创建、更新、删除分享 |
|
||||||
|
| `unlock` | 解锁接口 | 解锁资源并获取链接 |
|
||||||
|
| `vip` | VIP 接口 | 用户信息、签到、VIP 用量 |
|
||||||
|
|
||||||
|
应用必须拥有对应 scope,用户授权 Token 如果限制了 scope,也必须包含对应 scope。
|
||||||
|
|
||||||
|
## 认证错误码
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MISSING_API_KEY` | 401 | 未提供 `X-API-Key` |
|
||||||
|
| `INVALID_API_KEY` | 401 | API Key 无效或不存在 |
|
||||||
|
| `DISABLED_API_KEY` | 401 | API Key 已被禁用 |
|
||||||
|
| `EXPIRED_API_KEY` | 401 | API Key 已过期 |
|
||||||
|
| `INVALID_OPENAPI_USER_TOKEN` | 401 | OpenAPI 用户 Token 无效 |
|
||||||
|
| `OPENAPI_TOKEN_APP_MISMATCH` | 401 | 用户 Token 不属于当前应用 |
|
||||||
|
| `OPENAPI_USER_REQUIRED` | 403 | 业务接口缺少用户身份 |
|
||||||
|
| `SCOPE_NOT_ALLOWED` | 403 | 应用未授权该接口组 |
|
||||||
|
| `USER_SCOPE_NOT_ALLOWED` | 403 | 用户授权 Token scope 不足 |
|
||||||
|
| `VIP_REQUIRED` | 403 | 当前用户不满足 Premium 要求 |
|
||||||
518
docs/hdhive-openapi-docs/endpoints.md
Normal file
518
docs/hdhive-openapi-docs/endpoints.md
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
# 接口列表
|
||||||
|
|
||||||
|
OpenAPI 分为两类接口:
|
||||||
|
|
||||||
|
- **用户授权接口**:以 `/api/public/openapi/oauth` 为前缀,用于授权预览、确认授权、换取 Token 和撤销 Token。
|
||||||
|
- **业务接口**:以 `/api/open` 为前缀,请求必须携带 `X-API-Key`。需要代表具体用户执行的接口,还应携带用户 Access Token;个人 API Key 或绑定用户的应用可回退到绑定用户。
|
||||||
|
|
||||||
|
## 接口总览
|
||||||
|
|
||||||
|
### 用户授权接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/api/public/openapi/oauth/authorize` | 站内登录 Cookie | 授权页预览信息 |
|
||||||
|
| POST | `/api/public/openapi/oauth/authorize` | 站内登录 Cookie | 用户确认授权并生成授权码 |
|
||||||
|
| POST | `/api/public/openapi/oauth/token` | `X-API-Key` 应用 Secret | 授权码或刷新令牌换取用户 Token |
|
||||||
|
| POST | `/api/public/openapi/oauth/revoke` | `X-API-Key` 应用 Secret | 撤销刷新令牌 |
|
||||||
|
|
||||||
|
### 业务接口
|
||||||
|
|
||||||
|
| scope | 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `meta` | GET | `/api/open/ping` | 健康检查 |
|
||||||
|
| `meta` | GET | `/api/open/quota` | 查询当前凭证配额状态 |
|
||||||
|
| `meta` | GET | `/api/open/usage` | 查询历史用量 |
|
||||||
|
| `meta` | GET | `/api/open/usage/today` | 查询今日用量 |
|
||||||
|
| `query` | GET | `/api/open/resources/:type/:tmdb_id` | 根据 TMDB ID 获取资源列表 |
|
||||||
|
| `query` | GET | `/api/open/shares/:slug` | 获取分享详情 |
|
||||||
|
| `query` | POST | `/api/open/check/resource` | 检查资源链接类型 |
|
||||||
|
| `unlock` | POST | `/api/open/resources/unlock` | 解锁资源 |
|
||||||
|
| `write` | GET | `/api/open/shares` | 获取我的分享列表 |
|
||||||
|
| `write` | POST | `/api/open/shares` | 创建分享 |
|
||||||
|
| `write` | PATCH | `/api/open/shares/:slug` | 更新分享 |
|
||||||
|
| `write` | DELETE | `/api/open/shares/:slug` | 删除分享 |
|
||||||
|
| `vip` | GET | `/api/open/me` | 获取当前用户信息 |
|
||||||
|
| `vip` | POST | `/api/open/checkin` | 每日签到 |
|
||||||
|
| `vip` | GET | `/api/open/vip/weekly-free-quota` | 获取长期 Premium 每周免费解锁状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户授权流程接口
|
||||||
|
|
||||||
|
第三方应用的用户授权入口应使用前端页面:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /openapi/authorize?client_id=app_xxx&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&scope=query%20unlock&state=opaque-state
|
||||||
|
```
|
||||||
|
|
||||||
|
前端页面会要求用户登录,并调用下面的后端授权接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/public/openapi/oauth/authorize
|
||||||
|
|
||||||
|
获取授权页展示信息。该接口需要站内登录 Cookie;未登录会返回 `401`,前端授权页会跳转到登录页。
|
||||||
|
|
||||||
|
### Query 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `client_id` | string | 是 | OpenAPI 应用公开 Client ID |
|
||||||
|
| `redirect_uri` | string | 是 | 授权完成回调地址,必须在应用配置中 |
|
||||||
|
| `scope` | string | 否 | 请求 scope,空格分隔,例如 `query unlock` |
|
||||||
|
| `state` | string | 否 | 第三方应用自定义状态 |
|
||||||
|
|
||||||
|
### 成功响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data.client_id` | string | 应用 Client ID |
|
||||||
|
| `data.app_name` | string | 应用名称 |
|
||||||
|
| `data.description` | string | 应用描述 |
|
||||||
|
| `data.auth_level` | string | 应用授权等级 |
|
||||||
|
| `data.redirect_uri` | string | 本次授权回调地址 |
|
||||||
|
| `data.requested_scopes` | string[] | 本次请求 scope |
|
||||||
|
| `data.allowed_scopes` | string[] | 应用实际允许 scope |
|
||||||
|
| `data.user` | object | 当前登录用户摘要 |
|
||||||
|
| `data.expires_in` | integer | 授权码有效期秒数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/public/openapi/oauth/authorize
|
||||||
|
|
||||||
|
用户确认授权并生成授权码。该接口需要站内登录 Cookie。
|
||||||
|
|
||||||
|
该接口本身返回 JSON 给 HDHive 前端授权页。HDHive 前端收到 `data.code` 后,会重定向到第三方 `redirect_uri`,并追加 `code` 和原始 `state`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://client.example.com/callback?code=authorization-code&state=opaque-state
|
||||||
|
```
|
||||||
|
|
||||||
|
### Body 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `client_id` | string | 是 | OpenAPI 应用公开 Client ID |
|
||||||
|
| `redirect_uri` | string | 是 | 授权完成回调地址 |
|
||||||
|
| `scope` | string | 否 | 请求 scope,空格分隔 |
|
||||||
|
| `state` | string | 否 | 第三方应用自定义状态 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hdhive.com/api/public/openapi/oauth/authorize \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"client_id": "app_xxx",
|
||||||
|
"redirect_uri": "https://client.example.com/callback",
|
||||||
|
"scope": "query unlock",
|
||||||
|
"state": "opaque-state"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"code": "200",
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"code": "authorization-code",
|
||||||
|
"redirect_uri": "https://client.example.com/callback",
|
||||||
|
"expires_in": 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
第三方应用通过自己的回调地址收到授权码后,应在服务端用应用 Secret 换取用户 Token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/public/openapi/oauth/token
|
||||||
|
|
||||||
|
使用授权码或刷新令牌换取 OpenAPI 用户 Token。该接口必须携带 OpenAPI 应用 Secret。
|
||||||
|
|
||||||
|
`redirect_uri` 只在 `authorization_code` 模式下需要。它不是跳转地址,而是授权码校验字段,必须与用户授权阶段传入的 `redirect_uri` 完全一致;后端会同时校验授权码、应用 Secret 和 `redirect_uri` 的绑定关系。
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: app-secret
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### authorization_code Body
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `grant_type` | string | 是 | 固定为 `authorization_code` |
|
||||||
|
| `code` | string | 是 | 用户确认授权后得到的授权码 |
|
||||||
|
| `redirect_uri` | string | 是 | 安全校验字段,必须与授权码创建时传入的回调地址完全一致;不会触发跳转 |
|
||||||
|
|
||||||
|
### refresh_token Body
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `grant_type` | string | 是 | 固定为 `refresh_token` |
|
||||||
|
| `refresh_token` | string | 是 | 刷新令牌 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hdhive.com/api/public/openapi/oauth/token \\
|
||||||
|
-H "X-API-Key: app-secret" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "authorization-code",
|
||||||
|
"redirect_uri": "https://client.example.com/callback"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成功响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data.access_token` | string | OpenAPI 用户 Access Token |
|
||||||
|
| `data.refresh_token` | string | Refresh Token,刷新时可能返回新值 |
|
||||||
|
| `data.token_type` | string | 固定为 `Bearer` |
|
||||||
|
| `data.expires_in` | integer | Access Token 有效期秒数 |
|
||||||
|
| `data.refresh_expires_in` | integer | Refresh Token 有效期秒数 |
|
||||||
|
| `data.scope` | string | 空格分隔 scope |
|
||||||
|
| `data.scopes` | string[] | scope 数组 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/public/openapi/oauth/revoke
|
||||||
|
|
||||||
|
撤销某个 Refresh Token。撤销后该刷新令牌不能再换取新的用户 Access Token。
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: app-secret
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Body 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `refresh_token` | string | 是 | 要撤销的刷新令牌 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hdhive.com/api/public/openapi/oauth/revoke \\
|
||||||
|
-H "X-API-Key: app-secret" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"refresh_token": "refresh-token"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/ping
|
||||||
|
|
||||||
|
健康检查接口,用于验证 API Key 是否有效。
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-api-key" https://hdhive.com/api/open/ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"code": "200",
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"message": "pong",
|
||||||
|
"api_key_id": 1,
|
||||||
|
"name": "My App"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/quota
|
||||||
|
|
||||||
|
查询当前 API Key 的配额状态。该接口用于展示服务端返回的当前状态,不应在客户端写死限制值。
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-api-key" https://hdhive.com/api/open/quota
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data.daily_reset` | integer | 配额重置时间 |
|
||||||
|
| `data.endpoint_limit` | integer / null | 当前接口限制状态,未配置时为空 |
|
||||||
|
| `data.endpoint_remaining` | integer / null | 当前接口剩余状态,未配置时为空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/usage
|
||||||
|
|
||||||
|
查询当前 API Key 的历史用量统计。
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `start_date` | query | string | 否 | 开始日期,格式 `YYYY-MM-DD` |
|
||||||
|
| `end_date` | query | string | 否 | 结束日期,格式 `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-api-key" \\
|
||||||
|
"https://hdhive.com/api/open/usage?start_date=2026-04-01&end_date=2026-04-25"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data.daily_stats` | array | 每日调用统计 |
|
||||||
|
| `data.endpoint_stats` | array | 按接口聚合的统计 |
|
||||||
|
| `data.summary` | object | 汇总统计 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/resources/:type/:tmdb_id
|
||||||
|
|
||||||
|
根据媒体类型和 TMDB ID 获取资源列表。
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `type` | path | string | 是 | 媒体类型,`movie` 或 `tv` |
|
||||||
|
| `tmdb_id` | path | string | 是 | TMDB ID |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your-api-key" \\
|
||||||
|
-H "Authorization: Bearer user-access-token" \\
|
||||||
|
https://hdhive.com/api/open/resources/movie/550
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data[].slug` | string | 资源唯一标识 |
|
||||||
|
| `data[].title` | string / null | 资源标题 |
|
||||||
|
| `data[].pan_type` | string / null | 网盘类型 |
|
||||||
|
| `data[].share_size` | string / null | 分享文件大小 |
|
||||||
|
| `data[].video_resolution` | string[] | 视频分辨率 |
|
||||||
|
| `data[].source` | string[] | 片源 |
|
||||||
|
| `data[].subtitle_language` | string[] | 字幕语言 |
|
||||||
|
| `data[].subtitle_type` | string[] | 字幕类型 |
|
||||||
|
| `data[].unlock_points` | integer / null | 解锁所需积分 |
|
||||||
|
| `data[].is_unlocked` | boolean | 当前用户是否已解锁 |
|
||||||
|
| `data[].user` | object / null | 分享者信息 |
|
||||||
|
| `meta.total` | integer | 返回数量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/open/resources/unlock
|
||||||
|
|
||||||
|
代表当前用户解锁资源并获取链接。第三方应用调用时应携带用户 Access Token。
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `slug` | body | string | 是 | 资源 slug,支持带横杠或不带横杠的 UUID |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \\
|
||||||
|
-H "X-API-Key: app-secret" \\
|
||||||
|
-H "Authorization: Bearer user-access-token" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"slug": "a1b2c3d4e5f647898765432112345678"}' \\
|
||||||
|
https://hdhive.com/api/open/resources/unlock
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"code": "200",
|
||||||
|
"message": "解锁成功",
|
||||||
|
"data": {
|
||||||
|
"url": "https://pan.example.com/s/abc123",
|
||||||
|
"access_code": "x1y2",
|
||||||
|
"full_url": "https://pan.example.com/s/abc123?pwd=x1y2",
|
||||||
|
"already_owned": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
| 场景 | HTTP 状态码 | 错误码 |
|
||||||
|
|---|---|---|
|
||||||
|
| 参数无效 | 400 | `400` |
|
||||||
|
| 缺少用户身份 | 403 | `OPENAPI_USER_REQUIRED` |
|
||||||
|
| 资源不存在 | 404 | `404` |
|
||||||
|
| 积分不足 | 402 | `INSUFFICIENT_POINTS` |
|
||||||
|
| 用户请求过快 | 429 | `RATE_LIMIT_EXCEEDED` |
|
||||||
|
|
||||||
|
> unlock 接口保留用户阶梯风控。第三方应用触发时,处罚对象是当前授权用户,不是整个应用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/open/check/resource
|
||||||
|
|
||||||
|
检查资源链接所属网盘类型,并尝试解析部分网盘的访问码。
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `url` | body | string | 是 | 资源分享链接 |
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \\
|
||||||
|
-H "X-API-Key: your-api-key" \\
|
||||||
|
-H "Authorization: Bearer user-access-token" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"url": "https://115.com/s/example 访问码:1234"}' \\
|
||||||
|
https://hdhive.com/api/open/check/resource
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/me
|
||||||
|
|
||||||
|
获取当前用户信息。该接口需要 `vip` scope,并要求当前用户满足 Premium 条件。
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: app-secret" \\
|
||||||
|
-H "Authorization: Bearer user-access-token" \\
|
||||||
|
https://hdhive.com/api/open/me
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data.id` | number | 用户 ID |
|
||||||
|
| `data.nickname` | string | 昵称 |
|
||||||
|
| `data.username` | string | 用户名 |
|
||||||
|
| `data.email` | string | 邮箱 |
|
||||||
|
| `data.avatar_url` | string | 头像 |
|
||||||
|
| `data.is_vip` | boolean | 是否 Premium |
|
||||||
|
| `data.user_meta` | object | 用户积分、签到、分享等元信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/open/checkin
|
||||||
|
|
||||||
|
代表当前用户执行每日签到。该接口需要 `vip` scope,并要求当前用户满足 Premium 条件。
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `is_gambler` | body | boolean | 否 | 是否使用高波动签到模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/vip/weekly-free-quota
|
||||||
|
|
||||||
|
获取当前用户长期 Premium 每周免费解锁状态。该接口只返回状态,不建议客户端假设配置固定。
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `is_forever_vip` | boolean | 是否长期 Premium |
|
||||||
|
| `limit` | int | 服务端当前配置的基础状态值 |
|
||||||
|
| `used` | int | 本周已使用次数 |
|
||||||
|
| `remaining` | int | 当前可用状态 |
|
||||||
|
| `unlimited` | boolean | 是否不限制 |
|
||||||
|
| `bonus_quota` | int | 累积状态值 |
|
||||||
|
| `bonus_quota_max` | int | 累积上限状态值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/shares
|
||||||
|
|
||||||
|
获取当前用户的分享列表。需要 `write` scope。
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `page` | query | integer | 否 | 页码 |
|
||||||
|
| `page_size` | query | integer | 否 | 每页数量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/open/shares/:slug
|
||||||
|
|
||||||
|
获取指定分享详情。该接口不返回资源下载链接和访问码,需要调用 unlock 接口解锁后获取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/open/shares
|
||||||
|
|
||||||
|
创建新的资源分享。需要 `write` scope。
|
||||||
|
|
||||||
|
### 关键参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `url` | string | 是 | 分享链接 |
|
||||||
|
| `tmdb_id` | string | 条件必填 | TMDB ID,需配合 `media_type` |
|
||||||
|
| `media_type` | string | 条件必填 | `movie` 或 `tv` |
|
||||||
|
| `movie_id` | integer | 条件必填 | 系统电影 ID |
|
||||||
|
| `tv_id` | integer | 条件必填 | 系统电视剧 ID |
|
||||||
|
| `collection_id` | integer | 否 | 系统合集 ID |
|
||||||
|
| `title` | string | 否 | 资源标题 |
|
||||||
|
| `access_code` | string | 否 | 访问码 |
|
||||||
|
| `unlock_points` | integer | 否 | 解锁积分,未传或 0 表示免费 |
|
||||||
|
| `is_anonymous` | boolean | 否 | 是否匿名分享 |
|
||||||
|
| `hide_link` | boolean | 否 | 是否在通知中隐藏链接 |
|
||||||
|
|
||||||
|
> 影视关联至少需要提供 `tmdb_id + media_type`、`movie_id`、`tv_id` 或 `collection_id` 之一。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PATCH /api/open/shares/:slug
|
||||||
|
|
||||||
|
部分更新已有分享。需要 `write` scope。只能更新自己创建的分享,或当前用户具备对应权限的分享。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /api/open/shares/:slug
|
||||||
|
|
||||||
|
删除分享。需要 `write` scope。只能删除自己创建的分享,或当前用户具备对应权限的分享。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更内容 |
|
||||||
|
|---|---|
|
||||||
|
| 2026-04-25 | 文档更新为新版 OpenAPI 应用授权、用户授权 Token、scope、限制与错误码口径 |
|
||||||
78
docs/hdhive-openapi-docs/limits.md
Normal file
78
docs/hdhive-openapi-docs/limits.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 限制与错误处理
|
||||||
|
|
||||||
|
## 限制维度
|
||||||
|
|
||||||
|
OpenAPI 会按多个维度保护系统稳定性:
|
||||||
|
|
||||||
|
- **应用级限制**:按 API Key / OpenAPI 应用维度限制请求频率和突发流量。
|
||||||
|
- **用户级限制**:按实际调用用户维度限制请求频率和每日成功业务请求。
|
||||||
|
- **接口组限制**:按 `meta/query/write/unlock/vip` 分组控制访问频率和是否计量。
|
||||||
|
- **IP 策略**:支持不限制 IP、固定 IP 白名单、动态 IP 风控。
|
||||||
|
- **unlock 风控**:资源解锁接口保留旧的用户阶梯处罚监测,处罚对象是实际授权用户,不是整个第三方应用。
|
||||||
|
|
||||||
|
具体阈值由管理员配置,不在开发者文档中公开。接入方只需要按响应头和错误码处理退避。
|
||||||
|
|
||||||
|
## 429 处理规则
|
||||||
|
|
||||||
|
当请求返回 `429` 时,客户端应读取 `Retry-After` 头,并在等待后重试。不要在冷却期内持续重试。
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Retry-After: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
429 错误响应会通过 `limit_scope` 明确说明限制对象:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"code": "USER_RATE_LIMIT_EXCEEDED",
|
||||||
|
"message": "User OpenAPI rate limit exceeded",
|
||||||
|
"description": "用户 OpenAPI 请求频率过高,当前授权用户已进入冷却,请稍后重试",
|
||||||
|
"limit_scope": "user",
|
||||||
|
"limit_scope_label": "授权用户",
|
||||||
|
"retry_after_seconds": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`limit_scope=app` 表示当前应用或 API Key 被限制,所有使用该应用的请求都应退避;`limit_scope=user` 表示当前授权用户被限制,不代表整个第三方应用不可用。
|
||||||
|
|
||||||
|
## 常见限制错误码
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `OPENAPI_COOLDOWN` | 429 | 应用或用户处于 OpenAPI 冷却期,具体对象读取 `limit_scope` |
|
||||||
|
| `IP_COUNT_EXCEEDED` | 429 | 动态 IP 风控触发 |
|
||||||
|
| `IP_NOT_ALLOWED` | 403 | 请求 IP 不在白名单内 |
|
||||||
|
| `APP_RATE_LIMIT_EXCEEDED` | 429 | 应用请求频率过高 |
|
||||||
|
| `APP_BURST_LIMIT_EXCEEDED` | 429 | 应用突发请求过高 |
|
||||||
|
| `APP_IP_RATE_LIMIT_EXCEEDED` | 429 | 应用单 IP 请求频率过高 |
|
||||||
|
| `ENDPOINT_GROUP_RATE_LIMIT_EXCEEDED` | 429 | 接口组请求频率过高 |
|
||||||
|
| `USER_RATE_LIMIT_EXCEEDED` | 429 | 用户 OpenAPI 请求频率过高 |
|
||||||
|
| `APP_DAILY_QUOTA_EXCEEDED` | 429 | 应用今日成功业务请求额度耗尽 |
|
||||||
|
| `USER_DAILY_QUOTA_EXCEEDED` | 429 | 用户今日成功业务请求额度耗尽 |
|
||||||
|
| `RATE_LIMIT_EXCEEDED` | 429 | unlock 用户阶梯风控触发 |
|
||||||
|
|
||||||
|
## 配额响应头
|
||||||
|
|
||||||
|
部分响应会返回当前用户或应用的剩余信息。客户端可以展示这些信息,但不能假设阈值固定不变。
|
||||||
|
|
||||||
|
| Header | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `Retry-After` | 建议等待秒数 |
|
||||||
|
| `X-OpenAPI-User-Daily-Limit` | 用户当日额度上限,存在时返回 |
|
||||||
|
| `X-OpenAPI-User-Daily-Remaining` | 用户当日剩余额度,存在时返回 |
|
||||||
|
| `X-OpenAPI-App-Daily-Limit` | 应用当日额度上限,存在时返回 |
|
||||||
|
| `X-OpenAPI-App-Daily-Remaining` | 应用当日剩余额度,存在时返回 |
|
||||||
|
|
||||||
|
## 限制字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `limit_scope` | string | 限制对象,`app` 表示应用,`user` 表示授权用户 |
|
||||||
|
| `limit_scope_label` | string | 限制对象中文说明 |
|
||||||
|
| `retry_after_seconds` | integer | 建议等待秒数,与 `Retry-After` Header 对齐 |
|
||||||
|
|
||||||
|
## 计量说明
|
||||||
|
|
||||||
|
`meta` 类接口用于健康检查、配额和用量查询,通常不计入用户每日业务额度。非成功响应会回滚本次已预占的每日额度。
|
||||||
32
docs/hdhive-openapi-docs/overview.md
Normal file
32
docs/hdhive-openapi-docs/overview.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# HDHive OpenAPI 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
HDHive OpenAPI 面向个人脚本、内部工具和第三方应用,提供资源查询、资源解锁、分享管理、用户信息和用量查询能力。
|
||||||
|
|
||||||
|
新版 OpenAPI 使用统一平台凭证:
|
||||||
|
|
||||||
|
- **个人 API Key**:用户自己创建,适合个人脚本和自用自动化。
|
||||||
|
- **OpenAPI 应用**:管理员创建,适合第三方应用或合作方系统。
|
||||||
|
- **用户授权 Token**:用户通过第三方应用授权后获得,代表具体用户执行业务操作。
|
||||||
|
|
||||||
|
## 基本信息
|
||||||
|
|
||||||
|
- **业务接口 Base URL**:`/api/open`
|
||||||
|
- **用户授权接口 Base URL**:`/api/public/openapi/oauth`
|
||||||
|
- **用户授权页**:`/openapi/authorize`
|
||||||
|
- **认证方式**:`X-API-Key` 必填,业务接口可附加 `Authorization: Bearer <token>`
|
||||||
|
- **响应格式**:JSON
|
||||||
|
- **权限模型**:应用 scope + 用户授权 scope + 用户身份共同决定可访问范围
|
||||||
|
- **SDK 与文档下载**:[打开 SDK 下载页](/api-docs/sdk),可下载 Go / Python / Node SDK 和公开版 API 文档 ZIP
|
||||||
|
|
||||||
|
## 文档原则
|
||||||
|
|
||||||
|
本文档只说明接入方式、接口语义、认证流程和错误码,不展示具体 QPS 或每日额度。具体限制由管理员在「OpenAPI 策略」和「OpenAPI 应用」中配置,接入方应按 `429` 响应和 `Retry-After` 头进行退避重试。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- **认证与授权** — API Key、用户 Token、scope、用户身份来源
|
||||||
|
- **限制与错误处理** — 429、冷却、IP 策略、额度响应头
|
||||||
|
- **通用响应格式** — 成功/错误响应结构、错误码汇总
|
||||||
|
- **接口列表** — 主要 OpenAPI 接口说明、请求参数与响应示例
|
||||||
63
docs/hdhive-openapi-docs/response-format.md
Normal file
63
docs/hdhive-openapi-docs/response-format.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 通用响应格式
|
||||||
|
|
||||||
|
所有 OpenAPI 接口均采用统一 JSON 响应结构。
|
||||||
|
|
||||||
|
## 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"code": "200",
|
||||||
|
"message": "success",
|
||||||
|
"data": { "...": "..." },
|
||||||
|
"meta": { "...": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | boolean | 是否成功 |
|
||||||
|
| `code` | string | 状态码或业务码 |
|
||||||
|
| `message` | string | 响应消息 |
|
||||||
|
| `data` | object / array / null | 响应数据 |
|
||||||
|
| `meta` | object / null | 分页、统计等元信息 |
|
||||||
|
|
||||||
|
## 错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"message": "Error message",
|
||||||
|
"description": "详细错误描述",
|
||||||
|
"limit_scope": "app",
|
||||||
|
"limit_scope_label": "应用",
|
||||||
|
"retry_after_seconds": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `success` | boolean | 固定为 `false` |
|
||||||
|
| `code` | string | 错误码 |
|
||||||
|
| `message` | string | 错误消息 |
|
||||||
|
| `description` | string | 面向用户或开发者的中文说明 |
|
||||||
|
| `limit_scope` | string / undefined | 限制类错误的对象,可能为 `app` 或 `user` |
|
||||||
|
| `limit_scope_label` | string / undefined | 限制对象中文说明 |
|
||||||
|
| `retry_after_seconds` | integer / undefined | 建议等待秒数 |
|
||||||
|
|
||||||
|
## 业务错误码
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `400` | 400 | 请求参数错误 |
|
||||||
|
| `401` | 401 | 未授权 |
|
||||||
|
| `403` | 403 | 无权访问 |
|
||||||
|
| `404` | 404 | 资源不存在 |
|
||||||
|
| `500` | 500 | 服务内部错误 |
|
||||||
|
| `INSUFFICIENT_POINTS` | 402 | 积分不足 |
|
||||||
|
| `VIP_REQUIRED` | 403 | 需要 Premium |
|
||||||
|
| `SCOPE_NOT_ALLOWED` | 403 | 应用 scope 不足 |
|
||||||
|
| `USER_SCOPE_NOT_ALLOWED` | 403 | 用户授权 scope 不足 |
|
||||||
|
| `OPENAPI_USER_REQUIRED` | 403 | 缺少用户身份 |
|
||||||
|
| `RATE_LIMIT_EXCEEDED` | 429 | 请求触发限制,应按 `Retry-After` 退避 |
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_BACKEND_BASE_URL=http://127.0.0.1:14620
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1546
frontend/package-lock.json
generated
Normal file
1546
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
13
frontend/src/App.vue
Normal file
13
frontend/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container">
|
||||||
|
<section class="card">
|
||||||
|
<h1>影视资源搜索与入库</h1>
|
||||||
|
<p class="desc">主页面用于 TMDB 搜索,详情页查看 HDHive 资源并点击入库。</p>
|
||||||
|
<nav class="tabs">
|
||||||
|
<RouterLink to="/" class="tab">影视搜索</RouterLink>
|
||||||
|
<RouterLink to="/tasks" class="tab">任务中心</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
42
frontend/src/api/backendApi.js
Normal file
42
frontend/src/api/backendApi.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_BASE_URL || "http://127.0.0.1:14620";
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
data?.error?.message || data?.message || `Request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTask(payload) {
|
||||||
|
return request("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchMedia(query, type = "movie") {
|
||||||
|
return request(
|
||||||
|
`/api/media/search?query=${encodeURIComponent(query)}&type=${encodeURIComponent(type)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMediaDetail(type, tmdbId) {
|
||||||
|
return request(`/api/media/${encodeURIComponent(type)}/${encodeURIComponent(tmdbId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ingestMediaResource(payload) {
|
||||||
|
return createTask(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTasks() {
|
||||||
|
return request("/api/tasks");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTaskLogs(taskId) {
|
||||||
|
return request(`/api/tasks/${taskId}/logs`);
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
95
frontend/src/components/HelloWorld.vue
Normal file
95
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import viteLogo from '../assets/vite.svg'
|
||||||
|
import heroImg from '../assets/hero.png'
|
||||||
|
import vueLogo from '../assets/vue.svg'
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="center">
|
||||||
|
<div class="hero">
|
||||||
|
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||||
|
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||||
|
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="counter" @click="count++">
|
||||||
|
Count is {{ count }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img class="logo" :src="viteLogo" alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img class="button-icon" :src="vueLogo" alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</template>
|
||||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
15
frontend/src/router/index.js
Normal file
15
frontend/src/router/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import SearchPage from "../views/SearchPage.vue";
|
||||||
|
import MediaDetailPage from "../views/MediaDetailPage.vue";
|
||||||
|
import TasksPage from "../views/TasksPage.vue";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", name: "search", component: SearchPage },
|
||||||
|
{ path: "/media/:type/:tmdbId", name: "media-detail", component: MediaDetailPage },
|
||||||
|
{ path: "/tasks", name: "tasks", component: TasksPage },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
200
frontend/src/style.css
Normal file
200
frontend/src/style.css
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
:root {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #1b1f23;
|
||||||
|
background: #f6f8fa;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8dee4;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
color: #57606a;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1f6feb;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.router-link-active {
|
||||||
|
background: #1f6feb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #1f6feb;
|
||||||
|
background: #1f6feb;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.small {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list,
|
||||||
|
.log-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list li {
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list li.active {
|
||||||
|
border-color: #1f6feb;
|
||||||
|
background: #f0f6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #57606a;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #cf222e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: #57606a;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-item img,
|
||||||
|
.no-poster {
|
||||||
|
width: 100%;
|
||||||
|
height: 210px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
cursor: pointer;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-poster {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #57606a;
|
||||||
|
background: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-title {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list li {
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
frontend/src/views/MediaDetailPage.vue
Normal file
90
frontend/src/views/MediaDetailPage.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { fetchMediaDetail, ingestMediaResource } from "../api/backendApi";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const loading = ref(false);
|
||||||
|
const ingestingSlug = ref("");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const successMessage = ref("");
|
||||||
|
const media = ref(null);
|
||||||
|
const resources = ref([]);
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const data = await fetchMediaDetail(route.params.type, route.params.tmdbId);
|
||||||
|
media.value = data.media || null;
|
||||||
|
resources.value = data.resources || [];
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || "加载详情失败";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIngest(resource) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`确认入库该资源吗?\n${resource.resourceTitle || "未命名资源"}`,
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ingestingSlug.value = resource.slug || "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
successMessage.value = "";
|
||||||
|
try {
|
||||||
|
const task = await ingestMediaResource({
|
||||||
|
tmdbId: route.params.tmdbId,
|
||||||
|
type: route.params.type,
|
||||||
|
slug: resource.slug,
|
||||||
|
keyword: media.value?.title || "",
|
||||||
|
});
|
||||||
|
successMessage.value = "入库任务已创建,正在跳转任务页...";
|
||||||
|
if (task?.taskId) {
|
||||||
|
router.push(`/tasks?taskId=${encodeURIComponent(task.taskId)}`);
|
||||||
|
} else {
|
||||||
|
router.push("/tasks");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || "入库失败";
|
||||||
|
} finally {
|
||||||
|
ingestingSlug.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDetail);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<h2>影视详情</h2>
|
||||||
|
<p v-if="loading">加载中...</p>
|
||||||
|
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||||
|
<p v-if="successMessage">{{ successMessage }}</p>
|
||||||
|
<pre v-if="media">{{ JSON.stringify(media, null, 2) }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>HDHive 资源链接</h2>
|
||||||
|
<ul class="resource-list">
|
||||||
|
<li v-for="res in resources" :key="res.slug || res.unlockUrl">
|
||||||
|
<div><strong>{{ res.resourceTitle || "未命名资源" }}</strong></div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{{ res.quality || "未知画质" }}</span>
|
||||||
|
<span>{{ res.diskType || "未知网盘" }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" @click.prevent="handleIngest(res)">
|
||||||
|
{{
|
||||||
|
ingestingSlug === res.slug
|
||||||
|
? "入库中..."
|
||||||
|
: res.unlockUrl || "点击该资源链接执行入库"
|
||||||
|
}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
77
frontend/src/views/SearchPage.vue
Normal file
77
frontend/src/views/SearchPage.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { searchMedia } from "../api/backendApi";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const keyword = ref("");
|
||||||
|
const mediaType = ref("movie");
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const results = ref([]);
|
||||||
|
|
||||||
|
function posterUrl(path) {
|
||||||
|
return path ? `https://image.tmdb.org/t/p/w342${path}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!keyword.value.trim()) {
|
||||||
|
errorMessage.value = "请输入关键词";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const data = await searchMedia(keyword.value.trim(), mediaType.value);
|
||||||
|
results.value = data.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || "搜索失败";
|
||||||
|
results.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(item) {
|
||||||
|
router.push(`/media/${mediaType.value}/${item.id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<h2>TMDB 影视搜索</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
关键词
|
||||||
|
<input v-model="keyword" placeholder="例如:沙丘、黑镜" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
类型
|
||||||
|
<select v-model="mediaType">
|
||||||
|
<option value="movie">movie</option>
|
||||||
|
<option value="tv">tv</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button :disabled="loading" @click="handleSearch">
|
||||||
|
{{ loading ? "搜索中..." : "开始搜索" }}
|
||||||
|
</button>
|
||||||
|
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>搜索结果</h2>
|
||||||
|
<div class="poster-grid">
|
||||||
|
<article v-for="item in results" :key="item.id" class="poster-item">
|
||||||
|
<img
|
||||||
|
v-if="posterUrl(item.posterPath)"
|
||||||
|
:src="posterUrl(item.posterPath)"
|
||||||
|
:alt="item.title"
|
||||||
|
@click="goDetail(item)"
|
||||||
|
/>
|
||||||
|
<div v-else class="no-poster" @click="goDetail(item)">无海报</div>
|
||||||
|
<div class="poster-title">{{ item.title }}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
116
frontend/src/views/TasksPage.vue
Normal file
116
frontend/src/views/TasksPage.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { fetchTaskLogs, fetchTasks } from "../api/backendApi";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const tasks = ref([]);
|
||||||
|
const selectedTaskId = ref("");
|
||||||
|
const logs = ref([]);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const taskItemRefs = ref({});
|
||||||
|
|
||||||
|
const selectedTask = computed(() =>
|
||||||
|
tasks.value.find((item) => item.taskId === selectedTaskId.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
function reloadTasks() {
|
||||||
|
const preferredTaskId = String(route.query.taskId || "").trim();
|
||||||
|
return fetchTasks()
|
||||||
|
.then((res) => {
|
||||||
|
tasks.value = res.items || [];
|
||||||
|
if (preferredTaskId) {
|
||||||
|
const matched = tasks.value.find((item) => item.taskId === preferredTaskId);
|
||||||
|
if (matched) {
|
||||||
|
selectedTaskId.value = matched.taskId;
|
||||||
|
return fetchTaskLogs(selectedTaskId.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tasks.value.length > 0) {
|
||||||
|
selectedTaskId.value = selectedTaskId.value || tasks.value[0].taskId;
|
||||||
|
return fetchTaskLogs(selectedTaskId.value);
|
||||||
|
}
|
||||||
|
return { items: [] };
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
logs.value = res.items || [];
|
||||||
|
return nextTick();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (selectedTaskId.value) {
|
||||||
|
scrollToTask(selectedTaskId.value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
errorMessage.value = error.message || "加载任务列表失败";
|
||||||
|
tasks.value = [];
|
||||||
|
logs.value = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(taskId) {
|
||||||
|
selectedTaskId.value = taskId;
|
||||||
|
fetchTaskLogs(taskId)
|
||||||
|
.then((res) => {
|
||||||
|
logs.value = res.items || [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
errorMessage.value = error.message || "加载日志失败";
|
||||||
|
logs.value = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerTaskItemRef(taskId, el) {
|
||||||
|
if (!el) return;
|
||||||
|
taskItemRefs.value[taskId] = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTask(taskId) {
|
||||||
|
const el = taskItemRefs.value[taskId];
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reloadTasks);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>任务列表</h2>
|
||||||
|
<button class="small" @click="reloadTasks">刷新</button>
|
||||||
|
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.taskId"
|
||||||
|
:class="{ active: task.taskId === selectedTaskId }"
|
||||||
|
:ref="(el) => registerTaskItemRef(task.taskId, el)"
|
||||||
|
@click="selectTask(task.taskId)"
|
||||||
|
>
|
||||||
|
<div>{{ task.taskId }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{{ task.status }}</span>
|
||||||
|
<span>{{ task.inputPayload?.tmdbId }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>任务详情</h2>
|
||||||
|
<pre v-if="selectedTask">{{ JSON.stringify(selectedTask, null, 2) }}</pre>
|
||||||
|
<p v-else>暂无任务</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>执行日志</h2>
|
||||||
|
<ul class="log-list">
|
||||||
|
<li v-for="(log, idx) in logs" :key="`${log.createdAt}_${idx}`">
|
||||||
|
<strong>[{{ log.level }}]</strong> {{ log.step }} - {{ log.message }}
|
||||||
|
<span class="log-time">{{ log.createdAt }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 14621,
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
sdk/hdhive-openapi-sdk-python.zip
Normal file
BIN
sdk/hdhive-openapi-sdk-python.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user