's'
This commit is contained in:
193
web.py
193
web.py
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user