feat: add remote API automation and service updates

This commit is contained in:
kelriclink committed 2026-03-09 15:03:46 +08:00
1 parent 4a67f773ac
commit 41481293c5
19 files changed
+1167 -195

No files matched your search

+5
View File
@@ -39,6 +39,11 @@ CELERY_PUB_CONCURRENCY=1
# Build with real ASR (faster-whisper)
INSTALL_ASR=1
# Optional: refresh yt-dlp at image build time.
# - latest: install newest release available when this layer is rebuilt
# - or pin a specific version, e.g. 2026.03.03
YTDLP_VERSION=latest
# Optional: allow browser origins (comma-separated)
CORS_ALLOW_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
+9
View File
@@ -12,6 +12,7 @@ RUN apt-get update \
COPY pyproject.toml README.md ./
ARG INSTALL_ASR=0
ARG YTDLP_VERSION=latest
# Install dependencies in a cache-friendly layer so editing source code doesn't
# force re-downloading everything on every docker build.
@@ -25,6 +26,14 @@ COPY docker ./docker
RUN pip install --no-cache-dir -e . --no-deps
RUN if [ -n "$YTDLP_VERSION" ]; then \
if [ "$YTDLP_VERSION" = "latest" ]; then \
pip install --no-cache-dir -U "yt-dlp[default]"; \
else \
pip install --no-cache-dir -U "yt-dlp[default]==${YTDLP_VERSION}"; \
fi; \
fi
RUN chmod +x /app/docker/entrypoint.sh
CMD ["/app/docker/entrypoint.sh"]
+1
View File
@@ -35,6 +35,7 @@ services:
context: .
args:
INSTALL_ASR: ${INSTALL_ASR:-1}
YTDLP_VERSION: ${YTDLP_VERSION:-latest}
image: videoroll:prod
environment:
DATABASE_URL: ${DATABASE_URL}
+1
View File
@@ -35,6 +35,7 @@ services:
context: .
args:
INSTALL_ASR: ${INSTALL_ASR:-1}
YTDLP_VERSION: ${YTDLP_VERSION:-latest}
image: videoroll:prod
environment:
DATABASE_URL: ${DATABASE_URL}
+79
View File
@@ -0,0 +1,79 @@
---
name: openclaw-videoroll
description: Collect YouTube video URLs on an external machine and submit them to VideoRoll through the remote auto-ingest API. Use when OpenClaw needs to find canonical YouTube watch links, deduplicate them, and push each URL to VideoRoll with the configured token so the project starts auto mode immediately.
---
# OpenClaw -> VideoRoll
## Overview
Use this skill only for one job: get valid YouTube video links and send them to VideoRoll's remote API.
## Required config
- `VIDEOROLL_REMOTE_URL`
Full remote endpoint URL. Usually:
`https://your-host/api/remote/auto/youtube`
- `VIDEOROLL_REMOTE_TOKEN`
Token configured in VideoRoll's `Settings · API`
- Optional: `VIDEOROLL_SOURCE_LICENSE`
Default `authorized`
- Optional: `VIDEOROLL_PROOF_URL`
Authorization proof link if needed
## Workflow
1. Collect candidate YouTube links from the requested source.
2. Normalize every link to a canonical watch URL:
`https://www.youtube.com/watch?v=<video_id>`
3. Drop non-video links, duplicates, shorts landing pages without a resolvable video id, and obviously broken URLs.
4. For each final URL, call VideoRoll remote API once.
5. Return the API result for each URL, especially:
`task_id`, `pipeline_job_id`, `deduped`, `source_id`
## API call
Send query parameters:
- `token`
- `url`
- Optional `license`
- Optional `proof_url`
Example:
```bash
curl -G "$VIDEOROLL_REMOTE_URL" \
--data-urlencode "token=$VIDEOROLL_REMOTE_TOKEN" \
--data-urlencode "url=https://www.youtube.com/watch?v=dQw4w9WgXcQ" \
--data-urlencode "license=${VIDEOROLL_SOURCE_LICENSE:-authorized}"
```
Success response shape:
```json
{
"task_id": "...",
"pipeline_job_id": "...",
"deduped": false,
"source_id": "..."
}
```
## Rules
- Only submit actual YouTube video URLs.
- Preserve the original video id exactly.
- Do not invent metadata that the API does not need.
- If the API returns `deduped=true`, report it instead of retrying.
- If token/auth fails, stop and surface the exact error.
- Do not attempt to bypass paywalls, private videos, or platform restrictions.
## Output format
When reporting back, list each processed URL with:
- normalized URL
- API status
- `task_id` if created
- `pipeline_job_id` if started
- whether it was deduped
@@ -0,0 +1,4 @@
interface:
display_name: "OpenClaw VideoRoll"
short_description: "Collect YouTube URLs and push them to VideoRoll"
default_prompt: "Use $openclaw-videoroll to collect YouTube watch URLs on an external machine and submit them to VideoRoll through the remote API."
@@ -2,8 +2,10 @@ from __future__ import annotations
import json
import logging
import os
import random
import tempfile
import threading
import traceback
import uuid
from datetime import datetime, timezone
@@ -31,6 +33,8 @@ from videoroll.apps.subtitle_service.translate_settings_store import get_transla
settings = get_bilibili_publisher_settings()
logger = logging.getLogger(__name__)
_DB_READY_LOCK = threading.Lock()
_DB_READY_PID: int | None = None
celery_app = Celery("bilibili_publisher", broker=settings.redis_url, backend=settings.redis_url)
celery_app.conf.update(
task_serializer="json",
@@ -47,9 +51,17 @@ def _db() -> Session:
def _ensure_db() -> None:
engine = get_engine(settings.database_url)
Base.metadata.create_all(engine)
auto_migrate(settings.database_url)
global _DB_READY_PID
pid = os.getpid()
if _DB_READY_PID == pid:
return
with _DB_READY_LOCK:
if _DB_READY_PID == pid:
return
engine = get_engine(settings.database_url)
Base.metadata.create_all(engine)
auto_migrate(settings.database_url)
_DB_READY_PID = pid
def _utcnow() -> datetime:
+407 -153
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import tempfile
import threading
@@ -18,6 +19,7 @@ from botocore.exceptions import ClientError
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, StreamingResponse
from starlette.concurrency import run_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from sqlalchemy import func
@@ -26,7 +28,7 @@ from sqlalchemy.orm import Session
from videoroll.config import OrchestratorSettings, get_orchestrator_settings
from videoroll.db.base import Base
from videoroll.db.auto_migrate import auto_migrate
from videoroll.db.models import AppSetting, Asset, AssetKind, PublishJob, Subtitle, SubtitleJob, SubtitleJobStatus, Task, TaskStatus
from videoroll.db.models import AppSetting, Asset, AssetKind, PublishJob, SourceLicense, Subtitle, SubtitleJob, SubtitleJobStatus, Task, TaskStatus
from videoroll.db.session import db_session, get_engine, get_sessionmaker
from videoroll.storage.s3 import S3Store
from videoroll.utils.internal_api_token import internal_api_token
@@ -41,6 +43,8 @@ from videoroll.apps.orchestrator_api.schemas import (
ConvertedVideoItem,
PublishActionRequest,
PublishJobSummary,
RemoteAPISettingsRead,
RemoteAPISettingsUpdate,
RemoteJobResponse,
RemotePublishResponse,
StorageRetentionSettingsRead,
@@ -70,6 +74,7 @@ from videoroll.apps.orchestrator_api.admin_auth_store import (
verify_password_hash,
)
from videoroll.apps.orchestrator_api.youtube_downloader import (
YtDlpRuntimeError,
download_youtube_video,
download_thumbnail_jpg,
extract_youtube_metadata,
@@ -79,6 +84,14 @@ from videoroll.apps.orchestrator_api.storage_retention_store import (
get_storage_retention_settings,
update_storage_retention_settings,
)
from videoroll.apps.orchestrator_api.remote_api_settings_store import (
REMOTE_API_TOKEN_QUERY_PARAM,
REMOTE_AUTO_YOUTUBE_PATH,
get_remote_api_settings,
remote_api_token_is_configured,
update_remote_api_settings,
verify_remote_api_token,
)
from videoroll.apps.subtitle_service.bilibili_tags_store import get_task_bilibili_tags
from videoroll.apps.subtitle_service.task_title_store import get_task_display_title_with_s3
from videoroll.apps.youtube_settings_store import (
@@ -89,6 +102,8 @@ from videoroll.apps.youtube_settings_store import (
update_youtube_settings,
)
logger = logging.getLogger(__name__)
def get_settings() -> OrchestratorSettings:
return get_orchestrator_settings()
@@ -99,9 +114,7 @@ def get_db(settings: OrchestratorSettings = Depends(get_settings)) -> Generator[
def get_s3(settings: OrchestratorSettings = Depends(get_settings)) -> S3Store:
store = S3Store(settings)
store.ensure_bucket()
return store
return S3Store(settings)
def _sha256_bytes(data: bytes) -> str:
@@ -300,6 +313,228 @@ def _write_s3_json(s3: S3Store, key: str, obj: dict[str, Any]) -> bytes:
return payload
def _write_s3_text(s3: S3Store, key: str, text: str) -> bytes:
payload = str(text).encode("utf-8")
s3.put_bytes(payload, key, content_type="text/plain; charset=utf-8")
return payload
def _store_task_log_asset(
db: Session,
s3: S3Store,
*,
task_id: uuid.UUID,
log_key: str,
text: str,
) -> Asset:
payload = _write_s3_text(s3, log_key, text)
asset = Asset(
task_id=task_id,
kind=AssetKind.log,
storage_key=log_key,
sha256=_sha256_bytes(payload),
size_bytes=len(payload),
)
db.add(asset)
db.commit()
db.refresh(asset)
return asset
def _build_youtube_download_failure_log(
*,
task_id: uuid.UUID,
url: str,
error_message: str,
hint: str | None,
diagnostics: list[str] | None,
) -> str:
body = "\n".join([str(line or "") for line in (diagnostics or [])]).strip()
sections = [
body or "videoroll yt-dlp diagnostics unavailable",
"\n".join(
[
"---- videoroll error summary ----",
f"task_id={task_id}",
f"url={url}",
f"error={error_message}",
]
),
]
hint_text = str(hint or "").strip()
if hint_text:
sections.append(f"---- videoroll hint ----\n{hint_text}")
return "\n\n".join(sections).rstrip() + "\n"
def _youtube_download_log_key(task_id: uuid.UUID) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"log/{task_id}/youtube_download_{stamp}_{uuid.uuid4().hex[:8]}.log"
def _extract_metadata_title(raw: bytes) -> str:
if not raw:
return ""
try:
parsed = json.loads(raw.decode("utf-8"))
except Exception:
return ""
info = parsed if isinstance(parsed, dict) else {}
return str(info.get("title") or info.get("fulltitle") or info.get("alt_title") or "").strip()
def _task_title_key(task_id: uuid.UUID) -> str:
return f"task.title.{task_id}"
def _load_task_display_titles(
db: Session,
task_ids: list[uuid.UUID],
*,
s3: S3Store | None = None,
allow_s3_fallback: bool,
) -> dict[uuid.UUID, str]:
title_map: dict[uuid.UUID, str] = {}
if not task_ids:
return title_map
rows = db.query(AppSetting).filter(AppSetting.key.in_([_task_title_key(tid) for tid in task_ids])).all()
by_key = {str(r.key): _as_dict(getattr(r, "value_json", None)) for r in rows}
for tid in task_ids:
data = by_key.get(_task_title_key(tid)) or {}
for key in ("translated_title", "source_title"):
value = data.get(key)
if isinstance(value, str) and value.strip():
title_map[tid] = value.strip()
break
if not allow_s3_fallback or s3 is None:
return title_map
missing = [tid for tid in task_ids if tid not in title_map]
if not missing:
return title_map
assets = (
db.query(Asset)
.filter(Asset.task_id.in_(missing), Asset.kind == AssetKind.metadata_json)
.order_by(Asset.created_at.desc())
.all()
)
picked: dict[uuid.UUID, Asset] = {}
for asset in assets:
if asset.task_id not in picked:
picked[asset.task_id] = asset
for tid, asset in picked.items():
try:
title = _extract_metadata_title(_read_s3_bytes(s3, asset.storage_key))
except Exception:
continue
if title:
title_map[tid] = title
return title_map
def _stream_upload_to_tempfile(
file_obj: Any,
*,
prefix: str,
suffix: str,
) -> tuple[Path, str, int]:
tmp_path: Path | None = None
sha256 = hashlib.sha256()
size_bytes = 0
try:
with tempfile.NamedTemporaryFile(prefix=prefix, suffix=suffix, delete=False) as tmp:
tmp_path = Path(tmp.name)
while True:
chunk = file_obj.read(1024 * 1024)
if not chunk:
break
if not isinstance(chunk, (bytes, bytearray)):
raise TypeError("uploaded file stream returned non-bytes content")
sha256.update(chunk)
size_bytes += len(chunk)
tmp.write(chunk)
return tmp_path, sha256.hexdigest(), size_bytes
except Exception:
if tmp_path is not None:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
raise
def _safe_unlink(path: Path | None) -> None:
if path is None:
return
try:
path.unlink(missing_ok=True)
except Exception:
pass
async def _store_uploaded_task_asset(
*,
task: Task,
file: UploadFile,
s3: S3Store,
db: Session,
temp_prefix: str,
default_suffix: str,
key_prefix: str,
object_name_prefix: str,
asset_kind: AssetKind,
update_task_status: TaskStatus | None = None,
) -> Asset:
suffix = Path(file.filename or "").suffix or default_suffix
tmp_path: Path | None = None
uploaded_key: str | None = None
try:
await file.seek(0)
tmp_path, sha256, size_bytes = await run_in_threadpool(
_stream_upload_to_tempfile,
file.file,
prefix=temp_prefix,
suffix=suffix,
)
uploaded_key = f"{key_prefix}/{task.id}/{object_name_prefix}_{sha256[:16]}{suffix}"
await run_in_threadpool(s3.upload_file, tmp_path, uploaded_key, file.content_type or None)
asset = Asset(
task_id=task.id,
kind=asset_kind,
storage_key=uploaded_key,
sha256=sha256,
size_bytes=size_bytes,
)
db.add(asset)
if update_task_status is not None:
task.status = update_task_status
db.add(task)
db.commit()
db.refresh(asset)
return asset
except HTTPException:
raise
except Exception as e:
db.rollback()
if uploaded_key:
try:
await run_in_threadpool(s3.delete_object, uploaded_key)
except Exception:
logger.exception("failed to roll back uploaded S3 object", extra={"storage_key": uploaded_key})
raise HTTPException(status_code=500, detail=f"upload failed: {e}") from e
finally:
await run_in_threadpool(_safe_unlink, tmp_path)
try:
await file.close()
except Exception:
pass
def _internal_header_token(settings: OrchestratorSettings) -> str:
return internal_api_token(settings.s3_secret_access_key)
@@ -334,6 +569,8 @@ class _AdminAuthMiddleware(BaseHTTPMiddleware):
return await call_next(request)
if path.startswith("/auth"):
return await call_next(request)
if path.rstrip("/") == REMOTE_AUTO_YOUTUBE_PATH:
return await call_next(request)
pw_hash = _get_admin_password_hash(request)
if not pw_hash:
@@ -422,48 +659,59 @@ def _cleanup_storage_once(settings: OrchestratorSettings) -> dict[str, int]:
cutoff = _utcnow() - timedelta(days=ttl_days)
store = S3Store(settings)
store.ensure_bucket()
deleted_objects = 0
deleted_assets = 0
deleted_subtitles = 0
failed_keys: set[str] = set()
# 1) Delete fully-expired keys (no Asset rows >= cutoff).
while True:
rows = (
db.query(Asset.storage_key)
.group_by(Asset.storage_key)
.having(func.max(Asset.created_at) < cutoff)
.limit(200)
.all()
)
query = db.query(Asset.storage_key).group_by(Asset.storage_key).having(func.max(Asset.created_at) < cutoff)
if failed_keys:
query = query.filter(~Asset.storage_key.in_(failed_keys))
rows = query.limit(200).all()
keys = [r[0] for r in rows if r and r[0]]
if not keys:
break
deleted_keys: list[str] = []
for key in keys:
try:
store.delete_object(key)
deleted_objects += 1
deleted_keys.append(key)
except Exception:
# Best-effort: still delete DB rows to honor TTL.
pass
failed_keys.add(key)
logger.exception("failed to delete expired S3 object", extra={"storage_key": key})
deleted_assets += (
db.query(Asset)
.filter(Asset.storage_key.in_(keys))
.delete(synchronize_session=False)
)
deleted_subtitles += (
db.query(Subtitle)
.filter(Subtitle.storage_key.in_(keys))
.delete(synchronize_session=False)
if not deleted_keys:
continue
deleted_assets += db.query(Asset).filter(Asset.storage_key.in_(deleted_keys)).delete(synchronize_session=False)
deleted_subtitles += db.query(Subtitle).filter(Subtitle.storage_key.in_(deleted_keys)).delete(
synchronize_session=False
)
db.commit()
# 2) Delete old duplicate rows for keys that still exist (object kept).
deleted_assets += db.query(Asset).filter(Asset.created_at < cutoff).delete(synchronize_session=False)
deleted_subtitles += db.query(Subtitle).filter(Subtitle.created_at < cutoff).delete(synchronize_session=False)
# 2) Delete old duplicate rows only for keys that still have a newer Asset row.
active_key_subq = (
db.query(Asset.storage_key.label("storage_key"))
.group_by(Asset.storage_key)
.having(func.max(Asset.created_at) >= cutoff)
.subquery()
)
active_key_query = db.query(active_key_subq.c.storage_key)
deleted_assets += (
db.query(Asset)
.filter(Asset.created_at < cutoff, Asset.storage_key.in_(active_key_query))
.delete(synchronize_session=False)
)
deleted_subtitles += (
db.query(Subtitle)
.filter(Subtitle.created_at < cutoff, Subtitle.storage_key.in_(active_key_query))
.delete(synchronize_session=False)
)
db.commit()
return {
@@ -481,7 +729,7 @@ def _cleanup_loop() -> None:
try:
_cleanup_storage_once(settings)
except Exception:
pass
logger.exception("storage cleanup loop failed")
_cleanup_stop.wait(timeout=max(30, _cleanup_interval_seconds))
@@ -694,22 +942,25 @@ def auth_logout(request: Request, db: Session = Depends(get_db)) -> JSONResponse
return resp
@app.post("/auto/youtube", response_model=AutoYouTubeResponse)
def auto_youtube(
payload: AutoYouTubeRequest,
settings: OrchestratorSettings = Depends(get_settings),
def _start_auto_youtube_pipeline(
*,
url: str,
license: SourceLicense,
proof_url: str | None,
settings: OrchestratorSettings,
) -> AutoYouTubeResponse:
url = (payload.url or "").strip()
url = str(url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="url is required")
if not _is_youtube_url(url):
raise HTTPException(status_code=400, detail="url is not a valid youtube url")
proof = str(proof_url or "").strip() or None
try:
with httpx.Client(timeout=30.0, headers=_internal_http_headers(settings)) as client:
resp = client.post(
f"{settings.youtube_ingest_url}/youtube/ingest",
json={"url": url, "license": payload.license.value, "proof_url": payload.proof_url},
json={"url": url, "license": license.value, "proof_url": proof},
)
resp.raise_for_status()
data = resp.json()
@@ -734,6 +985,41 @@ def auto_youtube(
)
@app.post("/auto/youtube", response_model=AutoYouTubeResponse)
def auto_youtube(
payload: AutoYouTubeRequest,
settings: OrchestratorSettings = Depends(get_settings),
) -> AutoYouTubeResponse:
return _start_auto_youtube_pipeline(
url=payload.url,
license=payload.license,
proof_url=payload.proof_url,
settings=settings,
)
@app.get(REMOTE_AUTO_YOUTUBE_PATH, response_model=AutoYouTubeResponse)
@app.post(REMOTE_AUTO_YOUTUBE_PATH, response_model=AutoYouTubeResponse)
def remote_auto_youtube(
url: str | None = Query(default=None),
token: str | None = Query(default=None, alias=REMOTE_API_TOKEN_QUERY_PARAM),
license: SourceLicense = Query(default=SourceLicense.authorized),
proof_url: str | None = Query(default=None),
settings: OrchestratorSettings = Depends(get_settings),
db: Session = Depends(get_db),
) -> AutoYouTubeResponse:
if not remote_api_token_is_configured(db):
raise HTTPException(status_code=403, detail="remote api token is not set")
if not verify_remote_api_token(db, str(token or "")):
raise HTTPException(status_code=401, detail="invalid remote api token")
return _start_auto_youtube_pipeline(
url=str(url or ""),
license=license,
proof_url=proof_url,
settings=settings,
)
@app.post("/tasks", response_model=TaskRead)
def create_task(payload: TaskCreate, db: Session = Depends(get_db)) -> Task:
task = Task(
@@ -755,7 +1041,6 @@ def create_task(payload: TaskCreate, db: Session = Depends(get_db)) -> Task:
def list_converted_videos(
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
s3: S3Store = Depends(get_s3),
) -> list[dict[str, Any]]:
# Fetch recent video_final assets and dedupe by task_id (keep latest per task).
fetch_n = min(1000, max(limit, 1) * 5)
@@ -767,25 +1052,48 @@ def list_converted_videos(
.all()
)
items: list[dict[str, Any]] = []
final_assets: list[Asset] = []
seen: set[uuid.UUID] = set()
for a in assets:
if a.task_id in seen:
for asset in assets:
if asset.task_id in seen:
continue
seen.add(a.task_id)
task = db.get(Task, a.task_id)
seen.add(asset.task_id)
final_assets.append(asset)
if len(final_assets) >= limit:
break
task_ids = [asset.task_id for asset in final_assets]
if not task_ids:
return []
task_map = {task.id: task for task in db.query(Task).filter(Task.id.in_(task_ids)).all()}
cover_assets = (
db.query(Asset)
.filter(Asset.task_id.in_(task_ids), Asset.kind == AssetKind.cover_image)
.order_by(Asset.created_at.desc())
.all()
)
cover_by_task: dict[uuid.UUID, Asset] = {}
for asset in cover_assets:
if asset.task_id not in cover_by_task:
cover_by_task[asset.task_id] = asset
title_map = _load_task_display_titles(db, task_ids, allow_s3_fallback=False)
items: list[dict[str, Any]] = []
for asset in final_assets:
task = task_map.get(asset.task_id)
if not task:
continue
cover_asset = (
db.query(Asset)
.filter(Asset.task_id == a.task_id, Asset.kind == AssetKind.cover_image)
.order_by(Asset.created_at.desc())
.first()
display_title = str(title_map.get(asset.task_id) or "").strip()
items.append(
{
"task": task,
"final_asset": asset,
"cover_asset": cover_by_task.get(asset.task_id),
"display_title": display_title or None,
}
)
display_title = get_task_display_title_with_s3(db, str(a.task_id), s3=s3)
items.append({"task": task, "final_asset": a, "cover_asset": cover_asset, "display_title": display_title or None})
if len(items) >= limit:
break
return items
@@ -796,55 +1104,13 @@ def list_tasks(
db: Session = Depends(get_db),
s3: S3Store = Depends(get_s3),
) -> list[dict[str, Any]]:
def _title_key(tid: uuid.UUID) -> str:
return f"task.title.{tid}"
q = db.query(Task).order_by(Task.created_at.desc())
if status is not None:
q = q.filter(Task.status == status)
tasks = q.limit(limit).all()
ids = [t.id for t in tasks]
# Batch load translated/source titles from AppSetting first, then fall back to YouTube meta title.
title_map: dict[uuid.UUID, str] = {}
if ids:
keys = [_title_key(tid) for tid in ids]
rows = db.query(AppSetting).filter(AppSetting.key.in_(keys)).all()
by_key: dict[str, dict[str, Any]] = {}
for r in rows:
by_key[str(r.key)] = _as_dict(getattr(r, "value_json", None))
for tid in ids:
data = by_key.get(_title_key(tid)) or {}
for k in ("translated_title", "source_title"):
v = data.get(k)
if isinstance(v, str) and v.strip():
title_map[tid] = v.strip()
break
missing = [tid for tid in ids if tid not in title_map]
if missing:
assets = (
db.query(Asset)
.filter(Asset.task_id.in_(missing), Asset.kind == AssetKind.metadata_json)
.order_by(Asset.created_at.desc())
.all()
)
picked: dict[uuid.UUID, Asset] = {}
for a in assets:
if a.task_id not in picked:
picked[a.task_id] = a
for tid, asset in picked.items():
try:
raw = _read_s3_bytes(s3, asset.storage_key)
parsed = json.loads(raw.decode("utf-8")) if raw else {}
info = parsed if isinstance(parsed, dict) else {}
except Exception:
continue
title = str(info.get("title") or info.get("fulltitle") or info.get("alt_title") or "").strip()
if title:
title_map[tid] = title
title_map = _load_task_display_titles(db, ids, s3=s3, allow_s3_fallback=True)
out: list[dict[str, Any]] = []
for t in tasks:
@@ -870,6 +1136,17 @@ def put_storage_settings(payload: StorageRetentionSettingsUpdate, db: Session =
return StorageRetentionSettingsRead(**cfg)
@app.get("/settings/api", response_model=RemoteAPISettingsRead)
def get_remote_api_settings_view(db: Session = Depends(get_db)) -> RemoteAPISettingsRead:
return RemoteAPISettingsRead(**get_remote_api_settings(db))
@app.put("/settings/api", response_model=RemoteAPISettingsRead)
def put_remote_api_settings_view(payload: RemoteAPISettingsUpdate, db: Session = Depends(get_db)) -> RemoteAPISettingsRead:
cfg = update_remote_api_settings(db, payload.model_dump(exclude_unset=True))
return RemoteAPISettingsRead(**cfg)
@app.get("/settings/youtube", response_model=YouTubeSettingsRead)
def get_youtube_settings_view(settings: OrchestratorSettings = Depends(get_settings), db: Session = Depends(get_db)) -> YouTubeSettingsRead:
cfg = get_youtube_settings(db, default_proxy=settings.youtube_proxy)
@@ -973,39 +1250,18 @@ async def upload_task_video(
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="task not found")
suffix = Path(file.filename or "").suffix or ".mp4"
with tempfile.NamedTemporaryFile(prefix="videoroll_", suffix=suffix, delete=False) as tmp:
tmp_path = Path(tmp.name)
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
tmp.write(chunk)
sha256 = sha256_file(tmp_path)
size_bytes = tmp_path.stat().st_size
key = f"raw/{task_id}/video_{sha256[:16]}{suffix}"
s3.upload_file(tmp_path, key)
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
asset = Asset(
task_id=task_id,
kind=AssetKind.video_raw,
storage_key=key,
sha256=sha256,
size_bytes=size_bytes,
return await _store_uploaded_task_asset(
task=task,
file=file,
s3=s3,
db=db,
temp_prefix="videoroll_",
default_suffix=".mp4",
key_prefix="raw",
object_name_prefix="video",
asset_kind=AssetKind.video_raw,
update_task_status=TaskStatus.downloaded,
)
db.add(asset)
task.status = TaskStatus.downloaded
db.add(task)
db.commit()
db.refresh(asset)
return asset
@app.post("/tasks/{task_id}/upload/cover", response_model=AssetRead)
@@ -1021,37 +1277,17 @@ async def upload_task_cover(
if file.content_type and not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="cover must be an image")
suffix = Path(file.filename or "").suffix or ".jpg"
with tempfile.NamedTemporaryFile(prefix="videoroll_cover_", suffix=suffix, delete=False) as tmp:
tmp_path = Path(tmp.name)
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
tmp.write(chunk)
sha256 = sha256_file(tmp_path)
size_bytes = tmp_path.stat().st_size
key = f"final/{task_id}/cover_{sha256[:16]}{suffix}"
s3.upload_file(tmp_path, key)
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
asset = Asset(
task_id=task_id,
kind=AssetKind.cover_image,
storage_key=key,
sha256=sha256,
size_bytes=size_bytes,
return await _store_uploaded_task_asset(
task=task,
file=file,
s3=s3,
db=db,
temp_prefix="videoroll_cover_",
default_suffix=".jpg",
key_prefix="final",
object_name_prefix="cover",
asset_kind=AssetKind.cover_image,
)
db.add(asset)
db.commit()
db.refresh(asset)
return asset
@app.get("/tasks/{task_id}/assets", response_model=list[AssetRead])
@@ -1376,6 +1612,24 @@ def download_youtube(
video_path, info, meta = download_youtube_video(url, yt_settings, work_dir=tmp_dir)
except Exception as e:
hint = _youtube_bot_check_hint(str(e), yt_settings=yt_settings, db=db)
diagnostics = e.diagnostics if isinstance(e, YtDlpRuntimeError) else []
try:
_store_task_log_asset(
db,
s3,
task_id=task_id,
log_key=_youtube_download_log_key(task_id),
text=_build_youtube_download_failure_log(
task_id=task_id,
url=url,
error_message=str(e),
hint=hint,
diagnostics=diagnostics,
),
)
except Exception:
db.rollback()
logger.exception("failed to persist youtube download diagnostics log for task %s", task_id)
detail = f"youtube download failed: {e}"
if hint:
detail = f"{detail}\n\n{hint}"
@@ -0,0 +1,111 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from videoroll.apps.orchestrator_api.admin_auth_store import encode_password_hash, verify_password_hash
from videoroll.db.models import AppSetting
REMOTE_API_SETTINGS_KEY = "remote.api"
REMOTE_AUTO_YOUTUBE_PATH = "/remote/auto/youtube"
REMOTE_API_TOKEN_QUERY_PARAM = "token"
REMOTE_AUTO_URL_QUERY_PARAM = "url"
REMOTE_AUTO_LICENSE_QUERY_PARAM = "license"
REMOTE_AUTO_PROOF_URL_QUERY_PARAM = "proof_url"
_MIN_TOKEN_LEN = 8
_MAX_TOKEN_LEN = 512
def _as_dict(v: Any) -> dict[str, Any]:
return v if isinstance(v, dict) else {}
def _get_row(db: Session) -> AppSetting:
row = db.get(AppSetting, REMOTE_API_SETTINGS_KEY)
if row:
return row
row = AppSetting(key=REMOTE_API_SETTINGS_KEY, value_json={})
db.add(row)
try:
db.commit()
db.refresh(row)
except IntegrityError:
db.rollback()
row2 = db.get(AppSetting, REMOTE_API_SETTINGS_KEY)
if row2:
return row2
raise
return row
def _normalize_token(token: Any) -> str:
value = str(token or "").strip()
if not value:
return ""
if len(value) < _MIN_TOKEN_LEN:
raise ValueError(f"token too short (min {_MIN_TOKEN_LEN} chars)")
if len(value) > _MAX_TOKEN_LEN:
raise ValueError(f"token too long (max {_MAX_TOKEN_LEN} chars)")
return value
def get_remote_api_settings(db: Session) -> dict[str, Any]:
row = db.get(AppSetting, REMOTE_API_SETTINGS_KEY)
stored = dict(_as_dict(row.value_json)) if row else {}
token_hash = str(stored.get("token_hash") or "").strip()
token_updated_at = str(stored.get("token_updated_at") or "").strip() or None
return {
"token_set": bool(token_hash),
"token_updated_at": token_updated_at,
"endpoint_path": REMOTE_AUTO_YOUTUBE_PATH,
"token_query_param": REMOTE_API_TOKEN_QUERY_PARAM,
"url_query_param": REMOTE_AUTO_URL_QUERY_PARAM,
"license_query_param": REMOTE_AUTO_LICENSE_QUERY_PARAM,
"proof_url_query_param": REMOTE_AUTO_PROOF_URL_QUERY_PARAM,
}
def update_remote_api_settings(db: Session, update: dict[str, Any]) -> dict[str, Any]:
row = _get_row(db)
stored = dict(_as_dict(row.value_json))
if "token" in update:
token_raw = update.get("token")
if token_raw is not None:
token = _normalize_token(token_raw)
if token:
stored["token_hash"] = encode_password_hash(token)
stored["token_updated_at"] = datetime.now(timezone.utc).isoformat()
else:
stored.pop("token_hash", None)
stored.pop("token_updated_at", None)
row.value_json = stored
db.add(row)
db.commit()
return get_remote_api_settings(db)
def remote_api_token_is_configured(db: Session) -> bool:
row = db.get(AppSetting, REMOTE_API_SETTINGS_KEY)
stored = dict(_as_dict(row.value_json)) if row else {}
return bool(str(stored.get("token_hash") or "").strip())
def verify_remote_api_token(db: Session, token: str) -> bool:
value = str(token or "").strip()
if not value:
return False
row = db.get(AppSetting, REMOTE_API_SETTINGS_KEY)
stored = dict(_as_dict(row.value_json)) if row else {}
token_hash = str(stored.get("token_hash") or "").strip()
if not token_hash:
return False
return verify_password_hash(value, token_hash)
@@ -188,6 +188,20 @@ class StorageRetentionSettingsUpdate(BaseModel):
asset_ttl_days: Optional[int] = None
class RemoteAPISettingsRead(BaseModel):
token_set: bool = False
token_updated_at: Optional[str] = None
endpoint_path: str = "/remote/auto/youtube"
token_query_param: str = "token"
url_query_param: str = "url"
license_query_param: str = "license"
proof_url_query_param: str = "proof_url"
class RemoteAPISettingsUpdate(BaseModel):
token: Optional[str] = None
class YouTubeSettingsRead(BaseModel):
proxy: str = ""
cookies_set: bool = False
@@ -6,7 +6,7 @@ import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse
from urllib.parse import urlparse, urlsplit, urlunsplit
import httpx
import yt_dlp
@@ -25,6 +25,12 @@ class YouTubeMeta:
duration: Optional[int] = None
class YtDlpRuntimeError(RuntimeError):
def __init__(self, message: str, *, diagnostics: list[str] | None = None) -> None:
super().__init__(message)
self.diagnostics = diagnostics or []
def _run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True)
@@ -66,10 +72,85 @@ def _extractor_args(settings: OrchestratorSettings) -> dict[str, Any] | None:
return parsed
def build_ydl_opts(settings: OrchestratorSettings, *, outtmpl: str | None = None, for_download: bool) -> dict[str, Any]:
def _redact_url(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
try:
parts = urlsplit(raw)
except Exception:
return raw
hostname = parts.hostname or ""
if not hostname:
return raw
auth = ""
if parts.username:
auth = parts.username
if parts.password is not None:
auth += ":***"
auth += "@"
netloc = auth + hostname
if parts.port is not None:
netloc += f":{parts.port}"
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
def _sanitize_diagnostic_line(line: str, *, proxy: str | None) -> str:
text = str(line or "").rstrip()
if proxy:
text = text.replace(proxy, _redact_url(proxy))
return text
def _yt_dlp_diagnostics_header(
*,
url: str,
settings: OrchestratorSettings,
outtmpl: str | None,
) -> list[str]:
extractor_args = _extractor_args(settings)
return [
"videoroll yt-dlp diagnostics",
f"url={url}",
f"proxy={_redact_url(str(settings.youtube_proxy or '').strip()) or '(none)'}",
f"cookiefile={(str(settings.youtube_cookie_file or '').strip() or '(none)')}",
f"ffmpeg_location={(str(settings.ffmpeg_path or '').strip() or '(default PATH)')}",
f"outtmpl={outtmpl or '(none)'}",
f"extractor_args={json.dumps(extractor_args, ensure_ascii=False, sort_keys=True) if extractor_args else '(none)'}",
"---- yt-dlp verbose ----",
]
class _DiagnosticsLogger:
def __init__(self, diagnostics: list[str], *, proxy: str | None) -> None:
self._diagnostics = diagnostics
self._proxy = str(proxy or "").strip() or None
def debug(self, msg: str) -> None:
self._diagnostics.append(_sanitize_diagnostic_line(f"[debug] {msg}", proxy=self._proxy))
def info(self, msg: str) -> None:
self._diagnostics.append(_sanitize_diagnostic_line(str(msg), proxy=self._proxy))
def warning(self, msg: str) -> None:
self._diagnostics.append(_sanitize_diagnostic_line(f"WARNING: {msg}", proxy=self._proxy))
def error(self, msg: str) -> None:
self._diagnostics.append(_sanitize_diagnostic_line(f"ERROR: {msg}", proxy=self._proxy))
def build_ydl_opts(
settings: OrchestratorSettings,
*,
outtmpl: str | None = None,
for_download: bool,
logger: Any | None = None,
verbose: bool = False,
) -> dict[str, Any]:
opts: dict[str, Any] = {
"quiet": True,
"no_warnings": True,
"ignoreconfig": True,
"noplaylist": True,
"retries": 3,
"fragment_retries": 3,
@@ -103,6 +184,10 @@ def build_ydl_opts(settings: OrchestratorSettings, *, outtmpl: str | None = None
if outtmpl:
opts["outtmpl"] = outtmpl
if logger is not None:
opts["logger"] = logger
if verbose:
opts["verbose"] = True
return opts
@@ -284,10 +369,20 @@ def _resolve_downloaded_file(info: dict[str, Any], *, work_dir: Path) -> Path:
raise RuntimeError("yt-dlp download succeeded but no output file was found")
def _download_once(url: str, settings: OrchestratorSettings, *, work_dir: Path) -> tuple[Path, dict[str, Any], YouTubeMeta]:
def _download_once(
url: str,
settings: OrchestratorSettings,
*,
work_dir: Path,
diagnostics: list[str] | None = None,
) -> tuple[Path, dict[str, Any], YouTubeMeta]:
work_dir.mkdir(parents=True, exist_ok=True)
outtmpl = str(work_dir / "%(id)s.%(ext)s")
with yt_dlp.YoutubeDL(build_ydl_opts(settings, outtmpl=outtmpl, for_download=True)) as ydl:
ydl_logger = None
if diagnostics is not None:
diagnostics.extend(_yt_dlp_diagnostics_header(url=url, settings=settings, outtmpl=outtmpl))
ydl_logger = _DiagnosticsLogger(diagnostics, proxy=settings.youtube_proxy)
with yt_dlp.YoutubeDL(build_ydl_opts(settings, outtmpl=outtmpl, for_download=True, logger=ydl_logger, verbose=diagnostics is not None)) as ydl:
info_raw = ydl.extract_info(url, download=True)
info = _pick_video_info(info_raw)
if not info:
@@ -306,9 +401,10 @@ def download_youtube_video(url: str, settings: OrchestratorSettings, *, work_dir
- sanitized info dict
- meta summary
"""
diagnostics: list[str] = []
try:
return _download_once(url, settings, work_dir=work_dir)
return _download_once(url, settings, work_dir=work_dir, diagnostics=diagnostics)
except DownloadError as e:
raise RuntimeError(str(e)) from e
raise YtDlpRuntimeError(str(e), diagnostics=diagnostics) from e
except Exception as e:
raise RuntimeError(str(e)) from e
raise YtDlpRuntimeError(str(e), diagnostics=diagnostics) from e
+82 -20
View File
@@ -632,8 +632,29 @@ def _read_task_queue(db: Session, *, limit: int) -> TaskQueueRead:
)
unlocked = (Task.lock_owner != TASK_QUEUE_LOCK_OWNER) | (Task.lock_until.is_(None)) | (Task.lock_until <= now)
# queued_count: distinct tasks with queued subtitle/render jobs (best-effort, capped).
queued_task_ids: set[uuid.UUID] = set()
orphaned_render_by_task: dict[uuid.UUID, RenderJob] = {}
orphaned_subtitle_by_task: dict[uuid.UUID, SubtitleJob] = {}
for rj in (
db.query(RenderJob)
.join(Task, Task.id == RenderJob.task_id)
.filter(RenderJob.status == RenderJobStatus.running, unlocked)
.order_by(RenderJob.updated_at.asc(), RenderJob.created_at.asc())
.limit(5000)
.all()
):
orphaned_render_by_task.setdefault(rj.task_id, rj)
for sj in (
db.query(SubtitleJob)
.join(Task, Task.id == SubtitleJob.task_id)
.filter(SubtitleJob.status == SubtitleJobStatus.running, unlocked)
.order_by(SubtitleJob.updated_at.asc(), SubtitleJob.created_at.asc())
.limit(5000)
.all()
):
orphaned_subtitle_by_task.setdefault(sj.task_id, sj)
# queued_count: distinct tasks with queued jobs, plus orphaned running jobs that lost their task lock.
queued_task_ids: set[uuid.UUID] = set(orphaned_render_by_task) | set(orphaned_subtitle_by_task)
for tid, in (
db.query(SubtitleJob.task_id)
.join(Task, Task.id == SubtitleJob.task_id)
@@ -659,33 +680,74 @@ def _read_task_queue(db: Session, *, limit: int) -> TaskQueueRead:
queued_items: list[TaskQueueItemRead] = []
seen: set[uuid.UUID] = set()
fetch_n = min(5000, max(50, remaining * 20))
for sj in (
db.query(SubtitleJob)
.join(Task, Task.id == SubtitleJob.task_id)
.filter(SubtitleJob.status == SubtitleJobStatus.queued, unlocked)
.order_by(SubtitleJob.created_at.asc())
.limit(fetch_n)
.all()
):
if sj.task_id in seen:
for rj in orphaned_render_by_task.values():
if rj.task_id in seen:
continue
seen.add(sj.task_id)
seen.add(rj.task_id)
queued_items.append(
TaskQueueItemRead(
task_id=sj.task_id,
task_id=rj.task_id,
state="queued",
stage="subtitle",
subtitle_job_id=sj.id,
progress=int(sj.progress or 0),
error_message=sj.error_message,
created_at=sj.created_at,
updated_at=sj.updated_at,
stage="recover_render",
render_job_id=rj.id,
subtitle_job_id=rj.subtitle_job_id,
progress=int(rj.progress or 0),
error_message=rj.error_message,
created_at=rj.created_at,
updated_at=rj.updated_at,
)
)
if len(queued_items) >= remaining:
break
if len(queued_items) < remaining:
for sj in orphaned_subtitle_by_task.values():
if sj.task_id in seen:
continue
seen.add(sj.task_id)
queued_items.append(
TaskQueueItemRead(
task_id=sj.task_id,
state="queued",
stage="recover_subtitle",
subtitle_job_id=sj.id,
progress=int(sj.progress or 0),
error_message=sj.error_message,
created_at=sj.created_at,
updated_at=sj.updated_at,
)
)
if len(queued_items) >= remaining:
break
fetch_n = min(5000, max(50, remaining * 20))
if len(queued_items) < remaining:
for sj in (
db.query(SubtitleJob)
.join(Task, Task.id == SubtitleJob.task_id)
.filter(SubtitleJob.status == SubtitleJobStatus.queued, unlocked)
.order_by(SubtitleJob.created_at.asc())
.limit(fetch_n)
.all()
):
if sj.task_id in seen:
continue
seen.add(sj.task_id)
queued_items.append(
TaskQueueItemRead(
task_id=sj.task_id,
state="queued",
stage="subtitle",
subtitle_job_id=sj.id,
progress=int(sj.progress or 0),
error_message=sj.error_message,
created_at=sj.created_at,
updated_at=sj.updated_at,
)
)
if len(queued_items) >= remaining:
break
if len(queued_items) < remaining:
for rj in (
db.query(RenderJob)
+73 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import logging
import os
import re
import shutil
import threading
@@ -70,6 +71,8 @@ settings = get_subtitle_settings()
_ORCH_INTERNAL_TOKEN = internal_api_token(settings.s3_secret_access_key)
_ORCH_INTERNAL_HEADERS = {INTERNAL_TOKEN_HEADER: _ORCH_INTERNAL_TOKEN} if _ORCH_INTERNAL_TOKEN else {}
logger = logging.getLogger(__name__)
_DB_READY_LOCK = threading.Lock()
_DB_READY_PID: int | None = None
celery_app = Celery("subtitle_service", broker=settings.redis_url, backend=settings.redis_url)
celery_app.conf.update(
task_serializer="json",
@@ -144,9 +147,17 @@ def _db() -> Session:
def _ensure_db() -> None:
engine = get_engine(settings.database_url)
Base.metadata.create_all(engine)
auto_migrate(settings.database_url)
global _DB_READY_PID
pid = os.getpid()
if _DB_READY_PID == pid:
return
with _DB_READY_LOCK:
if _DB_READY_PID == pid:
return
engine = get_engine(settings.database_url)
Base.metadata.create_all(engine)
auto_migrate(settings.database_url)
_DB_READY_PID = pid
def _now() -> datetime:
@@ -173,6 +184,18 @@ def _task_queue_unlock(task: Task) -> None:
task.lock_until = None
def _task_queue_join_message(message: str | None, detail: str, *, limit: int = 2000) -> str:
head = str(message or "").strip()
tail = str(detail or "").strip()
if head and tail:
out = f"{head}\n{tail}"
else:
out = head or tail
if len(out) > limit:
out = out[: limit - 1] + "…"
return out
def _task_queue_lock_settings_row(db: Session) -> None:
"""
Serialize queue ticks by locking the settings row.
@@ -1178,6 +1201,53 @@ def task_queue_tick() -> dict[str, Any]:
except Exception:
unlocked_expired = 0
unlocked = or_(
Task.lock_owner != TASK_QUEUE_LOCK_OWNER,
Task.lock_until.is_(None),
Task.lock_until <= now,
)
# Recover jobs that were actively running but lost their task slot/heartbeat.
# This is the "ghost running" case: task disappears from the queue while the job row
# still says running forever.
orphaned_subtitle = (
db.query(SubtitleJob)
.join(Task, Task.id == SubtitleJob.task_id)
.filter(SubtitleJob.status == SubtitleJobStatus.running, unlocked)
.order_by(SubtitleJob.updated_at.asc(), SubtitleJob.created_at.asc())
.with_for_update(skip_locked=True)
.all()
)
for j in orphaned_subtitle:
detail = "检测到任务队列锁已丢失:subtitle job 已自动按 resume 方式重新排队。"
req = dict(j.request_json) if isinstance(j.request_json, dict) else {}
req["resume"] = True
j.request_json = req
j.error_message = _task_queue_join_message(j.error_message, detail)
j.status = SubtitleJobStatus.queued
j.progress = 0
db.add(j)
recovered_subtitle += 1
orphaned_render = (
db.query(RenderJob)
.join(Task, Task.id == RenderJob.task_id)
.filter(RenderJob.status == RenderJobStatus.running, unlocked)
.order_by(RenderJob.updated_at.asc(), RenderJob.created_at.asc())
.with_for_update(skip_locked=True)
.all()
)
for j in orphaned_render:
detail = "检测到任务队列锁已丢失:render job 已自动重新排队。"
j.error_message = _task_queue_join_message(j.error_message, detail)
j.retry_count = int(j.retry_count or 0) + 1
j.status = RenderJobStatus.queued
j.progress = 0
j.started_at = None
j.finished_at = None
db.add(j)
recovered_render += 1
# Recover render jobs that were marked as running by the scheduler but never claimed by a worker.
cutoff = now - timedelta(seconds=60)
# Subtitle jobs: scheduler sets progress=1; worker bumps it to >=2 once claimed.
+14 -3
View File
@@ -4,13 +4,25 @@ from functools import lru_cache
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.orm import Session, sessionmaker
def _engine_connect_args(database_url: str) -> dict[str, object]:
try:
url = make_url(database_url)
except Exception:
return {}
if url.get_backend_name() == "postgresql" and url.get_driver_name() == "psycopg":
# PgBouncer/transaction-pooling setups can fail with
# "prepared statement already exists" when psycopg auto-prepares queries.
return {"prepare_threshold": None}
return {}
@lru_cache
def get_engine(database_url: str) -> Engine:
return create_engine(database_url, pool_pre_ping=True)
return create_engine(database_url, pool_pre_ping=True, connect_args=_engine_connect_args(database_url))
def get_sessionmaker(database_url: str) -> sessionmaker[Session]:
@@ -25,4 +37,3 @@ def db_session(database_url: str) -> Generator[Session, None, None]:
yield db
finally:
db.close()
+22 -2
View File
@@ -42,11 +42,31 @@ class S3Store:
def bucket(self) -> str:
return self._bucket
@staticmethod
def _is_missing_bucket_error(exc: ClientError) -> bool:
err = exc.response.get("Error") or {}
code = str(err.get("Code") or "").strip()
status = int((exc.response.get("ResponseMetadata") or {}).get("HTTPStatusCode") or 0)
return code in {"404", "NoSuchBucket", "NotFound"} or status == 404
@staticmethod
def _is_bucket_already_exists_error(exc: ClientError) -> bool:
err = exc.response.get("Error") or {}
code = str(err.get("Code") or "").strip()
return code in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}
def ensure_bucket(self) -> None:
try:
self._client.head_bucket(Bucket=self._bucket)
except ClientError:
self._client.create_bucket(Bucket=self._bucket)
except ClientError as e:
if not self._is_missing_bucket_error(e):
raise
try:
self._client.create_bucket(Bucket=self._bucket)
except ClientError as create_error:
if self._is_bucket_already_exists_error(create_error):
return
raise
def upload_file(self, path: Path, key: str, content_type: Optional[str] = None) -> PutResult:
if content_type is None:
+3
View File
@@ -12,6 +12,7 @@ import YouTubeSourcesPage from "./pages/YouTubeSourcesPage";
import SettingsASRPage from "./pages/SettingsASRPage";
import SettingsYouTubePage from "./pages/SettingsYouTubePage";
import SettingsStoragePage from "./pages/SettingsStoragePage";
import SettingsApiPage from "./pages/SettingsApiPage";
import SettingsTranslatePage from "./pages/SettingsTranslatePage";
import SettingsBilibiliPage from "./pages/SettingsBilibiliPage";
import SettingsAutoPage from "./pages/SettingsAutoPage";
@@ -97,6 +98,7 @@ export default function App() {
<NavItem to="/settings/asr" label="Settings · ASR" />
<NavItem to="/settings/youtube" label="Settings · YouTube" />
<NavItem to="/settings/storage" label="Settings · Storage" />
<NavItem to="/settings/api" label="Settings · API" />
<NavItem to="/settings/auto" label="Settings · Auto" />
<NavItem to="/settings/translate" label="Settings · Translate" />
<NavItem to="/settings/bilibili" label="Settings · Bilibili" />
@@ -118,6 +120,7 @@ export default function App() {
<Route path="/settings/asr" element={<SettingsASRPage />} />
<Route path="/settings/youtube" element={<SettingsYouTubePage />} />
<Route path="/settings/storage" element={<SettingsStoragePage />} />
<Route path="/settings/api" element={<SettingsApiPage />} />
<Route path="/settings/auto" element={<SettingsAutoPage />} />
<Route path="/settings/translate" element={<SettingsTranslatePage />} />
<Route path="/settings/bilibili" element={<SettingsBilibiliPage />} />
+12 -1
View File
@@ -2,7 +2,18 @@ export async function fetchJson<T>(url: string, init?: RequestInit): Promise<T>
const resp = await fetch(url, { credentials: "include", ...init });
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`${resp.status} ${resp.statusText}${text ? ` - ${text}` : ""}`);
let detail = text;
if (text) {
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
detail = parsed.detail.trim();
} else if (typeof parsed.message === "string" && parsed.message.trim()) {
detail = parsed.message.trim();
}
} catch {}
}
throw new Error(`${resp.status} ${resp.statusText}${detail ? ` - ${detail}` : ""}`);
}
return (await resp.json()) as T;
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from "react";
import { fetchJson } from "../lib/http";
import { ORCHESTRATOR_URL } from "../lib/urls";
type RemoteApiSettings = {
token_set: boolean;
token_updated_at?: string | null;
endpoint_path: string;
token_query_param: string;
url_query_param: string;
license_query_param: string;
proof_url_query_param: string;
};
const DEFAULT_SETTINGS: RemoteApiSettings = {
token_set: false,
token_updated_at: null,
endpoint_path: "/remote/auto/youtube",
token_query_param: "token",
url_query_param: "url",
license_query_param: "license",
proof_url_query_param: "proof_url",
};
function orchestratorBaseUrl(): string {
const raw = ORCHESTRATOR_URL.replace(/\/+$/, "");
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
if (typeof window === "undefined") return raw;
return new URL(raw || "/api", window.location.origin).toString().replace(/\/+$/, "");
}
export default function SettingsApiPage() {
const [settings, setSettings] = useState<RemoteApiSettings | null>(null);
const [tokenInput, setTokenInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function refresh() {
setError(null);
try {
const s = await fetchJson<RemoteApiSettings>(`${ORCHESTRATOR_URL}/settings/api`);
setSettings(s);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
}
}
useEffect(() => {
refresh();
}, []);
const effective = settings ?? DEFAULT_SETTINGS;
const baseUrl = useMemo(() => orchestratorBaseUrl(), []);
const remoteEndpoint = useMemo(() => `${baseUrl}${effective.endpoint_path}`, [baseUrl, effective.endpoint_path]);
const sampleUrl = useMemo(() => {
const qs = new URLSearchParams();
qs.set(effective.token_query_param, tokenInput.trim() || "YOUR_TOKEN");
qs.set(effective.url_query_param, "https://www.youtube.com/watch?v=dQw4w9WgXcQ");
qs.set(effective.license_query_param, "authorized");
return `${remoteEndpoint}?${qs.toString()}`;
}, [effective.license_query_param, effective.token_query_param, effective.url_query_param, remoteEndpoint, tokenInput]);
const sampleCurl = useMemo(
() =>
[
`curl -G "${remoteEndpoint}" \\`,
` --data-urlencode "${effective.token_query_param}=${tokenInput.trim() || "YOUR_TOKEN"}" \\`,
` --data-urlencode "${effective.url_query_param}=https://www.youtube.com/watch?v=dQw4w9WgXcQ" \\`,
` --data-urlencode "${effective.license_query_param}=authorized"`,
].join("\n"),
[effective.license_query_param, effective.token_query_param, effective.url_query_param, remoteEndpoint, tokenInput],
);
return (
<div className="space-y-4">
<div className="rounded border bg-white p-4">
<div className="text-lg font-semibold">Settings · API</div>
<div className="mt-1 text-sm text-slate-600">配置远程管理 token。外部请求带上 token 和 YouTube `url` 参数后,会直接按 Auto Mode 自动创建任务并开始处理。</div>
{error ? <div className="mt-3 whitespace-pre-wrap break-words text-sm text-rose-700">{error}</div> : null}
</div>
<div className="rounded border bg-white p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">当前配置</div>
<button onClick={() => refresh()} className="rounded border px-3 py-2 text-sm hover:bg-slate-50">
刷新
</button>
</div>
{!settings ? (
<div className="mt-2 text-sm text-slate-500">加载中…</div>
) : (
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="rounded border p-3">
<div className="text-xs text-slate-500">remote token</div>
<div className="mt-1 text-sm">{settings.token_set ? "已设置" : "未设置"}</div>
</div>
<div className="rounded border p-3">
<div className="text-xs text-slate-500">token_updated_at</div>
<div className="mt-1 font-mono text-sm">{settings.token_updated_at || "-"}</div>
</div>
<div className="rounded border p-3 md:col-span-2">
<div className="text-xs text-slate-500">remote endpoint</div>
<div className="mt-1 break-all font-mono text-sm">{remoteEndpoint}</div>
</div>
</div>
)}
<div className="mt-3 text-xs text-slate-500">说明:当前远程入口仅支持 YouTube 视频链接,行为等价于网页里的 “YouTube 自动模式”。</div>
</div>
<div className="rounded border bg-white p-4">
<div className="text-sm font-semibold">保存 token</div>
<div className="mt-2 grid gap-3 md:grid-cols-2">
<label className="block md:col-span-2">
<div className="mb-1 text-xs text-slate-600">token(最少 8 个字符)</div>
<input
className="w-full rounded border px-3 py-2 text-sm"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder={settings?.token_set ? "已设置(输入新 token 会覆盖旧值)" : "输入新的远程调用 token"}
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
disabled={busy || !tokenInput.trim()}
className="rounded bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 disabled:opacity-50"
onClick={async () => {
setBusy(true);
setError(null);
try {
await fetchJson(`${ORCHESTRATOR_URL}/settings/api`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: tokenInput.trim() }),
});
setTokenInput("");
await refresh();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}}
>
保存
</button>
<button
disabled={busy || !settings?.token_set}
className="rounded border px-3 py-2 text-sm hover:bg-slate-50 disabled:opacity-50"
onClick={async () => {
if (!window.confirm("确认清空远程管理 token?清空后外部将无法再调用该接口。")) return;
setBusy(true);
setError(null);
try {
await fetchJson(`${ORCHESTRATOR_URL}/settings/api`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: "" }),
});
setTokenInput("");
await refresh();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}}
>
清空 token
</button>
</div>
<div className="mt-3 text-xs text-slate-500">提示:保存后不会回显明文 token。请自行保管;需要更换时直接输入新 token 覆盖即可。</div>
</div>
<div className="rounded border bg-white p-4">
<div className="text-sm font-semibold">调用示例</div>
<div className="mt-2 text-xs text-slate-500">
必填参数:<span className="font-mono">{effective.token_query_param}</span>、<span className="font-mono">{effective.url_query_param}</span>
。可选参数:<span className="font-mono">{effective.license_query_param}</span>、<span className="font-mono">{effective.proof_url_query_param}</span>。
</div>
<div className="mt-3 rounded border bg-slate-50 p-3">
<div className="text-xs text-slate-500">示例 URL</div>
<pre className="mt-2 whitespace-pre-wrap break-all font-mono text-xs text-slate-800">{sampleUrl}</pre>
</div>
<div className="mt-3 rounded border bg-slate-50 p-3">
<div className="text-xs text-slate-500">curl</div>
<pre className="mt-2 whitespace-pre-wrap break-all font-mono text-xs text-slate-800">{sampleCurl}</pre>
</div>
</div>
</div>
);
}
+19 -3
View File
@@ -427,8 +427,13 @@ export default function TaskDetailPage() {
const finalAssets = useMemo(() => (assets ?? []).filter((x) => x.kind === "video_final"), [assets]);
const coverAssets = useMemo(() => (assets ?? []).filter((x) => x.kind === "cover_image"), [assets]);
const logAssets = useMemo(() => (assets ?? []).filter((x) => x.kind === "log"), [assets]);
const youtubeDownloadLogAssets = useMemo(() => logAssets.filter((x) => x.storage_key.includes("/youtube_download_")), [logAssets]);
const subtitleLogAssets = useMemo(() => logAssets.filter((x) => x.storage_key.includes("/subtitle_")), [logAssets]);
const renderLogAssets = useMemo(() => logAssets.filter((x) => x.storage_key.includes("/render_")), [logAssets]);
const latestYouTubeDownloadLog = useMemo(
() => (youtubeDownloadLogAssets.length ? youtubeDownloadLogAssets[youtubeDownloadLogAssets.length - 1] : null),
[youtubeDownloadLogAssets],
);
const latestSubtitleLog = useMemo(
() => (subtitleLogAssets.length ? subtitleLogAssets[subtitleLogAssets.length - 1] : null),
[subtitleLogAssets],
@@ -541,6 +546,10 @@ export default function TaskDetailPage() {
await refresh();
alert(`${opts.resume ? "已继续字幕任务" : "已提交字幕任务"}:${resp.job_id}`);
} catch (e: unknown) {
try {
await refresh({ silent: true });
await loadLogs({ silent: true });
} catch {}
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
@@ -578,6 +587,9 @@ export default function TaskDetailPage() {
const maxBytes = 200_000;
if (logSelection === "combined") {
const parts: Array<{ title: string; asset: Asset }> = [];
if (latestYouTubeDownloadLog) {
parts.push({ title: `YouTube Download · ${latestYouTubeDownloadLog.storage_key}`, asset: latestYouTubeDownloadLog });
}
if (latestSubtitleLog) parts.push({ title: `Subtitle · ${latestSubtitleLog.storage_key}`, asset: latestSubtitleLog });
if (latestRenderLog) parts.push({ title: `Render · ${latestRenderLog.storage_key}`, asset: latestRenderLog });
if (!parts.length) {
@@ -605,7 +617,7 @@ export default function TaskDetailPage() {
if (!silent) setLogBusy(false);
}
},
[taskId, logSelection, latestSubtitleLog, latestRenderLog, selectedLogAsset],
[taskId, logSelection, latestYouTubeDownloadLog, latestSubtitleLog, latestRenderLog, selectedLogAsset],
);
useEffect(() => {
@@ -679,7 +691,7 @@ export default function TaskDetailPage() {
</button>
</div>
{error ? <div className="mt-3 text-sm text-rose-700">{error}</div> : null}
{error ? <div className="mt-3 whitespace-pre-wrap break-words text-sm text-rose-700">{error}</div> : null}
{!task ? <div className="mt-3 text-sm text-slate-500">加载中…</div> : null}
{task ? (
<div className="mt-3 grid gap-3 md:grid-cols-2">
@@ -748,6 +760,10 @@ export default function TaskDetailPage() {
}
await refresh();
} catch (e: unknown) {
try {
await refresh({ silent: true });
await loadLogs({ silent: true });
} catch {}
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
@@ -996,7 +1012,7 @@ export default function TaskDetailPage() {
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<select className="rounded border px-3 py-2 text-sm" value={logSelection} onChange={(e) => setLogSelection(e.target.value)}>
<option value="combined">合并(最新字幕 + 压制)</option>
<option value="combined">合并(最新 YouTube 下载 + 字幕 + 压制)</option>
{[...logAssets].reverse().map((a) => (
<option key={a.id} value={a.id}>
{a.storage_key}