This commit is contained in:
eric
2026-03-29 03:35:05 -05:00
parent 895dafc0e0
commit 22061fb3c8
14 changed files with 669 additions and 94 deletions

193
web.py
View File

@@ -10,7 +10,7 @@ from typing import Optional
from pydantic import BaseModel, Field
from fastapi import FastAPI, Query, Request, WebSocket
from fastapi import FastAPI, File, Query, Request, UploadFile, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@@ -50,7 +50,8 @@ from src.config_governance import (
restore_backup,
save_managed_file as governed_save_managed_file,
)
from src.hub_routes import configure_hub, router as hub_router
from src.hub_kv_store import get_youtube_pro_channels, set_youtube_pro_channels
from src.hub_routes import YoutubeProChannelsBody, configure_hub, router as hub_router
from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes
# ---------------- 配置(根目录结构) ----------------
@@ -61,9 +62,28 @@ CONSOLE_OUT = BASE_DIR / "web-console" / "out"
MAX_LOG_LINES = 300
MAX_TIKTOK_REPLAY_UPLOAD = 2 * 1024 * 1024 * 1024
_TIKTOK_REPLAY_EXT = frozenset({".mp4", ".mkv", ".ts", ".flv", ".mov", ".m4v", ".webm"})
process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR)
def _sanitize_pm2_dir_fragment(name: str) -> str:
s = re.sub(r"[^a-zA-Z0-9._-]+", "_", (name or "").strip())
return (s or "default")[:120]
def tiktok_replay_dir(pm2: str) -> Path:
d = CONFIG_DIR / "tiktok_replay" / _sanitize_pm2_dir_fragment(pm2)
d.mkdir(parents=True, exist_ok=True)
return d
def allows_tiktok_replay_upload(pm2: str) -> bool:
sc = script_for_pm2_or_pro(pm2)
return bool(sc and Path(sc).stem.lower().startswith("tiktok"))
def _label_short(prefix: str) -> str:
return {
"tiktok": "TikTok",
@@ -238,19 +258,47 @@ def _resolve_pro_style_script(head_l: str) -> Optional[str]:
return matches[0]
def _guess_script_from_pm2_alias(pm2: str) -> Optional[str]:
"""PM2 名与 ecosystem 脚本 stem 不一致时(如自定义 name按关键词匹配仓库内脚本。"""
low = (pm2 or "").strip().lower()
if not low:
return None
base = BASE_DIR
web_path = Path(__file__).resolve()
def pick(glob_pat: str, skip: frozenset[str]) -> Optional[str]:
for p in sorted(base.glob(glob_pat)):
if not p.is_file() or p.resolve() == web_path:
continue
if p.name in skip:
continue
return p.name
return None
if "douyin_youtube" in low or low.startswith("d2y"):
return pick("douyin_youtube*.py", frozenset({"douyin_youtube_ffplay.py"}))
if "tiktok" in low:
return pick("tiktok*.py", frozenset())
if "youtube" in low:
return pick("youtube*.py", frozenset())
return None
def script_for_pm2_or_pro(pm2: str) -> Optional[str]:
s = script_for_pm2(pm2)
if s:
return s
head_l = _pro_script_head(pm2)
if not head_l:
return None
resolved = _resolve_pro_style_script(head_l)
if resolved:
return resolved
for p in PROCESS_MONITOR:
if Path(p["script"]).stem.lower() == head_l:
return p["script"]
if head_l:
resolved = _resolve_pro_style_script(head_l)
if resolved:
return resolved
for p in PROCESS_MONITOR:
if Path(p["script"]).stem.lower() == head_l:
return p["script"]
guess = _guess_script_from_pm2_alias(pm2)
if guess:
return guess
return None
@@ -267,7 +315,7 @@ def managed_url_config_relpath(pm2: str) -> str:
sc = script_for_pm2_or_pro(pm2)
if not sc:
return "URL_config.ini"
if "__" in pm2 and (is_youtube_script(sc) or is_tiktok_script(sc)):
if is_youtube_script(sc) or is_tiktok_script(sc):
return f"URL_config.{_pm2_safe_segment(pm2)}.ini"
return "URL_config.ini"
@@ -276,9 +324,7 @@ def managed_youtube_ini_relpath(pm2: str) -> Optional[str]:
sc = script_for_pm2_or_pro(pm2)
if not sc or not is_youtube_script(sc):
return None
if "__" in pm2:
return f"youtube.{_pm2_safe_segment(pm2)}.ini"
return "youtube.ini"
return f"youtube.{_pm2_safe_segment(pm2)}.ini"
def script_for_pm2(pm2: str) -> Optional[str]:
@@ -299,6 +345,22 @@ app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
configure_hub(process_backend, list(PROCESS_MONITOR), BASE_DIR)
app.include_router(hub_router)
@app.get("/youtube_pro_channels", include_in_schema=False)
async def alias_youtube_pro_channels_get() -> dict:
"""与 /hub/youtube_pro_channels 相同;便于反代只转发根路径时仍能同步多频道列表。"""
return {"channels": get_youtube_pro_channels()}
@app.post("/youtube_pro_channels", include_in_schema=False)
async def alias_youtube_pro_channels_post(body: YoutubeProChannelsBody):
try:
set_youtube_pro_channels(body.channels)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return {"message": "ok"}
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -350,6 +412,11 @@ class AndroidPairBody(BaseModel):
code: str = Field(..., min_length=4, max_length=16)
class TiktokReplayDeleteBody(BaseModel):
process: str = Field(..., min_length=1)
filename: str = Field(..., min_length=1, max_length=512)
async def read_log_path(path: Optional[str], lines: int) -> str:
if not path:
return "无日志路径\n"
@@ -745,7 +812,13 @@ async def get_config(process: str = Query(..., description="进程名")):
)
config_file = CONFIG_DIR / rel
if not config_file.exists():
if not config_file.is_file():
legacy = CONFIG_DIR / "youtube.ini"
if legacy.is_file():
try:
return {"content": legacy.read_text(encoding="utf-8")}
except OSError as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
return {"content": f"# {rel} 尚未创建,保存后将写入磁盘\n"}
try:
content = config_file.read_text(encoding="utf-8")
@@ -786,7 +859,13 @@ async def get_url_config(process: str = Query(..., description="进程名")):
rel = managed_url_config_relpath(process)
config_file = CONFIG_DIR / rel
if not config_file.exists():
if not config_file.is_file():
legacy = CONFIG_DIR / "URL_config.ini"
if legacy.is_file():
try:
return {"content": legacy.read_text(encoding="utf-8")}
except OSError as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
return {"content": f"# {rel} 尚未创建,保存后将写入磁盘\n"}
try:
content = config_file.read_text(encoding="utf-8")
@@ -819,6 +898,88 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
@app.get("/tiktok_replay/list")
async def tiktok_replay_list(process: str = Query(..., min_length=1)):
if not allows_tiktok_replay_upload(process):
return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400)
root = tiktok_replay_dir(process)
files: list[dict[str, str | int]] = []
try:
for p in sorted(root.iterdir(), key=lambda x: x.name.lower()):
if not p.is_file():
continue
if p.suffix.lower() not in _TIKTOK_REPLAY_EXT:
continue
st = p.stat()
abs_posix = str(p.resolve()).replace("\\", "/")
files.append(
{
"name": p.name,
"size": int(st.st_size),
"mtime": int(st.st_mtime),
"replay_url": f"replayfile:{abs_posix}",
}
)
except OSError as e:
return JSONResponse({"error": f"列出文件失败: {e}"}, status_code=500)
return {"files": files}
@app.post("/tiktok_replay/upload")
async def tiktok_replay_upload(
process: str = Query(..., min_length=1),
file: UploadFile = File(...),
):
if not allows_tiktok_replay_upload(process):
return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400)
raw_name = (file.filename or "").strip()
if not raw_name:
return JSONResponse({"error": "缺少文件名"}, status_code=400)
base = Path(raw_name).name
if base in {".", ".."} or "/" in raw_name or "\\" in raw_name:
return JSONResponse({"error": "非法文件名"}, status_code=400)
ext = Path(base).suffix.lower()
if ext not in _TIKTOK_REPLAY_EXT:
return JSONResponse({"error": f"不支持的扩展名(允许 {', '.join(sorted(_TIKTOK_REPLAY_EXT))}"}, status_code=400)
dest = tiktok_replay_dir(process) / base
try:
data = await file.read(MAX_TIKTOK_REPLAY_UPLOAD + 1)
except OSError as e:
return JSONResponse({"error": f"读取上传失败: {e}"}, status_code=500)
if len(data) > MAX_TIKTOK_REPLAY_UPLOAD:
return JSONResponse({"error": "文件过大(上限 2GB"}, status_code=413)
try:
dest.write_bytes(data)
except OSError as e:
return JSONResponse({"error": f"写入失败: {e}"}, status_code=500)
abs_posix = str(dest.resolve()).replace("\\", "/")
return {"message": "上传成功", "name": base, "replay_url": f"replayfile:{abs_posix}"}
@app.post("/tiktok_replay/delete")
async def tiktok_replay_delete(body: TiktokReplayDeleteBody):
if not allows_tiktok_replay_upload(body.process):
return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400)
base = Path(body.filename).name
if not base or base in {".", ".."}:
return JSONResponse({"error": "非法文件名"}, status_code=400)
root = tiktok_replay_dir(body.process)
target = root / base
try:
resolved = target.resolve()
if resolved.parent != root.resolve():
return JSONResponse({"error": "路径越界"}, status_code=400)
except OSError:
return JSONResponse({"error": "路径无效"}, status_code=400)
if not target.is_file():
return JSONResponse({"error": "文件不存在"}, status_code=404)
try:
target.unlink()
except OSError as e:
return JSONResponse({"error": f"删除失败: {e}"}, status_code=500)
return {"message": "已删除"}
def _not_found_hint(process: str) -> str:
if process_backend.mode == "local":
return (