新增拉流转推功能,并按业务重设计摄像头/拉流独立界面
支持 RTMP/SRT/HLS/FLV/WHEP 拉流到 YouTube;摄像头与拉流页采用独立工作台布局,更新 README 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
291
web2_pull_stream.py
Normal file
291
web2_pull_stream.py
Normal 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": "低延迟 SRT;SRS 常用 caller 模式",
|
||||
},
|
||||
{
|
||||
"id": "hls",
|
||||
"label": "HLS",
|
||||
"example": "https://example.com/live/stream.m3u8",
|
||||
"hint": "HTTP Live Streaming(m3u8)",
|
||||
},
|
||||
{
|
||||
"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
|
||||
Reference in New Issue
Block a user