This commit is contained in:
eric
2026-03-28 02:39:29 -05:00
parent 243b9d7acb
commit 4b3c6cbee4
23 changed files with 1468 additions and 173 deletions

View File

@@ -1,6 +1,13 @@
.github/workflows/build-image.yml
.git
.gitignore
.dockerignore
README.md
LICENSE
.github
**/__pycache__
**/*.pyc
.venv
venv
node_modules
web-console/node_modules
web-console/.next
*.log
.process_runner
.cursor
mobile

12
.gitignore vendored
View File

@@ -165,3 +165,15 @@ cython_debug/
#.idea/
backup_config/
# 根目录 npmconcurrently 等)
node_modules/
# Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署)
web-console/.next/
# 无 PM2 时的本地进程状态
.process_runner/
# 本机端口/主机覆盖(从 config/runtime.env.example 复制)
config/runtime.env

View File

@@ -1,19 +1,28 @@
FROM python:3.11-slim
# 生产镜像ffmpeg + 多阶段构建 Next 静态页 + Python API默认端口 8101减少与同机 SRS/代理 冲突)
# docker compose up --build
FROM node:20-bookworm-slim AS console
WORKDIR /src/web-console
COPY web-console/package.json web-console/package-lock.json ./
RUN npm ci
COPY web-console/ ./
RUN npm run build
FROM python:3.12-slim-bookworm
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PORT=8101
COPY . /app
RUN apt-get update && \
apt-get install -y curl gnupg && \
curl -sL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& ffmpeg -version
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && \
apt-get install -y ffmpeg tzdata && \
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
dpkg-reconfigure -f noninteractive tzdata
COPY . .
COPY --from=console /src/web-console/out ./web-console/out
CMD ["python", "main.py"]
EXPOSE 8101
CMD ["sh", "-c", "exec python -m uvicorn web:app --host 0.0.0.0 --port ${PORT}"]

View File

@@ -0,0 +1,11 @@
# 复制本文件为同目录下的 runtime.env 后生效(已加入 .gitignore不会提交
# 启动器 scripts/launch.py 会自动加载 runtime.env 中的键(不覆盖已存在的环境变量)
# 局域网内任意浏览器访问控制台时,请保持 0.0.0.0
HOST=0.0.0.0
# Web 控制台FastAPI + 静态页)端口;与同机 SRS / Clash / Neko 等冲突时请改成未占用端口,例如 8101、9200
PORT=8001
# 开发模式时 Next 端口(一般无需改)
# NEXT_DEV_PORT=3000

6
dev.bat Normal file
View File

@@ -0,0 +1,6 @@
@echo off
cd /d "%~dp0"
if not defined PORT set "PORT=8001"
echo [dev] API+Next 开发模式 PORT=%PORT%
echo [dev] 浏览器打开 http://localhost:3000
python scripts\launch.py --dev --port %PORT%

8
dev.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# 开发API(热重载) + Next dev浏览器打开 http://localhost:3000
set -euo pipefail
cd "$(dirname "$0")"
export PORT="${PORT:-8001}"
PY=python3
command -v python3 >/dev/null 2>&1 || PY=python
exec "$PY" scripts/launch.py --dev --port "$PORT"

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
# 控制台默认 8101与 SRS 8080/1985、Clash 7890 等错开);改端口请同步修改左侧宿主机映射
services:
live-console:
build: .
ports:
- "${LIVE_CONSOLE_PORT:-8101}:8101"
restart: unless-stopped
environment:
HOST: "0.0.0.0"
PORT: "8101"

26
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,26 @@
/**
* PM2在项目根执行 `pm2 start ecosystem.config.cjs` 后 `pm2 save`
* web 使用 scripts/launch.py若缺少 web-console/out 会自动 npm build 再起 uvicorn
*/
const path = require("path");
const root = __dirname;
const py = process.env.PYTHON || "python3";
module.exports = {
apps: [
{
name: "web",
cwd: root,
script: path.join(root, "scripts", "launch.py"),
interpreter: py,
env: {
HOST: "0.0.0.0",
PORT: process.env.PORT || "8001",
},
},
// { name: "youtube", cwd: root, script: py, args: "youtube.py" },
// { name: "tiktok", cwd: root, script: py, args: "tiktok.py" },
// Linux: { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` },
// Win: { name: "obs", cwd: root, script: "cmd.exe", args: "/c obs.bat" },
],
};

View File

@@ -84,7 +84,7 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
<pre id="pm2Log">正在加载状态与日志...</pre>
</div>
<script>
/** 进程列表来自 GET /process_monitorweb2.py 启动时扫描项目根目录自动识别PM2 名 = 脚本主文件名(无扩展名)。 */
/** 进程列表来自 GET /process_monitorweb.py 扫描根目录PM2 名 = 脚本主文件名(无扩展名)。 */
let PROCESS_MONITOR = [];
function pm2NameFromScript(script) {
@@ -94,7 +94,9 @@ function pm2NameFromScript(script) {
}
function isYoutubeScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube');
if (!/\.py$/i.test(script)) return false;
var s = pm2NameFromScript(script).toLowerCase();
return s.startsWith('youtube') || s.startsWith('douyin_youtube');
}
function isTiktokScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
@@ -315,12 +317,12 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
try {
await loadProcessMonitor();
} catch (e) {
logEl.textContent = '无法加载 /process_monitor请用本服务打开的页面访问或检查 web2 是否已启动)\n' + (e && e.message ? e.message : '');
logEl.textContent = '无法加载 /process_monitor请用本服务打开的页面访问或检查 web.py / uvicorn 是否已启动)\n' + (e && e.message ? e.message : '');
statusIndicator.textContent = '❌ 未连接';
return;
}
if (!PROCESS_MONITOR.length) {
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh且 Web 为当前 web*.py';
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / douyin_youtube*.py / obs 脚本,且含 web.py';
return;
}
fillProcessSelect();

17
obs.bat Normal file
View File

@@ -0,0 +1,17 @@
@echo off
setlocal EnableDelayedExpansion
REM Windows从本机 SRS 拉 SRT 并用 ffplay 播放(需已安装 ffmpeg 且 ffplay 在 PATH
REM 下方 SRS_SRT_URL 里的端口是 SRS 的 SRT 端口,与 Web 控制台端口config\runtime.env无关。
if not defined SRS_SRT_URL (
set "SRS_SRT_URL=srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request^&latency=10"
)
where ffplay >nul 2>&1
if errorlevel 1 (
echo ffplay not found. Install ffmpeg and add to PATH.
exit /b 1
)
ffplay -nostats -hide_banner -fflags nobuffer -flags low_delay -framedrop -probesize 32 -analyzeduration 0 -sync ext -infbuf -vf "setpts=PTS-STARTPTS" "%SRS_SRT_URL%"
endlocal

35
obs.sh
View File

@@ -1,5 +1,30 @@
export DISPLAY=:0
ffplay -fflags nobuffer -flags low_delay -framedrop \
-probesize 32 -analyzeduration 0 -sync ext -infbuf \
-vf "setpts=PTS-STARTPTS" \
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'
#!/usr/bin/env bash
# 从本机 SRS 拉 SRT 流并用 ffplay 全屏输出到 HDMI需已登录图形会话X11 或 Wayland 下 XWayland
# 注意:下方端口为 SRS 的 SRT 端口(常见 10080与 Web 控制台端口(见 config/runtime.env无关。
set -euo pipefail
# SRS SRT 播放地址(与 srs 里 app/stream 名一致时可只改此项)
: "${SRS_SRT_URL:=srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10}"
# X11常见为 :0开发板/工控机本地桌面)
if [[ -z "${DISPLAY:-}" ]]; then
export DISPLAY="${DISPLAY:-:0}"
fi
# 部分 ARM 板卡仅支持 OpenGL ES可尝试export FFPLAY_SDL_DRIVER=opengles2
if [[ -n "${FFPLAY_SDL_DRIVER:-}" ]]; then
export SDL_VIDEODRIVER="${FFPLAY_SDL_DRIVER}"
fi
# 纯 DRM/KMS 无 X 时不能用本脚本;请改用 ffmpeg 直送 drm 或 kmsgrab见 README 说明)
if ! command -v ffplay >/dev/null 2>&1; then
echo "ffplay 未找到,请安装 ffmpeg含 ffplay。" >&2
exit 1
fi
# 需要额外参数时可在本行自行追加,例如:-window_title hdmi
exec ffplay -nostats -hide_banner \
-fflags nobuffer -flags low_delay -framedrop \
-probesize 32 -analyzeduration 0 -sync ext -infbuf \
-vf "setpts=PTS-STARTPTS" \
"$SRS_SRT_URL"

327
package-lock.json generated Normal file
View File

@@ -0,0 +1,327 @@
{
"name": "douyinyoutube",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "douyinyoutube",
"devDependencies": {
"concurrently": "^9.1.2"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "douyinyoutube",
"private": true,
"description": "直播推流控制台:一键开发/构建脚本入口(核心逻辑见 scripts/launch.py",
"scripts": {
"dev": "concurrently -k -n api,console -c cyan,magenta \"npm run dev:api\" \"npm run dev:console\"",
"dev:api": "python -m uvicorn web:app --host 0.0.0.0 --port 8001 --reload",
"dev:console": "npm run dev --prefix web-console",
"build": "npm run build --prefix web-console",
"start": "python scripts/launch.py",
"start:api": "python scripts/launch.py --api-only",
"preview": "python scripts/launch.py --skip-build"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

View File

@@ -4,4 +4,6 @@ pycryptodome>=3.20.0
distro>=1.9.0
tqdm>=4.67.1
httpx[http2]>=0.28.1
PyExecJS>=1.5.1
PyExecJS>=1.5.1
fastapi>=0.115.0
uvicorn[standard]>=0.30.0

91
scripts/env_check.py Normal file
View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""启动前环境分析Python 依赖、ffmpeg、Node/npm、端口提示Windows / Linux 通用)。"""
from __future__ import annotations
import importlib.util
import shutil
import socket
import sys
def _has_module(name: str) -> bool:
return importlib.util.find_spec(name) is not None
def _local_ips() -> list[str]:
out: list[str] = []
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
ip = info[4][0]
if ip and ip != "127.0.0.1" and ip not in out:
out.append(ip)
except OSError:
pass
return out[:4]
def print_env_report(
*,
dev: bool,
need_npm: bool,
host: str,
port: str,
next_port: str = "3000",
) -> None:
print()
print("┌── 环境检测 ─────────────────────────────────────────")
print(f"│ 系统: {sys.platform} Python: {sys.version.split()[0]}")
ok = True
for mod, label in (
("fastapi", "FastAPI"),
("uvicorn", "Uvicorn"),
):
if _has_module(mod):
print(f"│ ✓ {label} 已安装")
else:
print(f"│ ✕ 缺少 {label},请执行: pip install -r requirements.txt")
ok = False
ff = shutil.which("ffmpeg")
fp = shutil.which("ffplay")
if ff:
print(f"│ ✓ ffmpeg: {ff}")
else:
print("│ ⚠ 未找到 ffmpeg推流/录制/OBS 拉流会失败Docker 镜像已内置)")
ok = False
if fp:
print(f"│ ✓ ffplay: {fp}")
elif sys.platform != "win32":
print("│ ⚠ 未找到 ffplayHDMI 全屏播放需要完整 ffmpeg 套件)")
if need_npm:
npm = shutil.which("npm") or shutil.which("npm.cmd")
if npm:
print(f"│ ✓ npm: {npm}")
else:
print("│ ✕ 未找到 npm构建/开发前端需要安装 Node.js")
ok = False
print("")
print("│ 同机端口提示(若冲突请在 config/runtime.env 修改 PORT")
print("│ 本控制台 API/Web 使用环境变量 PORT默认见 launch.py")
print("│ SRS 常见: 1935/1985/8080/10080 等;代理常见: 7890/9090Chrome 远程: 9222")
print("")
print(f"│ 监听: {host}:{port}")
if dev:
ips = _local_ips()
print(f"│ 开发 UI: 0.0.0.0:{next_port}(局域网可用本机 IP 访问)")
if ips:
print(f"│ 示例: http://{ips[0]}:{next_port}/")
else:
if host == "0.0.0.0":
ips = _local_ips()
if ips:
print(f"│ 局域网访问示例: http://{ips[0]}:{port}/")
else:
print("│ 局域网访问: http://<本机IP>:%s/" % port)
print("└────────────────────────────────────────────────────")
print()
if not ok and not dev:
print("[env] 存在警告,仍可尝试启动;若运行失败请按上述提示补全依赖。\n")

245
scripts/launch.py Normal file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
一键启动 Web 控制台:
生产:构建 web-console若需要+ 仅 uvicorn单端口托管 API + 静态页)
开发uvicorn + Next dev默认绑定 0.0.0.0,局域网可访问)
自动加载 config/runtime.env若存在不覆盖已有环境变量
用法:
python scripts/launch.py
python scripts/launch.py --dev
python scripts/launch.py --port 8101
python scripts/launch.py --skip-build
python scripts/launch.py --no-env-check
环境变量: HOST, PORT, NEXT_DEV_PORT, WEB_STACK=api, API_PROXY_TARGET开发
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = Path(__file__).resolve().parent
CONSOLE = ROOT / "web-console"
OUT_INDEX = CONSOLE / "out" / "index.html"
sys.path.insert(0, str(SCRIPTS_DIR))
import env_check # noqa: E402
def load_runtime_env() -> None:
path = ROOT / "config" / "runtime.env"
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, _, v = line.partition("=")
k, v = k.strip(), v.strip().strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
def _which_npm() -> str | None:
return shutil.which("npm") or shutil.which("npm.cmd")
def _npm(args: list[str], *, cwd: Path, env: dict | None = None) -> int:
full_env = {**os.environ, **(env or {})}
if sys.platform == "win32":
return subprocess.call(
"npm " + " ".join(args),
cwd=cwd,
shell=True,
env=full_env,
)
npm = _which_npm()
if not npm:
return 127
return subprocess.call([npm, *args], cwd=cwd, env=full_env)
def ensure_console_built(*, skip: bool) -> bool:
"""返回是否已存在可托管的静态 index。"""
if skip:
return OUT_INDEX.is_file()
if not CONSOLE.is_dir():
print("[launch] 未找到 web-console将仅使用根目录 index.html若存在")
return False
if not _which_npm():
print("[launch] 未检测到 npm跳过前端构建请先安装 Node.js 或手动在 web-console 执行 npm run build。")
return OUT_INDEX.is_file()
if OUT_INDEX.is_file():
return True
print("[launch] 正在安装依赖并构建 Web 控制台(首次略慢)…")
lock = CONSOLE / "package-lock.json"
if lock.is_file():
if _npm(["ci"], cwd=CONSOLE) != 0:
print("[launch] npm ci 失败,尝试 npm install…")
if _npm(["install"], cwd=CONSOLE) != 0:
sys.exit(1)
else:
if _npm(["install"], cwd=CONSOLE) != 0:
sys.exit(1)
if _npm(["run", "build"], cwd=CONSOLE) != 0:
sys.exit(1)
print("[launch] 前端构建完成。")
return True
def _terminate_process(p: subprocess.Popen | None) -> None:
if p is None or p.poll() is not None:
return
p.terminate()
try:
p.wait(timeout=8)
except subprocess.TimeoutExpired:
p.kill()
p.wait(timeout=3)
def run_dev(host: str, port: str, next_port: str) -> None:
if not _which_npm():
print("[launch] 开发模式需要 Node.js/npm。仅起 API 请用: python scripts/launch.py --api-only --reload")
sys.exit(1)
api_url = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env.setdefault("API_PROXY_TARGET", api_url)
api_cmd = [
sys.executable,
"-m",
"uvicorn",
"web:app",
"--host",
host,
"--port",
port,
"--reload",
]
os.chdir(ROOT)
api: subprocess.Popen | None = None
ui: subprocess.Popen | None = None
next_args = f"npm run dev -- --hostname 0.0.0.0 --port {next_port}"
try:
api = subprocess.Popen(api_cmd, cwd=ROOT)
time.sleep(0.6)
if api.poll() is not None:
print("[launch] API 进程异常退出,请检查端口是否被占用。")
sys.exit(api.returncode or 1)
if sys.platform == "win32":
ui = subprocess.Popen(next_args, cwd=CONSOLE, shell=True, env=env)
else:
ui = subprocess.Popen(
["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", next_port],
cwd=CONSOLE,
env=env,
)
print()
print(" —— 开发模式(局域网可访问)——")
print(f" 控制台界面: http://0.0.0.0:{next_port} 或用本机 IP + 端口")
print(f" API: http://{host}:{port}/ Next 已代理同名接口到 {api_url}")
print(" 按 Ctrl+C 可同时结束前后端")
if host == "0.0.0.0":
print(" 提示: 绑定 0.0.0.0 时请勿把管理端口暴露到公网")
print()
while True:
time.sleep(0.5)
if api.poll() is not None:
print("[launch] API 已退出")
break
if ui and ui.poll() is not None:
print("[launch] Next 已退出")
break
except KeyboardInterrupt:
print("\n[launch] 正在关闭…")
finally:
_terminate_process(ui)
_terminate_process(api)
def run_api_only(host: str, port: str, reload: bool) -> None:
os.chdir(ROOT)
cmd = [sys.executable, "-m", "uvicorn", "web:app", "--host", host, "--port", port]
if reload:
cmd.append("--reload")
os.execvp(sys.executable, cmd)
def run_prod(host: str, port: str, skip_build: bool, reload: bool) -> None:
ensure_console_built(skip=skip_build)
run_api_only(host, port, reload)
def main() -> None:
load_runtime_env()
default_port = os.environ.get("PORT", "8001")
ap = argparse.ArgumentParser(description="直播控制台一键启动")
ap.add_argument("--dev", action="store_true", help="开发API + Next需 Node")
ap.add_argument(
"--api-only",
action="store_true",
help="仅 uvicorn不构建、不启动 Next",
)
ap.add_argument("--host", default=None, help="监听地址(默认生产 0.0.0.0,可经 HOST 环境变量)")
ap.add_argument("--port", default=None, help="端口(默认 PORT 或 8001")
ap.add_argument("--skip-build", action="store_true", help="跳过前端构建检测")
ap.add_argument("--reload", action="store_true", help="uvicorn --reload生产慎用")
ap.add_argument("--no-env-check", action="store_true", help="跳过环境分析")
args = ap.parse_args()
if os.environ.get("WEB_STACK", "").lower() == "api":
args.api_only = True
port = str(args.port if args.port is not None else default_port)
next_port = os.environ.get("NEXT_DEV_PORT", "3000")
if args.dev:
host = args.host or os.environ.get("HOST") or "0.0.0.0"
else:
host = args.host or os.environ.get("HOST") or "0.0.0.0"
need_npm = args.dev or (
not args.skip_build and not args.api_only and not OUT_INDEX.is_file()
)
if not args.no_env_check:
env_check.print_env_report(
dev=bool(args.dev),
need_npm=need_npm,
host=host,
port=port,
next_port=next_port,
)
if args.dev:
run_dev(host, port, next_port)
return
if args.api_only:
run_api_only(host, port, args.reload)
return
run_prod(host, port, skip_build=args.skip_build, reload=args.reload)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 无 X11/Wayland、仅有 DRM/KMS 的开发板示例:将同源 SRT 解码后送 kmsgrab/drm 需按板卡改设备。
# 请先确认 ffmpeg 编译选项含 --enable-libdrm路径与 connector 用 `modetest`/`drm_info` 自查。
# 使用前复制为 srt_to_hdmi_drm.sh 并修改 DRM 设备与 mode。
set -euo pipefail
: "${SRS_SRT_URL:?设置 SRS_SRT_URL}"
: "${DRM_DEVICE:=/dev/dri/card0}"
echo "示例占位:请根据硬件将下行 ffmpeg 参数改为本机可用的 drmvideo/kmsgrab 输出。" >&2
echo "SRT: $SRS_SRT_URL DRM: $DRM_DEVICE" >&2
exit 1

371
src/web_process_backend.py Normal file
View File

@@ -0,0 +1,371 @@
"""
Web 控制台进程后端:优先 PM2Linux/Windows 通用),不可用时回退为本地 PID 注册表。
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Optional
BACKEND_STATE_VERSION = 1
def strip_ansi_codes(text: str) -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", text)
async def _shell_out(cmd: str, timeout: float = 15.0) -> tuple[int, str]:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return 124, "ERROR: 命令超时"
out = (stdout or b"").decode(errors="ignore").strip()
err = (stderr or b"").decode(errors="ignore").strip()
text = out if out else err
return proc.returncode or 0, text if text else "命令执行完成(无输出)"
async def pm2_available() -> bool:
code, _ = await _shell_out("pm2 -v", timeout=5.0)
return code == 0
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
_, text = await _shell_out(f"pm2 {cmd}", timeout=timeout)
return text
async def force_kill_ffmpeg(process_name: str) -> None:
"""尽量结束与本业务相关的 ffmpegLinux 精准 + 兜底Windows 与 Linux 兜底策略一致:结束 ffmpeg 进程)。"""
if sys.platform == "win32":
_ = process_name # 保留参数供将来按命令行筛选;当前与 Linux killall 行为对齐
proc2 = await asyncio.create_subprocess_shell(
"taskkill /IM ffmpeg.exe /F /T 2>nul || exit /b 0",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc2.wait()
return
for cmd in (
f"pkill -f 'ffmpeg.*{process_name}' || true",
"killall -9 ffmpeg 2>/dev/null || true",
):
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
await asyncio.sleep(0.4)
class LocalProcessRegistry:
"""无 PM2 时:用 JSON 记录 pid日志写入 .pm2/logs。"""
def __init__(self, base_dir: Path, log_dir: Path):
self.base_dir = base_dir.resolve()
self.log_dir = log_dir
self.state_path = self.base_dir / ".process_runner" / "state.json"
self._lock = asyncio.Lock()
def _read_state(self) -> dict[str, Any]:
if not self.state_path.exists():
return {"version": BACKEND_STATE_VERSION, "processes": {}}
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
if not isinstance(data.get("processes"), dict):
data["processes"] = {}
return data
except Exception:
return {"version": BACKEND_STATE_VERSION, "processes": {}}
def _write_state(self, data: dict[str, Any]) -> None:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False)
tmp = self.state_path.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
os.replace(str(tmp), str(self.state_path))
def _pid_alive(self, pid: int) -> bool:
if pid <= 0:
return False
if sys.platform == "win32":
try:
out = subprocess.check_output(
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="ignore",
)
return str(pid) in out
except Exception:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
def _kill_tree(self, pid: int) -> None:
if pid <= 0:
return
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True,
text=True,
timeout=30,
)
return
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
def build_command(self, script: str) -> Optional[list[str]]:
path = self.base_dir / script
if not path.is_file():
return None
if script.lower().endswith(".py"):
return [sys.executable, str(path)]
if script.lower().endswith(".sh"):
bash = shutil_which("bash") or shutil_which("sh")
if not bash:
return None
return [bash, str(path)]
if script.lower().endswith((".bat", ".cmd")):
return ["cmd.exe", "/c", str(path)]
return None
async def start(self, name: str, script: str) -> str:
async with self._lock:
data = self._read_state()
procs = data["processes"]
if name in procs:
old_pid = int(procs[name].get("pid") or 0)
if self._pid_alive(old_pid):
return f"进程 {name} 已在运行 (PID {old_pid})"
cmd = self.build_command(script)
if not cmd:
return f"ERROR: 无法启动 {script}(文件不存在或缺少 bash/cmd"
self.log_dir.mkdir(parents=True, exist_ok=True)
out_log = open(self.log_dir / f"{name}-out.log", "a", encoding="utf-8", errors="ignore")
err_log = open(self.log_dir / f"{name}-error.log", "a", encoding="utf-8", errors="ignore")
preexec_fn = os.setsid if sys.platform != "win32" else None
creationflags = 0
if sys.platform == "win32":
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
try:
kw: dict[str, Any] = {
"cwd": str(self.base_dir),
"stdout": out_log,
"stderr": err_log,
}
if preexec_fn is not None:
kw["preexec_fn"] = preexec_fn
if creationflags:
kw["creationflags"] = creationflags
proc = await asyncio.create_subprocess_exec(*cmd, **kw)
except Exception as e:
out_log.close()
err_log.close()
return f"ERROR: 启动失败: {e}"
out_log.close()
err_log.close()
procs[name] = {
"pid": proc.pid,
"script": script,
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
self._write_state(data)
return f"已本地启动 {name} (PID {proc.pid}),命令: {' '.join(cmd)}"
async def stop(self, name: str) -> str:
async with self._lock:
data = self._read_state()
procs = data["processes"]
if name not in procs:
return f"进程 {name} 未在本地注册表中"
pid = int(procs[name].get("pid") or 0)
if self._pid_alive(pid):
self._kill_tree(pid)
await asyncio.sleep(0.5)
procs.pop(name, None)
self._write_state(data)
return f"已停止 {name}(本地后端)"
async def restart(self, name: str, script: str) -> str:
await self.stop(name)
await asyncio.sleep(0.3)
return await self.start(name, script)
def describe_status(self, name: str) -> dict[str, Any]:
data = self._read_state()
procs = data.get("processes") or {}
out_path = str(self.log_dir / f"{name}-out.log")
err_path = str(self.log_dir / f"{name}-error.log")
if name not in procs:
return {
"process_status": "not_found",
"out_path": out_path,
"err_path": err_path,
}
pid = int(procs[name].get("pid") or 0)
if self._pid_alive(pid):
status = "online"
else:
status = "stopped"
return {
"process_status": status,
"out_path": out_path,
"err_path": err_path,
}
def status_table_text(self) -> str:
data = self._read_state()
lines = ["┌─────────────┬──────────┬──────────────────────────┐", "│ name │ status │ pid │", "├─────────────┼──────────┼──────────────────────────┤"]
for name, info in sorted((data.get("processes") or {}).items()):
pid = int(info.get("pid") or 0)
st = "online" if self._pid_alive(pid) else "stopped"
lines.append(f"{name:<11}{st:<8}{pid:<24}")
lines.append("└─────────────┴──────────┴──────────────────────────┘")
lines.append("(本地进程后端,非 PM2)")
return "\n".join(lines)
def shutil_which(cmd: str) -> Optional[str]:
from shutil import which
return which(cmd)
class WebProcessBackend:
def __init__(self, base_dir: Path, default_log_dir: Path):
self.base_dir = base_dir
self.default_log_dir = default_log_dir
self._local = LocalProcessRegistry(base_dir, default_log_dir)
self._use_pm2: Optional[bool] = None
async def refresh_mode(self) -> None:
self._use_pm2 = await pm2_available()
@property
def mode(self) -> str:
if self._use_pm2 is None:
return "unknown"
return "pm2" if self._use_pm2 else "local"
async def ensure_mode(self) -> None:
if self._use_pm2 is None:
await self.refresh_mode()
async def start(self, process: str, script: str) -> str:
await self.ensure_mode()
if self._use_pm2:
return await run_pm2(f"start {process}")
return await self._local.start(process, script)
async def stop(self, process: str) -> str:
await self.ensure_mode()
if self._use_pm2:
return await run_pm2(f"stop {process}")
return await self._local.stop(process)
async def restart(self, process: str, script: str) -> str:
await self.ensure_mode()
if self._use_pm2:
return await run_pm2(f"restart {process}")
return await self._local.restart(process, script)
async def describe_raw(self, process: str) -> str:
await self.ensure_mode()
if self._use_pm2:
return await run_pm2(f"describe {process}", timeout=10.0)
info = self._local.describe_status(process)
if info["process_status"] == "not_found":
return "No such process"
return f"status │ {info['process_status']}\nout_log_path │ {info['out_path']}\nerr_log_path │ {info['err_path']}"
async def full_status_raw(self) -> str:
await self.ensure_mode()
if self._use_pm2:
return await run_pm2("status", timeout=8.0)
return self._local.status_table_text()
async def parse_pm2_describe(self, process: str) -> dict[str, Any]:
await self.ensure_mode()
if not self._use_pm2:
d = self._local.describe_status(process)
return {
"process_status": d["process_status"],
"out_path": d["out_path"],
"err_path": d["err_path"],
}
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
describe_clean = strip_ansi_codes(describe_raw)
if any(
x in describe_clean.lower()
for x in ["no such process", "unknown process", "not found"]
) or not describe_clean.strip():
return {
"process_status": "not_found",
"out_path": None,
"err_path": None,
}
status = "unknown"
out_path = None
err_path = None
for line in describe_raw.splitlines():
line_clean = strip_ansi_codes(line)
if line_clean.count("") < 2:
continue
parts = [p.strip() for p in line_clean.split("") if p.strip()]
if len(parts) >= 2:
key = parts[0].lower()
value = parts[1]
if key == "status":
status = value.lower()
elif key == "out_log_path":
out_path = value
elif key == "err_log_path":
err_path = value
if not out_path:
out_path = str(self.default_log_dir / f"{process}-out.log")
if not err_path:
err_path = str(self.default_log_dir / f"{process}-error.log")
return {
"process_status": status,
"out_path": out_path,
"err_path": err_path,
}

6
start.bat Normal file
View File

@@ -0,0 +1,6 @@
@echo off
cd /d "%~dp0"
if not defined HOST set "HOST=0.0.0.0"
if not defined PORT set "PORT=8001"
echo [start] 生产模式 HOST=%HOST% PORT=%PORT%
python scripts\launch.py --host %HOST% --port %PORT%

9
start.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# 生产/运维:自动构建前端(若缺失)+ 单进程 uvicornAPI 与静态页同端口)
set -euo pipefail
cd "$(dirname "$0")"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8001}"
PY=python3
command -v python3 >/dev/null 2>&1 || PY=python
exec "$PY" scripts/launch.py --host "$HOST" --port "$PORT"

11
web.bat Normal file
View File

@@ -0,0 +1,11 @@
@echo off
cd /d "%~dp0"
if not defined PORT set "PORT=8001"
REM 默认与 web.sh 一致:开发双端。仅 API: set WEB_STACK=api
if /i "%WEB_STACK%"=="api" (
echo [web] 仅 API 模式 ^(reload^) PORT=%PORT%
python scripts\launch.py --api-only --host 0.0.0.0 --port %PORT% --reload
) else (
echo [web] 开发模式: 浏览器 http://localhost:3000 API %PORT%
python scripts\launch.py --dev --port %PORT%
)

351
web.py
View File

@@ -1,45 +1,97 @@
from fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path
import asyncio
import re
from typing import Optional
from fastapi.middleware.cors import CORSMiddleware
from __future__ import annotations
app = FastAPI(title="多进程录制控制台")
import asyncio
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes
# ---------------- 配置(根目录结构) ----------------
BASE_DIR = Path(__file__).parent
BASE_DIR = Path(__file__).parent.resolve()
CONFIG_DIR = BASE_DIR / "config"
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
CONSOLE_OUT = BASE_DIR / "web-console" / "out"
MAX_LOG_LINES = 300
process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR)
def _label_short(prefix: str) -> str:
return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
return {
"tiktok": "TikTok",
"youtube": "YouTube",
"douyin_youtube": "抖音→YouTube",
"obs": "OBS",
"web": "Web 控制台",
}.get(prefix, prefix)
def _sort_script_entries(entries: list[dict], web_script_name: str) -> list[dict]:
"""下拉列表顺序抖音→YouTube → YouTube → TikTok → OBS → Web 控制台(默认首选 YouTube 业务)。"""
def sort_key(e: dict) -> tuple:
script = e["script"]
st = Path(script).stem.lower()
if st.startswith("douyin_youtube"):
return (0, script)
if script.endswith(".py") and st.startswith("youtube"):
return (1, script)
if st.startswith("tiktok"):
return (2, script)
if st.startswith("obs"):
return (3, script)
if script == web_script_name or st == "web":
return (99, script)
return (50, script)
return sorted(entries, key=sort_key)
def discover_script_entries() -> tuple:
"""扫描项目根目录自动识别入口tiktok*.py、youtube*.py、obs*.sh以及当前本文件Web 控制台。PM2 名 = 主文件名(无扩展名)"""
"""扫描根目录tiktok*.py、youtube*.py、douyin_youtube*.pyOBS 在 Windows 用 obs*.bat/.cmd在类 Unix 用 obs*.sh"""
base = BASE_DIR
web_path = Path(__file__).resolve()
entries: list[dict] = []
seen_stems: set[str] = set()
def add(script_name: str, label_key: str) -> None:
stem = Path(script_name).stem
if stem in seen_stems:
return
seen_stems.add(stem)
entries.append({"script": script_name, "label": _label_short(label_key)})
for path in sorted(base.glob("tiktok*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("tiktok")})
add(path.name, "tiktok")
for path in sorted(base.glob("youtube*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("youtube")})
add(path.name, "youtube")
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
for path in sorted(base.glob("douyin_youtube*.py")):
if path.is_file() and path.resolve() != web_path:
add(path.name, "douyin_youtube")
if sys.platform == "win32":
for path in sorted(list(base.glob("obs*.bat")) + list(base.glob("obs*.cmd"))):
if path.is_file():
add(path.name, "obs")
else:
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
add(path.name, "obs")
entries.append({"script": web_path.name, "label": _label_short("web")})
entries = _sort_script_entries(entries, web_path.name)
return tuple(entries)
@@ -55,7 +107,10 @@ def _stem(script: str) -> str:
def is_youtube_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("youtube")
if not script.endswith(".py"):
return False
s = _stem(script).lower()
return s.startswith("youtube") or s.startswith("douyin_youtube")
def is_tiktok_script(script: str) -> bool:
@@ -89,7 +144,15 @@ def script_for_pm2(pm2: str) -> Optional[str]:
return p["script"]
return None
# CORS
@asynccontextmanager
async def lifespan(app: FastAPI):
await process_backend.refresh_mode()
yield
app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -97,50 +160,12 @@ app.add_middleware(
allow_headers=["*"],
)
# ---------------- 工具函数 ----------------
def strip_ansi_codes(text: str) -> str:
return re.sub(r'\x1b\[[0-9;]*m', '', text)
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
process = await asyncio.create_subprocess_shell(
f"pm2 {cmd}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
result = (stdout or stderr).decode(errors="ignore").strip()
return result if result else "命令执行完成(无输出)"
except asyncio.TimeoutError:
process.kill()
await process.wait()
return "ERROR: PM2 命令超时,已强制终止"
async def force_kill_ffmpeg(process_name: str):
"""尝试强杀与该进程相关的 ffmpeg"""
cmds = [
# 优先杀带有进程名的 ffmpeg最精准
f"pkill -f 'ffmpeg.*{process_name}' || true",
# 再杀所有 ffmpeg兜底比较暴力
"killall -9 ffmpeg 2>/dev/null || true",
]
for cmd in cmds:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
await asyncio.sleep(0.4) # 稍微间隔,避免竞争
async def read_log_path(path: str, lines: int) -> str:
async def read_log_path(path: Optional[str], lines: int) -> str:
if not path:
return "无日志路径\n"
if "/dev/null" in path:
norm = path.replace("\\", "/").lower()
if "/dev/null" in path or norm.endswith("/nul") or norm == "nul":
return "日志输出被禁用PM2 配置为无日志)\n"
log_file = Path(path)
if not log_file.exists():
@@ -149,127 +174,144 @@ async def read_log_path(path: str, lines: int) -> str:
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
content_lines = f.readlines()
return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
except Exception as e:
except OSError as e:
return f"读取日志失败 ({path}): {str(e)}\n"
async def get_process_info(process: str) -> dict:
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
describe_clean = strip_ansi_codes(describe_raw)
return await process_backend.parse_pm2_describe(process)
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
return {
"process_status": "not_found",
"out_path": None,
"err_path": None,
}
status = "unknown"
out_path = None
err_path = None
# ---------------- 服务信息(前端可展示 PM2 / 本地后端) ----------------
@app.get("/health")
async def health():
return {"status": "ok"}
for line in describe_raw.splitlines():
line_clean = strip_ansi_codes(line)
if line_clean.count('') < 2:
continue
parts = [p.strip() for p in line_clean.split('') if p.strip()]
if len(parts) >= 2:
key = parts[0].lower()
value = parts[1]
if key == "status":
status = value.lower()
elif key == "out_log_path":
out_path = value
elif key == "err_log_path":
err_path = value
if not out_path:
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
if not err_path:
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
@app.get("/server_info")
async def server_info(refresh: bool = Query(False, description="为 true 时重新检测 PM2 是否可用")):
if refresh:
await process_backend.refresh_mode()
else:
await process_backend.ensure_mode()
return {
"process_status": status,
"out_path": out_path,
"err_path": err_path,
"platform": sys.platform,
"process_backend": process_backend.mode,
"console_built": (CONSOLE_OUT / "index.html").is_file(),
"message": "PM2 可用时使用 PM2否则使用本地 PID 表(无需 npm i -g pm2",
"deployment": {
"runtime_env_example": "config/runtime.env.example",
"runtime_env_active": (CONFIG_DIR / "runtime.env").is_file(),
"docker_compose_default_port": 8101,
"process_dropdown_order": "douyin_youtube → youtube → tiktok → obs → web",
},
}
# ---------------- 配置路由youtube.ini仅 youtube ----------------
@app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
return JSONResponse(
{"error": "仅 YouTube / 抖音→YouTube 类脚本支持 youtube.ini 编辑"}, status_code=400
)
config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists():
return {"content": "# youtube.ini 文件不存在,将创建空文件\n"}
try:
content = config_file.read_text(encoding="utf-8")
return {"content": content}
except Exception as e:
except OSError as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
@app.post("/save_config")
async def save_config(request: Request, process: str = Query(..., description="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
return JSONResponse(
{"error": "仅 YouTube / 抖音→YouTube 类脚本支持 youtube.ini 编辑"}, status_code=400
)
try:
data = await request.json()
content = data.get("content", "")
except:
except Exception:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
return {"message": "配置保存成功"}
except Exception as e:
except OSError as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- 配置路由URL_config.iniyoutube 和 tiktok 共享) ----------------
# ---------------- 配置路由URL_config.ini ----------------
@app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
return JSONResponse(
{"error": "仅 tiktok*.py / youtube*.py / douyin_youtube*.py 对应进程支持 URL 配置编辑"},
status_code=400,
)
config_file = CONFIG_DIR / "URL_config.ini"
if not config_file.exists():
return {"content": "# URL_config.ini 文件不存在,将创建空文件\n"}
try:
content = config_file.read_text(encoding="utf-8")
return {"content": content}
except Exception as e:
except OSError as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
@app.post("/save_url_config")
async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
return JSONResponse(
{
"error": "仅 tiktok*.py / youtube*.py / douyin_youtube*.py 对应进程支持 URL 配置编辑",
},
status_code=400,
)
try:
data = await request.json()
content = data.get("content", "")
except:
except Exception:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
return {"message": "配置保存成功"}
except Exception as e:
except OSError as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- PM2 控制路由 ----------------
def _not_found_hint(process: str) -> str:
if process_backend.mode == "local":
return (
f"进程「{process}」未在本地注册表中;请点击「启动」由控制台直接拉起脚本,"
"或安装 PM2 后先用 pm2 start 注册同名进程。"
)
return "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)"
# ---------------- 进程控制 ----------------
@app.get("/start")
async def start(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"start {process}")
sc = script_for_pm2(process)
if not sc:
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
output = await process_backend.start(process, sc)
return JSONResponse({"output": output})
@@ -278,31 +320,32 @@ async def stop(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
# 第一步:尝试优雅停止
pm2_output = await run_pm2(f"stop {process}")
pm2_output = await process_backend.stop(process)
# 第二步:等待 PM2 信号传播(通常 1~3 秒)
await asyncio.sleep(2.0)
# 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 ffmpeg
if not process.startswith("web"):
await force_kill_ffmpeg(process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
})
return JSONResponse(
{
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查日志目录 .pm2/logs",
}
)
@app.get("/restart")
async def restart(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"restart {process}")
sc = script_for_pm2(process)
if not sc:
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
output = await process_backend.restart(process, sc)
if not process.startswith("web"):
await force_kill_ffmpeg(process)
return JSONResponse({"output": output})
@@ -311,43 +354,63 @@ async def restart(process: str = Query(..., description="进程名")):
@app.get("/status")
async def status(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
return JSONResponse({
"raw_status": "",
"process_status": "invalid",
"recent_log": f"无效进程名: {process}",
"recent_error": ""
})
return JSONResponse(
{
"raw_status": "",
"process_status": "invalid",
"recent_log": f"无效进程名: {process}",
"recent_error": "",
}
)
full_status_raw = await run_pm2("status", timeout=8.0)
full_status_raw = await process_backend.full_status_raw()
full_status = strip_ansi_codes(full_status_raw)
info = await get_process_info(process)
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
recent_log = await read_log_path(info.get("out_path"), MAX_LOG_LINES)
recent_error = await read_log_path(info.get("err_path"), MAX_LOG_LINES)
if info["process_status"] == "not_found":
recent_log = "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)\n"
recent_log = _not_found_hint(process) + "\n"
recent_error = ""
return JSONResponse({
"raw_status": full_status,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
})
return JSONResponse(
{
"raw_status": full_status,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
}
)
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
await process_backend.ensure_mode()
return {
"entries": [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
for p in PROCESS_MONITOR
]
],
"process_backend": process_backend.mode,
}
# ---------------- 首页 ----------------
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
ico = CONSOLE_OUT / "favicon.ico"
if ico.is_file():
return FileResponse(ico)
return JSONResponse({}, status_code=404)
@app.get("/")
async def index():
return FileResponse(BASE_DIR / "index.html")
next_index = CONSOLE_OUT / "index.html"
if next_index.is_file():
return FileResponse(next_index)
return FileResponse(BASE_DIR / "index.html")
_next_dir = CONSOLE_OUT / "_next"
if _next_dir.is_dir():
app.mount("/_next", StaticFiles(directory=str(_next_dir)), name="next_static")

14
web.sh
View File

@@ -1,2 +1,12 @@
# Ubuntu本分支控制台入口与无「2」分支的 web / youtube.py 等区分)
uvicorn web2:app --host 0.0.0.0 --port 8001 --reload
#!/usr/bin/env bash
# 兼容入口默认一键开发API + Next。仅后端: WEB_STACK=api ./web.sh
# 生产部署请用: ./start.sh
set -euo pipefail
cd "$(dirname "$0")"
export PORT="${PORT:-8001}"
PY=python3
command -v python3 >/dev/null 2>&1 || PY=python
if [[ "${WEB_STACK:-}" == "api" ]]; then
exec "$PY" scripts/launch.py --api-only --host 0.0.0.0 --port "$PORT" --reload
fi
exec "$PY" scripts/launch.py --dev --port "$PORT"