支持 RTMP/SRT/HLS/FLV/WHEP 拉流到 YouTube;摄像头与拉流页采用独立工作台布局,更新 README 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
292 lines
8.4 KiB
Python
292 lines
8.4 KiB
Python
"""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
|