新增拉流转推功能,并按业务重设计摄像头/拉流独立界面
支持 RTMP/SRT/HLS/FLV/WHEP 拉流到 YouTube;摄像头与拉流页采用独立工作台布局,更新 README 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
190
pull_stream_youtube.py
Normal file
190
pull_stream_youtube.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Pull stream (RTMP / SRT / HLS / FLV / WHEP) → YouTube RTMPS relay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from web2_proxy_config import merge_child_env
|
||||
from web2_pull_stream import build_pull_input_args, load_pull_stream_config, resolve_protocol
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_DIR = BASE_DIR / "config"
|
||||
YOUTUBE_INI = CONFIG_DIR / "youtube.ini"
|
||||
|
||||
RUNNING = True
|
||||
|
||||
|
||||
def _on_signal(*_args):
|
||||
global RUNNING
|
||||
RUNNING = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, _on_signal)
|
||||
signal.signal(signal.SIGINT, _on_signal)
|
||||
|
||||
|
||||
def _load_youtube_key() -> tuple[str, str]:
|
||||
rtmps = "rtmps://a.rtmp.youtube.com/live2/"
|
||||
if not YOUTUBE_INI.exists():
|
||||
return "", rtmps
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(YOUTUBE_INI, encoding="utf-8")
|
||||
if not cp.has_section("youtube"):
|
||||
return "", rtmps
|
||||
key = ""
|
||||
for name, val in cp.items("youtube"):
|
||||
v = (val or "").strip()
|
||||
if name in ("key", "stream_key") and v and not v.startswith("#"):
|
||||
key = v
|
||||
break
|
||||
if key.startswith("rtmp://") or key.startswith("rtmps://"):
|
||||
return key, key
|
||||
return key, rtmps + key if key else ""
|
||||
|
||||
|
||||
def _pick_nvenc(force_sw: bool) -> tuple[str, list[str]]:
|
||||
if force_sw:
|
||||
return "libx264", ["-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "high"]
|
||||
try:
|
||||
if platform.system().lower() not in ("windows", "linux"):
|
||||
raise RuntimeError("skip")
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
if check.returncode != 0:
|
||||
raise RuntimeError("no nvidia")
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
print("[INFO] 使用 NVENC 硬件编码")
|
||||
return "h264_nvenc", ["-profile:v", "high", "-tune", "ll", "-rc", "cbr"]
|
||||
except Exception:
|
||||
pass
|
||||
return "libx264", ["-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "high"]
|
||||
|
||||
|
||||
def _build_cmd(cfg: dict[str, str], youtube_url: str) -> list[str]:
|
||||
input_args, proto = build_pull_input_args(cfg)
|
||||
transcode = (cfg.get("transcode") or "transcode").lower()
|
||||
br = int(cfg.get("bitrate") or "4500")
|
||||
b_v = f"{br}k"
|
||||
maxrate = f"{min(int(br * 1.2), 8000)}k"
|
||||
bufsize = f"{br * 2}k"
|
||||
force_sw = (cfg.get("use_gpu") or "").lower() in ("否", "0", "false", "无", "关", "off")
|
||||
|
||||
print(f"[INFO] 拉流协议: {proto}")
|
||||
print(f"[INFO] 源地址: {(cfg.get('source_url') or '').strip()}")
|
||||
|
||||
if transcode == "copy":
|
||||
output_args = [
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-bsf:a",
|
||||
"aac_adtstoasc",
|
||||
"-f",
|
||||
"flv",
|
||||
youtube_url,
|
||||
]
|
||||
return ["ffmpeg", *input_args, *output_args]
|
||||
|
||||
codec, vopts = _pick_nvenc(force_sw)
|
||||
fps = 30
|
||||
output_args = [
|
||||
"-c:v",
|
||||
codec,
|
||||
*vopts,
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-b:v",
|
||||
b_v,
|
||||
"-maxrate",
|
||||
maxrate,
|
||||
"-bufsize",
|
||||
bufsize,
|
||||
"-g",
|
||||
str(fps * 2),
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-f",
|
||||
"flv",
|
||||
youtube_url,
|
||||
]
|
||||
return ["ffmpeg", *input_args, *output_args]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("[INFO] 拉流 → YouTube 转播进程已启动")
|
||||
proc: subprocess.Popen | None = None
|
||||
while RUNNING:
|
||||
cfg = load_pull_stream_config(CONFIG_DIR)
|
||||
key, youtube_url = _load_youtube_key()
|
||||
if not key:
|
||||
print("[WARN] 请在 config/youtube.ini 配置 YouTube 串流密钥 (key=…)")
|
||||
time.sleep(5)
|
||||
continue
|
||||
if not (cfg.get("source_url") or "").strip():
|
||||
print("[WARN] 请在拉流配置中填写 source_url")
|
||||
time.sleep(5)
|
||||
continue
|
||||
if resolve_protocol(cfg) == "auto":
|
||||
print("[WARN] 无法识别拉流协议,请在配置中指定 protocol")
|
||||
time.sleep(5)
|
||||
continue
|
||||
cmd = _build_cmd(cfg, youtube_url)
|
||||
print("[INFO] 启动 FFmpeg 拉流转播…")
|
||||
print("[INFO] ffmpeg " + " ".join(cmd[1:]))
|
||||
env = merge_child_env(os.environ.copy())
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
cwd=str(BASE_DIR),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 无法启动 FFmpeg: {e}")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
assert proc.stdout is not None
|
||||
while RUNNING:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
print(line.rstrip())
|
||||
code = proc.wait()
|
||||
proc = None
|
||||
if not RUNNING:
|
||||
break
|
||||
print(f"[WARN] FFmpeg 退出 code={code},5 秒后重试…")
|
||||
time.sleep(5)
|
||||
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
print("[INFO] 拉流转播进程已结束")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user