This commit is contained in:
Your Name
2026-01-27 08:55:32 +00:00
parent 6fbd86bf43
commit 3ccbf3d973
9 changed files with 182 additions and 78 deletions

View File

@@ -1,10 +1,3 @@
[youtube] [youtube]
# 只写 stream key(推荐方式) # 只写 stream key(推荐方式)
key = qxvb-r47b-r5ju-6ud3-6k7z key = qxvb-r47b-r5ju-6ud3-6k7z
# 如果想推多个频道,直接加更多 key(自动多路推流)
# main = qxvb-r47b-r5ju-6ud3-6k7z
# backup = xxxx-xxxx-xxxx-xxxx-xxxx
# 如果你想直接写完整 RTMP 地址(兼容旧方式)
# full_url = rtmp://a.rtmp.youtube.com/live2/qxvb-r47b-r5ju-6ud3-6k7z

3
dist/douyin_youtube_ffplay.py vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1,2 @@
# Pyarmor 9.2.3 (trial), 000000, 2026-01-24T06:52:53.934396 # Pyarmor 9.2.3 (trial), 000000, 2026-01-27T08:55:17.418182
from .pyarmor_runtime import __pyarmor__ from .pyarmor_runtime import __pyarmor__

View File

@@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>多进程录制控制台</title> <title>多进程录制控制台</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style> <style>
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000} :root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}} @media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
@@ -14,17 +13,17 @@ h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
#processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em} #processSelect{padding:8px;border-radius:8px;border:1px solid #ddd;font-size:1em}
#statusIndicator{font-size:1.2em;font-weight:600} #statusIndicator{font-size:1.2em;font-weight:600}
h2{margin:24px 0 12px;font-size:1.4em} h2{margin:24px 0 12px;font-size:1.4em}
input,button{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em} input,button,textarea{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
input{background:#f5f5f5;color:#000;border:1px solid #ddd} input,textarea{background:#f5f5f5;color:#000;border:1px solid #ddd;font-family:monospace}
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s} button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
button:hover{background:#1a237e} button:hover{background:#1a237e}
button.loading{opacity:.7;pointer-events:none} button.loading{opacity:.7;pointer-events:none}
button.loading::after{content:" ⏳";animation:spin 1s linear infinite} button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0} #pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
#videoPlayer{position:relative;width:100%;padding-bottom:56.25%;background:#000;border-radius:8px;overflow:hidden;margin:16px 0;display:none}
video{position:absolute;inset:0;width:100%;height:100%}
#loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.8em;pointer-events:none;background:rgba(0,0,0,.5);opacity:0;transition:opacity .3s;border-radius:8px}
#pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all} #pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
#configButtons{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin:12px 0}
#configStatus{color:#0f0;margin-top:8px;font-family:monospace}
#obsAddress{background:#f0f0f0;padding:12px;border-radius:8px;font-family:monospace;font-size:1.1em;margin:8px 0;word-break:break-all}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
</style> </style>
</head> </head>
@@ -39,13 +38,26 @@ video{position:absolute;inset:0;width:100%;height:100%}
</select> </select>
<span id="statusIndicator">⚪ 加载中...</span> <span id="statusIndicator">⚪ 加载中...</span>
</h1> </h1>
<h2>视频播放器(M3U8 / FLV)</h2>
<input type="text" id="videoUrlInput" placeholder="请输入视频链接"> <!-- YouTube 配置编辑区(仅 youtube 时显示) -->
<button id="playButton">播放</button> <div id="configSection" style="display:none;">
<div id="videoPlayer"> <h2>YouTube 配置编辑 (config/youtube.ini)</h2>
<video controls autoplay playsinline></video> <textarea id="youtubeConfig" rows="20"></textarea>
<div id="loading">加载中...</div> <div id="configButtons">
<button id="loadConfigBtn">加载配置</button>
<button id="saveConfigBtn">保存配置</button>
</div>
<pre id="configStatus">就绪</pre>
</div> </div>
<!-- OBS 推流地址区(仅 obs 时显示) -->
<div id="obsSection" style="display:none;">
<h2>OBS 直播推流地址</h2>
<p>请在 OBS → 设置 → 推流 → 服务选择“自定义”,服务器填写以下地址:</p>
<div id="obsAddress">srt://live.local:10080/live/obs</div>
<p>密钥(Stream Key)留空即可。</p>
</div>
<h2>PM2 进程控制</h2> <h2>PM2 进程控制</h2>
<div id="pm2Buttons"> <div id="pm2Buttons">
<button data-action="start">启动</button> <button data-action="start">启动</button>
@@ -57,70 +69,48 @@ video{position:absolute;inset:0;width:100%;height:100%}
<pre id="pm2Log">正在加载状态与日志...</pre> <pre id="pm2Log">正在加载状态与日志...</pre>
</div> </div>
<script> <script>
const video = document.querySelector('video');
const player = document.getElementById('videoPlayer');
const loading = document.getElementById('loading');
const logEl = document.getElementById('pm2Log'); const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator'); const statusIndicator = document.getElementById('statusIndicator');
const processSelect = document.getElementById('processSelect'); const processSelect = document.getElementById('processSelect');
let currentProcess = 'tiktok';
let currentProcess = 'tiktok'; // 默认进程 // 各专属区域
const configSection = document.getElementById('configSection');
const obsSection = document.getElementById('obsSection');
const youtubeConfig = document.getElementById('youtubeConfig');
const loadConfigBtn = document.getElementById('loadConfigBtn');
const saveConfigBtn = document.getElementById('saveConfigBtn');
const configStatus = document.getElementById('configStatus');
// 当选择进程改变时,更新当前进程并刷新状态
processSelect.addEventListener('change', () => { processSelect.addEventListener('change', () => {
currentProcess = processSelect.value; currentProcess = processSelect.value;
fetchStatus(); fetchStatus();
updateSectionsVisibility();
}); });
function httpToHttps(url){return url.replace(/^http:/,"https:")} // 根据当前进程显示/隐藏对应区域
function updateSectionsVisibility() {
configSection.style.display = currentProcess === 'youtube' ? 'block' : 'none';
obsSection.style.display = currentProcess === 'obs' ? 'block' : 'none';
if (currentProcess === 'youtube') {
loadConfig();
}
}
function scrollLog(){logEl.scrollTop = logEl.scrollHeight} function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
function updateStatus(process_status){ function updateStatus(process_status){
let emoji = '⚪', text = '未知'; let emoji = '⚪', text = '未知';
if(process_status === 'online'){ emoji = ' '; text = '运行中'; } if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
else if(process_status === 'stopped'){ emoji = ' '; text = '已停止'; } else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
else if(process_status === 'errored'){ emoji = ' '; text = '错误'; } else if(process_status === 'errored'){ emoji = '🔴'; text = '错误'; }
else if(process_status === 'launching' || process_status === 'starting'){ emoji = ' '; text = '启动中'; } else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
else if(process_status === 'waiting restart'){ emoji = ' '; text = '等待重启'; } else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; } else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
statusIndicator.innerHTML = `${emoji} ${text}`; statusIndicator.innerHTML = `${emoji} ${text}`;
} }
async function playVideo(){
let url = document.getElementById('videoUrlInput').value.trim();
if(!url) return alert('请输入视频链接');
url = httpToHttps(url);
player.style.display = 'block';
loading.style.opacity = 1;
video.pause(); video.src = '';
if(url.endsWith('.m3u8')){
if(Hls.isSupported()){
const hls = new Hls({enableWorker:true});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, ()=>loading.style.opacity=0);
hls.on(Hls.Events.ERROR, (e,data)=>{alert('HLS 错误: '+data.type); loading.style.opacity=0});
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
video.src = url; video.play(); loading.style.opacity=0;
}else{alert('浏览器不支持 M3U8'); return}
}else if(url.endsWith('.flv')){
if(typeof flvjs !== 'undefined' && flvjs.isSupported()){
const flvPlayer = flvjs.createPlayer({type:'flv',url,isLive:true});
flvPlayer.attachMediaElement(video);
flvPlayer.load(); flvPlayer.play();
flvPlayer.on(flvjs.Events.LOADING_COMPLETE, ()=>loading.style.opacity=0);
}else{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js';
script.onload = playVideo;
document.head.appendChild(script);
return;
}
}else{alert('仅支持 .m3u8 或 .flv 格式'); return}
}
document.getElementById('playButton').onclick = playVideo;
// PM2 控制 + 实时状态/日志(所有请求都带 ?process=xxx)
async function pm2Action(action){ async function pm2Action(action){
const btn = document.querySelector(`[data-action="${action}"]`); const btn = document.querySelector(`[data-action="${action}"]`);
if(btn) btn.classList.add('loading'); if(btn) btn.classList.add('loading');
@@ -166,6 +156,44 @@ async function pm2Action(action){
async function fetchStatus(){ pm2Action('status'); } async function fetchStatus(){ pm2Action('status'); }
// 配置加载
async function loadConfig(){
configStatus.textContent = '加载中...';
try{
const res = await fetch(`/get_config?process=${currentProcess}`);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
youtubeConfig.value = data.content || '';
configStatus.textContent = '加载成功';
}catch(err){
configStatus.textContent = `加载失败: ${err.message}`;
}
}
// 配置保存
async function saveConfig(){
configStatus.textContent = '保存中...';
const content = youtubeConfig.value;
try{
const res = await fetch(`/save_config?process=${currentProcess}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content})
});
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
configStatus.textContent = data.message || '保存成功';
}catch(err){
configStatus.textContent = `保存失败: ${err.message}`;
}
}
loadConfigBtn.onclick = loadConfig;
saveConfigBtn.onclick = () => {
saveConfigBtn.classList.add('loading');
saveConfig().finally(() => saveConfigBtn.classList.remove('loading'));
};
// 按钮事件委托 // 按钮事件委托
document.getElementById('pm2Buttons').addEventListener('click', e=>{ document.getElementById('pm2Buttons').addEventListener('click', e=>{
const btn = e.target.closest('button'); const btn = e.target.closest('button');
@@ -174,9 +202,12 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
if(action && action !== 'status') pm2Action(action); if(action && action !== 'status') pm2Action(action);
}); });
// 自动刷新(每 1 秒) // 自动刷新状态与日志
setInterval(fetchStatus, 1000); setInterval(fetchStatus, 1000);
// 初始加载
fetchStatus(); fetchStatus();
updateSectionsVisibility();
</script> </script>
</body> </body>
</html> </html>

17
mpv.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
mpv \
--fs \
--no-border \
--vo=drm \
--drm-device=/dev/dri/card0 \
--hwdec=auto \
--video-sync=display-resample \
--untimed \
--cache=no \
--demuxer-lavf-o=ffflags=nobuffer \
--demuxer-lavf-probesize=32 \
--demuxer-lavf-analyzeduration=0 \
--vf=setpts=PTS-STARTPTS \
--no-audio \
--loop=inf \
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'

3
obs.sh
View File

@@ -1,4 +1,5 @@
export DISPLAY=:0
ffplay -fflags nobuffer -flags low_delay -framedrop \ ffplay -fflags nobuffer -flags low_delay -framedrop \
-probesize 32 -analyzeduration 0 -sync ext -infbuf \ -probesize 32 -analyzeduration 0 -sync ext -infbuf \
-vf "setpts=PTS-STARTPTS" \ -vf "setpts=PTS-STARTPTS" \
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10' 'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'

23
obs1.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# 先确保有推流!(pm2 start tiktok/youtube 等,让 ffmpeg 推到 SRS 的 live/livestream)
export SDL_VIDEODRIVER=kmsdrm # 尝试 SDL 的 DRM 驱动(直接 HDMI 输出,无需 DISPLAY)
mpv \
--fs \
--no-border \
--vo=drm \
--drm-device=/dev/dri/card0 \ # 如果 card0 不对,改 card1(ls /dev/dri/ 查看)
--hwdec=auto \
--video-sync=display-resample \
--untimed \
--cache=no \
--demuxer-lavf-o=ffflags=nobuffer \
--demuxer-lavf-probesize=32 \
--demuxer-lavf-analyzeduration=0 \
--vf=setpts=PTS-STARTPTS \
--no-audio \
--loop=inf \
http://127.0.0.1:8081/live/livestream.flv # 先用 HTTP-FLV 测试(稳定,不依赖 SRT)
# 如果 HTTP-FLV 正常,再切回 SRT:
# 'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'

52
web.py
View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI, Query from fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path from pathlib import Path
import asyncio import asyncio
@@ -7,19 +7,21 @@ from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="多进程录制控制台") app = FastAPI(title="多进程录制控制台")
# ---------------- 配置 ---------------- # ---------------- 配置(修正为根目录结构) ----------------
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
DEFAULT_LOG_DIR = BASE_DIR.parent / ".pm2" / "logs" CONFIG_DIR = BASE_DIR / "config" # 直接在根目录下
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" # 直接在根目录下
MAX_LOG_LINES = 300 MAX_LOG_LINES = 300
# 支持的进程列表(可扩展) # 支持的进程列表
VALID_PROCESSES = {"tiktok", "youtube", "obs"} VALID_PROCESSES = {"tiktok", "youtube", "obs"}
# CORS # CORS(支持 POST)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_methods=["GET"], allow_methods=["GET", "POST"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -84,7 +86,7 @@ async def get_process_info(process: str) -> dict:
out_path = value out_path = value
elif key == "err_log_path": elif key == "err_log_path":
err_path = value err_path = value
# 备用默认路径(仅当 describe 未提供时) # 备用默认路径(修正为根目录)
if not out_path: if not out_path:
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log") out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
if not err_path: if not err_path:
@@ -95,7 +97,41 @@ async def get_process_info(process: str) -> dict:
"err_path": err_path, "err_path": err_path,
} }
# ---------------- 路由 ---------------- # ---------------- 配置路由 ----------------
@app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")):
if process != "youtube":
return JSONResponse({"error": "仅 youtube 支持配置编辑"}, 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:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
@app.post("/save_config")
async def save_config(request: Request, process: str = Query(..., description="进程名")):
if process != "youtube":
return JSONResponse({"error": "仅 youtube 支持配置编辑"}, status_code=400)
try:
data = await request.json()
content = data.get("content", "")
except:
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:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- 原有路由 ----------------
@app.get("/start") @app.get("/start")
async def start(process: str = Query(..., description="进程名")): async def start(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES: if process not in VALID_PROCESSES:

2
web.sh
View File

@@ -1 +1 @@
uvicorn tiktokweb:app --host 0.0.0.0 --port 8000 --reload uvicorn web:app --host 0.0.0.0 --port 8000 --reload