新增拉流转推功能,并按业务重设计摄像头/拉流独立界面

支持 RTMP/SRT/HLS/FLV/WHEP 拉流到 YouTube;摄像头与拉流页采用独立工作台布局,更新 README 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric
2026-07-07 21:16:43 -05:00
parent 9dd3a63109
commit 59ad853140
15 changed files with 2286 additions and 224 deletions

291
web2_pull_stream.py Normal file
View File

@@ -0,0 +1,291 @@
"""Pull-stream relay config and FFmpeg input helpers (RTMP / SRT / HLS / FLV / WebRTC-WHEP)."""
from __future__ import annotations
import configparser
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
PULL_STREAM_SECTION = "pull_stream"
PROTOCOL_CHOICES = ("auto", "rtmp", "rtmps", "srt", "hls", "http-flv", "webrtc", "udp")
def pull_stream_ini_path(config_dir: Path) -> Path:
return config_dir / "pull_stream.ini"
def default_pull_stream_config() -> dict[str, str]:
return {
"source_url": "",
"protocol": "auto",
"transcode": "transcode",
"bitrate": "4500",
"use_gpu": "",
"srt_latency_ms": "200",
"reconnect": "",
"youtube_watch_url": "",
"label": "",
"extra_input_args": "",
}
def load_pull_stream_config(config_dir: Path) -> dict[str, str]:
p = pull_stream_ini_path(config_dir)
out = default_pull_stream_config()
if not p.exists():
return out
cp = configparser.ConfigParser()
try:
cp.read(p, encoding="utf-8")
except Exception:
return out
if not cp.has_section(PULL_STREAM_SECTION):
return out
for key in out:
if cp.has_option(PULL_STREAM_SECTION, key):
out[key] = str(cp.get(PULL_STREAM_SECTION, key) or "").strip()
return out
def save_pull_stream_config(config_dir: Path, patch: dict[str, Any]) -> tuple[bool, str]:
config_dir = Path(config_dir)
config_dir.mkdir(parents=True, exist_ok=True)
cur = load_pull_stream_config(config_dir)
for k, v in patch.items():
if k in cur:
cur[k] = str(v or "").strip()
proto = (cur.get("protocol") or "auto").lower()
if proto not in PROTOCOL_CHOICES:
cur["protocol"] = "auto"
transcode = (cur.get("transcode") or "transcode").lower()
if transcode not in ("copy", "transcode"):
cur["transcode"] = "transcode"
cp = configparser.ConfigParser()
cp[PULL_STREAM_SECTION] = cur
p = pull_stream_ini_path(config_dir)
try:
with p.open("w", encoding="utf-8") as f:
cp.write(f)
return True, "拉流配置已保存"
except Exception as e:
return False, f"保存失败: {e}"
def detect_protocol_from_url(url: str) -> str:
raw = (url or "").strip()
low = raw.lower()
if not low:
return "auto"
if low.startswith("rtmps://"):
return "rtmps"
if low.startswith("rtmp://"):
return "rtmp"
if low.startswith("srt://"):
return "srt"
if low.startswith("whep://") or low.startswith("webrtc://"):
return "webrtc"
if low.startswith("udp://"):
return "udp"
if ".m3u8" in low or low.endswith("/m3u8"):
return "hls"
if ".flv" in low:
return "http-flv"
if low.startswith("http://") or low.startswith("https://"):
return "http-flv"
return "auto"
def resolve_protocol(cfg: dict[str, str]) -> str:
chosen = (cfg.get("protocol") or "auto").strip().lower()
if chosen and chosen != "auto":
return chosen
return detect_protocol_from_url(cfg.get("source_url") or "")
def pull_stream_config_ready(config_dir: Path) -> str | None:
cfg = load_pull_stream_config(config_dir)
url = str(cfg.get("source_url") or "").strip()
if not url:
return "请先填写拉流地址RTMP / SRT / HLS / FLV 等)"
proto = resolve_protocol(cfg)
if proto == "auto":
return "无法识别协议,请手动选择协议类型"
yp = Path(config_dir) / "youtube.ini"
if not yp.exists():
return "请先在 YouTube 配置中填写串流密钥"
cp = configparser.ConfigParser()
try:
cp.read(yp, encoding="utf-8")
except Exception:
return "无法读取 youtube.ini"
if not cp.has_section("youtube"):
return "请先在 YouTube 配置中填写串流密钥"
for name, val in cp.items("youtube"):
v = (val or "").strip()
if name in ("key", "stream_key") and v and not v.startswith("#"):
return None
return "请先在 YouTube 配置中填写串流密钥"
def _split_extra_args(text: str) -> list[str]:
raw = (text or "").strip()
if not raw:
return []
try:
import shlex
return shlex.split(raw, posix=False)
except Exception:
return [x for x in re.split(r"\s+", raw) if x]
def build_pull_input_args(cfg: dict[str, str]) -> tuple[list[str], str]:
"""Return FFmpeg input args (before -i URL) and resolved protocol."""
url = (cfg.get("source_url") or "").strip()
proto = resolve_protocol(cfg)
reconnect_on = (cfg.get("reconnect") or "").lower() not in ("", "0", "false", "", "off")
latency = int(cfg.get("srt_latency_ms") or "200")
extra = _split_extra_args(cfg.get("extra_input_args") or "")
common: list[str] = ["-hide_banner", "-loglevel", "info"]
if reconnect_on:
common.extend(
[
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
]
)
if proto in ("rtmp", "rtmps"):
input_args = [
*common,
"-fflags",
"+genpts",
"-flags",
"low_delay",
"-rw_timeout",
"15000000",
"-i",
url,
]
elif proto == "srt":
input_args = [
*common,
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-timeout",
"10000000",
"-i",
url if "latency" in url.lower() else f"{url}{'&' if '?' in url else '?'}latency={latency}",
]
elif proto == "hls":
input_args = [
*common,
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-live_start_index",
"-1",
"-i",
url,
]
elif proto in ("http-flv", "udp"):
input_args = [
*common,
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-i",
url,
]
elif proto == "webrtc":
input_args = [
*common,
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-allowed_extensions",
"ALL",
"-protocol_whitelist",
"file,http,https,tcp,tls,udp,rtp",
"-i",
url,
]
else:
input_args = [*common, "-i", url]
if extra:
input_args = input_args[:1] + extra + input_args[1:]
return input_args, proto
def protocol_help() -> list[dict[str, str]]:
return [
{
"id": "rtmp",
"label": "RTMP",
"example": "rtmp://127.0.0.1/live/stream",
"hint": "SRS / OBS / nginx-rtmp 等标准 RTMP 拉流",
},
{
"id": "rtmps",
"label": "RTMPS",
"example": "rtmps://live.example.com/app/key",
"hint": "TLS 加密的 RTMP",
},
{
"id": "srt",
"label": "SRT",
"example": "srt://127.0.0.1:10080?mode=caller&streamid=#!::r=live/obs,m=publish",
"hint": "低延迟 SRTSRS 常用 caller 模式",
},
{
"id": "hls",
"label": "HLS",
"example": "https://example.com/live/stream.m3u8",
"hint": "HTTP Live Streamingm3u8",
},
{
"id": "http-flv",
"label": "HTTP-FLV",
"example": "http://127.0.0.1:8080/live/stream.flv",
"hint": "SRS HTTP-FLV 等",
},
{
"id": "webrtc",
"label": "WebRTC (WHEP)",
"example": "whep://your-server/index/api/whep?app=live&stream=obs",
"hint": "需 FFmpeg 支持 WHEP否则请经 SRS 转为 RTMP/SRT 再拉",
},
{
"id": "udp",
"label": "UDP / MPEG-TS",
"example": "udp://@:1234",
"hint": "MPEG-TS over UDP",
},
]
def source_url_looks_valid(url: str) -> bool:
raw = (url or "").strip()
if not raw:
return False
if raw.startswith(("rtmp://", "rtmps://", "srt://", "udp://", "whep://", "webrtc://")):
return True
try:
p = urlparse(raw)
return p.scheme in ("http", "https") and bool(p.netloc)
except Exception:
return False