's'
This commit is contained in:
@@ -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
12
.gitignore
vendored
@@ -165,3 +165,15 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
backup_config/
|
||||
|
||||
# 根目录 npm(concurrently 等)
|
||||
node_modules/
|
||||
|
||||
# Next 开发缓存(静态导出目录 web-console/out 可纳入版本库以便无 Node 环境直接部署)
|
||||
web-console/.next/
|
||||
|
||||
# 无 PM2 时的本地进程状态
|
||||
.process_runner/
|
||||
|
||||
# 本机端口/主机覆盖(从 config/runtime.env.example 复制)
|
||||
config/runtime.env
|
||||
|
||||
33
Dockerfile
33
Dockerfile
@@ -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}"]
|
||||
|
||||
11
config/runtime.env.example
Normal file
11
config/runtime.env.example
Normal 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
6
dev.bat
Normal 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
8
dev.sh
Normal 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
10
docker-compose.yml
Normal 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
26
ecosystem.config.cjs
Normal 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" },
|
||||
],
|
||||
};
|
||||
10
index.html
10
index.html
@@ -84,7 +84,7 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
<script>
|
||||
/** 进程列表仅来自 GET /process_monitor(web2.py 启动时扫描项目根目录自动识别);PM2 名 = 脚本主文件名(无扩展名)。 */
|
||||
/** 进程列表来自 GET /process_monitor(web.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
17
obs.bat
Normal 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
35
obs.sh
@@ -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
327
package-lock.json
generated
Normal 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
17
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
91
scripts/env_check.py
Normal 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("│ ⚠ 未找到 ffplay(HDMI 全屏播放需要完整 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/9090;Chrome 远程: 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
245
scripts/launch.py
Normal 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()
|
||||
10
scripts/srt_to_hdmi_drm.example.sh
Normal file
10
scripts/srt_to_hdmi_drm.example.sh
Normal 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
371
src/web_process_backend.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Web 控制台进程后端:优先 PM2(Linux/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:
|
||||
"""尽量结束与本业务相关的 ffmpeg(Linux 精准 + 兜底;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
6
start.bat
Normal 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
9
start.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# 生产/运维:自动构建前端(若缺失)+ 单进程 uvicorn(API 与静态页同端口)
|
||||
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
11
web.bat
Normal 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
351
web.py
@@ -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*.py;OBS 在 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.ini,youtube 和 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)
|
||||
|
||||
# 第三步:录制类进程才强杀 ffmpeg(web* 控制台不杀 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
14
web.sh
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user