Mantiene la copertina YouTube se il telefono cade e avvia ffmpeg sul nodo, non via SSH.
Il relay HLS→RTMPS resta sul worker/agent del nodo assegnato, così tre dirette contemporanee restano in onda sul sito e sul canale della società senza schermo nero. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -96,6 +96,7 @@ STREAM_NODE_ENV=collaudo
|
||||
STREAM_CLOUD_PROVIDER=local_lab
|
||||
RELAY_MAX_CONCURRENT=4
|
||||
YOUTUBE_RELAY_WORKER=1
|
||||
# Worker home: STREAM_NODE_SLUG=home (default). Overflow: coda youtube_relay_<slug>
|
||||
|
||||
STREAM_AUTOSCALE_ENABLED=0
|
||||
STREAM_AUTOSCALE_KIND=lab
|
||||
|
||||
@@ -24,8 +24,14 @@ services:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
sidekiq:
|
||||
hostname: home
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
STREAM_NODE_SLUG: home
|
||||
YOUTUBE_RELAY_WORKER: "1"
|
||||
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
|
||||
volumes:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
hostname: home
|
||||
command: bundle exec sidekiq -C config/sidekiq.yml -e production
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
@@ -180,6 +181,9 @@ services:
|
||||
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||
YOUTUBE_RELAY_WORKER: "1"
|
||||
STREAM_NODE_SLUG: home
|
||||
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
|
||||
RAILS_INTERNAL_URL: http://rails:3000
|
||||
MEDIAMTX_HLS_URL: http://mediamtx:8888
|
||||
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
|
||||
@@ -46,6 +46,25 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Secondo ingest = nodo lab/cloud locale (RTMP host :11935).
|
||||
mediamtx_lab:
|
||||
image: bluenviron/mediamtx:latest
|
||||
ports:
|
||||
- "11935:1935"
|
||||
- "18888:8888"
|
||||
- "19997:9997"
|
||||
volumes:
|
||||
- ./mediamtx.yml:/mediamtx.yml:ro
|
||||
- ./slates:/slates:ro
|
||||
- recordings_lab:/recordings
|
||||
command: /mediamtx.yml
|
||||
healthcheck:
|
||||
test: ["CMD", "/mediamtx", "--help"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
rails:
|
||||
build:
|
||||
context: ../backend
|
||||
@@ -60,6 +79,34 @@ services:
|
||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
|
||||
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
|
||||
MEDIAMTX_LAB_API_URL: http://mediamtx_lab:9997
|
||||
MEDIAMTX_LAB_HLS_URL: http://mediamtx_lab:8888
|
||||
MEDIAMTX_LAB_INTERNAL_RTMP_URL: rtmp://mediamtx_lab:1935
|
||||
MEDIAMTX_LAB_RTMP_URL: rtmp://127.0.0.1:11935
|
||||
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-1}
|
||||
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
|
||||
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-lab}
|
||||
STREAM_LAB_MAX_PUBLISHERS: ${STREAM_LAB_MAX_PUBLISHERS:-2}
|
||||
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-1}
|
||||
STREAM_AUTOSCALE_KIND: ${STREAM_AUTOSCALE_KIND:-lab}
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD: ${STREAM_AUTOSCALE_ALLOW_CLOUD:-0}
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS: ${STREAM_AUTOSCALE_SOFT_FREE_SLOTS:-2}
|
||||
STREAM_AUTOSCALE_WARM_SPARE: ${STREAM_AUTOSCALE_WARM_SPARE:-1}
|
||||
STREAM_AUTOSCALE_MAX_NODES: ${STREAM_AUTOSCALE_MAX_NODES:-2}
|
||||
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-30}
|
||||
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
|
||||
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-lab}
|
||||
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
|
||||
STREAM_CLOUD_DNS_SUFFIX: ${STREAM_CLOUD_DNS_SUFFIX:-mltv-stream.net}
|
||||
STREAM_CLOUD_MAX_PUBLISHERS: ${STREAM_CLOUD_MAX_PUBLISHERS:-4}
|
||||
HCLOUD_TOKEN: ${HCLOUD_TOKEN:-}
|
||||
HCLOUD_LOCATION: ${HCLOUD_LOCATION:-nbg1}
|
||||
HCLOUD_SERVER_TYPE: ${HCLOUD_SERVER_TYPE:-cpx12}
|
||||
HCLOUD_IMAGE: ${HCLOUD_IMAGE:-debian-12}
|
||||
HCLOUD_SSH_KEY: ${HCLOUD_SSH_KEY:-matchlivetv-stream-hetzner}
|
||||
HCLOUD_NETWORK_ID: ${HCLOUD_NETWORK_ID:-}
|
||||
HCLOUD_USER_DATA_FILE: /opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
|
||||
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||
RAILS_WEBHOOK_URL: http://rails:3000
|
||||
@@ -100,12 +147,15 @@ services:
|
||||
condition: service_healthy
|
||||
mediamtx:
|
||||
condition: service_started
|
||||
mediamtx_lab:
|
||||
condition: service_started
|
||||
garage:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
- recordings:/recordings
|
||||
- bundle_cache:/usr/local/bundle
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/up"]
|
||||
interval: 15s
|
||||
@@ -118,6 +168,85 @@ services:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
command: bundle exec sidekiq -C config/sidekiq.yml
|
||||
hostname: home
|
||||
environment:
|
||||
RAILS_ENV: development
|
||||
BUNDLE_WITHOUT: ""
|
||||
BUNDLE_DEPLOYMENT: "0"
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||
MEDIAMTX_LAB_API_URL: http://mediamtx_lab:9997
|
||||
MEDIAMTX_LAB_HLS_URL: http://mediamtx_lab:8888
|
||||
MEDIAMTX_LAB_INTERNAL_RTMP_URL: rtmp://mediamtx_lab:1935
|
||||
MEDIAMTX_LAB_RTMP_URL: rtmp://127.0.0.1:11935
|
||||
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-1}
|
||||
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
|
||||
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-lab}
|
||||
STREAM_LAB_MAX_PUBLISHERS: ${STREAM_LAB_MAX_PUBLISHERS:-2}
|
||||
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-1}
|
||||
STREAM_AUTOSCALE_KIND: ${STREAM_AUTOSCALE_KIND:-lab}
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD: ${STREAM_AUTOSCALE_ALLOW_CLOUD:-0}
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS: ${STREAM_AUTOSCALE_SOFT_FREE_SLOTS:-2}
|
||||
STREAM_AUTOSCALE_WARM_SPARE: ${STREAM_AUTOSCALE_WARM_SPARE:-1}
|
||||
STREAM_AUTOSCALE_MAX_NODES: ${STREAM_AUTOSCALE_MAX_NODES:-2}
|
||||
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-30}
|
||||
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
|
||||
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-lab}
|
||||
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
|
||||
STREAM_CLOUD_DNS_SUFFIX: ${STREAM_CLOUD_DNS_SUFFIX:-mltv-stream.net}
|
||||
STREAM_CLOUD_MAX_PUBLISHERS: ${STREAM_CLOUD_MAX_PUBLISHERS:-4}
|
||||
HCLOUD_TOKEN: ${HCLOUD_TOKEN:-}
|
||||
HCLOUD_LOCATION: ${HCLOUD_LOCATION:-nbg1}
|
||||
HCLOUD_SERVER_TYPE: ${HCLOUD_SERVER_TYPE:-cpx12}
|
||||
HCLOUD_IMAGE: ${HCLOUD_IMAGE:-debian-12}
|
||||
HCLOUD_SSH_KEY: ${HCLOUD_SSH_KEY:-matchlivetv-stream-hetzner}
|
||||
HCLOUD_NETWORK_ID: ${HCLOUD_NETWORK_ID:-}
|
||||
HCLOUD_USER_DATA_FILE: /opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||
RAILS_WEBHOOK_URL: http://rails:3000
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
|
||||
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
|
||||
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
|
||||
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-http://localhost:3000/api/v1/youtube/callback}
|
||||
YOUTUBE_RELAY_WORKER: "1"
|
||||
STREAM_NODE_SLUG: home
|
||||
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
|
||||
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
|
||||
MEDIAMTX_HLS_URL: http://mediamtx:8888
|
||||
RECORDINGS_PATH: /recordings
|
||||
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
|
||||
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|
||||
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
|
||||
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||
depends_on:
|
||||
rails:
|
||||
condition: service_healthy
|
||||
garage:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
- recordings:/recordings
|
||||
- bundle_cache:/usr/local/bundle
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
# Worker ffmpeg per il secondo MediaMTX (nodo lab locale, RTMP host :11935).
|
||||
# STREAM_NODE_SLUG=ingest-lab-01 docker compose --profile stream-node up -d sidekiq_relay
|
||||
sidekiq_relay:
|
||||
profiles: ["stream-node"]
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
command:
|
||||
- bash
|
||||
- -lc
|
||||
- bundle exec sidekiq -c $${RELAY_MAX_CONCURRENT:-2} -q youtube_relay_$${STREAM_NODE_SLUG} -e $${RAILS_ENV:-development}
|
||||
hostname: ${STREAM_NODE_SLUG:-ingest-lab-01}
|
||||
environment:
|
||||
RAILS_ENV: development
|
||||
BUNDLE_WITHOUT: ""
|
||||
@@ -131,6 +260,17 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
|
||||
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
|
||||
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
||||
YOUTUBE_RELAY_WORKER: "1"
|
||||
STREAM_NODE_SLUG: ${STREAM_NODE_SLUG:-ingest-lab-01}
|
||||
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx_lab:1935
|
||||
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx_lab:8888
|
||||
STREAM_NODE_REMOTE_SSH: ${STREAM_NODE_REMOTE_SSH:-}
|
||||
STREAM_NODE_SSH_KEY: ${STREAM_NODE_SSH_KEY:-/root/.ssh/id_ed25519}
|
||||
STREAM_NODE_RELAY_AGENT_URL: ${STREAM_NODE_RELAY_AGENT_URL:-}
|
||||
STREAM_NODE_AGENT_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
|
||||
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx_lab:1935
|
||||
MEDIAMTX_HLS_URL: http://mediamtx_lab:8888
|
||||
RELAY_MAX_CONCURRENT: ${RELAY_MAX_CONCURRENT:-2}
|
||||
RECORDINGS_PATH: /recordings
|
||||
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
|
||||
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|
||||
@@ -171,6 +311,7 @@ volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
recordings:
|
||||
recordings_lab:
|
||||
bundle_cache:
|
||||
garage_meta:
|
||||
garage_data:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#cloud-config
|
||||
# Nodo stream Hetzner: MediaMTX + ffmpeg.
|
||||
# Nodo stream Hetzner: MediaMTX + ffmpeg + relay-agent (YouTube sul nodo).
|
||||
# HCLOUD_USER_DATA_FILE=.../infra/stream-node/cloud-init.yaml
|
||||
|
||||
package_update: true
|
||||
@@ -8,14 +8,14 @@ packages:
|
||||
- ffmpeg
|
||||
- curl
|
||||
- apparmor
|
||||
- python3
|
||||
- fonts-dejavu-core
|
||||
|
||||
write_files:
|
||||
- path: /opt/stream-node/mediamtx.yml
|
||||
permissions: "0644"
|
||||
content: |
|
||||
logLevel: info
|
||||
# Allinea a infra/mediamtx.yml: API raggiungibile anche da IP non-localhost
|
||||
# (in prod restringere via WireGuard / firewall Hetzner).
|
||||
authInternalUsers:
|
||||
- user: any
|
||||
pass:
|
||||
@@ -34,34 +34,61 @@ write_files:
|
||||
hlsAddress: :8888
|
||||
paths:
|
||||
all_others:
|
||||
- path: /opt/stream-node/relay-agent.service
|
||||
permissions: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Match Live TV YouTube relay agent
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-/opt/stream-node/agent.env
|
||||
ExecStart=/usr/bin/python3 /opt/stream-node/relay-agent.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
- path: /opt/stream-node/agent.env
|
||||
permissions: "0600"
|
||||
content: |
|
||||
STREAM_NODE_AGENT_SECRET=mediamtx_webhook_dev_secret
|
||||
STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100
|
||||
STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
|
||||
STREAM_NODE_RELAY_LOG_DIR=/var/log
|
||||
- path: /opt/stream-node/bootstrap.sh
|
||||
permissions: "0755"
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
mkdir -p /recordings /slates
|
||||
mkdir -p /recordings /slates /var/log
|
||||
systemctl enable --now docker
|
||||
# aspetta il socket docker
|
||||
for i in $(seq 1 60); do
|
||||
if docker info >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
docker pull bluenviron/mediamtx:latest
|
||||
docker rm -f mediamtx 2>/dev/null || true
|
||||
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path.
|
||||
# MediaMTX 1.20+: AAC del publisher RTMP deve matchare la slate.
|
||||
# Allineato ad app Android (BroadcastConfig: 48 kHz mono) e infra/scripts/generate_slate.sh.
|
||||
mkdir -p /slates
|
||||
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=48000:cl=mono \
|
||||
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 1 -ar 48000 -shortest /slates/offline.mp4
|
||||
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
||||
FONT=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
|
||||
# Copertina alwaysAvailable (NON nera): resta in HLS/YouTube se l'app cade.
|
||||
ffmpeg -y -f lavfi -i color=c=0x0a0a0e:s=1280x720:r=30:d=30 \
|
||||
-f lavfi -i anullsrc=r=48000:cl=mono \
|
||||
-vf "drawtext=fontfile=${FONT}:text='Match Live TV':fontsize=64:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2-48,drawtext=fontfile=${FONT}:text='Trasmissione in pausa':fontsize=36:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2+36" \
|
||||
-c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 \
|
||||
-x264-params "keyint=30:min-keyint=30:scenecut=0:bframes=0" \
|
||||
-g 30 -keyint_min 30 -preset fast \
|
||||
-c:a aac -b:a 128k -ac 1 -ar 48000 -shortest /slates/offline.mp4
|
||||
docker run -d --name mediamtx --restart unless-stopped --network host \
|
||||
--security-opt apparmor=unconfined \
|
||||
-v /opt/stream-node/mediamtx.yml:/mediamtx.yml:ro \
|
||||
-v /recordings:/recordings \
|
||||
-v /slates:/slates:ro \
|
||||
bluenviron/mediamtx:latest /mediamtx.yml
|
||||
# smoke locale
|
||||
if [[ -f /opt/stream-node/relay-agent.py ]]; then
|
||||
cp /opt/stream-node/relay-agent.service /etc/systemd/system/mltv-relay-agent.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mltv-relay-agent.service
|
||||
fi
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:9997/v3/paths/list >/dev/null; then
|
||||
echo "mediamtx ready" | tee /opt/stream-node/ready
|
||||
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Avvia/ferma ffmpeg sul nodo stream (loopback MediaMTX → YouTube).
|
||||
Il control plane chiama questo agent all'avvio della diretta; ffmpeg resta sul CPX.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "")
|
||||
LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100")
|
||||
HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/")
|
||||
LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log")
|
||||
|
||||
_lock = threading.Lock()
|
||||
# session_id -> {"pid": int, "path": str, "proc": Popen}
|
||||
_relays: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _authorized(handler: BaseHTTPRequestHandler) -> bool:
|
||||
if not SECRET:
|
||||
return True
|
||||
hdr = handler.headers.get("Authorization", "")
|
||||
return hdr == f"Bearer {SECRET}"
|
||||
|
||||
|
||||
def _ffmpeg_cmd(path: str, rtmps: str) -> list[str]:
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-fflags",
|
||||
"+genpts+discardcorrupt",
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_on_network_error",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"2",
|
||||
"-rw_timeout",
|
||||
"15000000",
|
||||
"-live_start_index",
|
||||
"-1",
|
||||
"-i",
|
||||
f"{HLS_BASE}/{path}/index.m3u8",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-bsf:a",
|
||||
"aac_adtstoasc",
|
||||
"-f",
|
||||
"flv",
|
||||
rtmps,
|
||||
]
|
||||
|
||||
|
||||
def _alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _stop_session(session_id: str) -> None:
|
||||
info = _relays.pop(session_id, None)
|
||||
if not info:
|
||||
return
|
||||
proc = info.get("proc")
|
||||
pid = int(info.get("pid") or 0)
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
elif pid:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _start_session(session_id: str, path: str, rtmps: str) -> int:
|
||||
existing = _relays.get(session_id)
|
||||
if existing:
|
||||
proc = existing.get("proc")
|
||||
pid = int(existing.get("pid") or 0)
|
||||
if proc is not None and proc.poll() is None and existing.get("path") == path:
|
||||
return pid
|
||||
_stop_session(session_id)
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
safe = path.replace("/", "_")
|
||||
log_path = os.path.join(LOG_DIR, f"youtube-relay-{safe}.log")
|
||||
log = open(log_path, "ab", buffering=0)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(path, rtmps),
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
start_new_session=True,
|
||||
)
|
||||
_relays[session_id] = {"pid": proc.pid, "path": path, "proc": proc}
|
||||
return proc.pid
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
print(f"[relay-agent] {self.address_string()} {fmt % args}")
|
||||
|
||||
def _json(self, code: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path in ("/health", "/"):
|
||||
self._json(200, {"ok": True, "relays": len(_relays)})
|
||||
return
|
||||
if not _authorized(self):
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if parsed.path.startswith("/relays/"):
|
||||
session_id = parsed.path.split("/relays/", 1)[1].strip("/")
|
||||
with _lock:
|
||||
info = _relays.get(session_id)
|
||||
running = bool(info and _alive(int(info["pid"])))
|
||||
if info and not running:
|
||||
_relays.pop(session_id, None)
|
||||
if not running:
|
||||
self._json(404, {"running": False, "session_id": session_id})
|
||||
return
|
||||
self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not _authorized(self):
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if urlparse(self.path).path != "/relays":
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length) or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
self._json(400, {"error": "invalid json"})
|
||||
return
|
||||
session_id = str(data.get("session_id") or "").strip()
|
||||
path = str(data.get("path") or "").strip()
|
||||
rtmps = str(data.get("rtmps") or "").strip()
|
||||
if not session_id or not path or not rtmps.startswith("rtmps://"):
|
||||
self._json(400, {"error": "session_id, path, rtmps required"})
|
||||
return
|
||||
with _lock:
|
||||
pid = _start_session(session_id, path, rtmps)
|
||||
self._json(201, {"pid": pid, "session_id": session_id, "path": path})
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
if not _authorized(self):
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
parsed = urlparse(self.path)
|
||||
if not parsed.path.startswith("/relays/"):
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
session_id = parsed.path.split("/relays/", 1)[1].strip("/")
|
||||
with _lock:
|
||||
_stop_session(session_id)
|
||||
self._json(200, {"stopped": True, "session_id": session_id})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host, port_s = LISTEN.rsplit(":", 1)
|
||||
httpd = ThreadingHTTPServer((host, int(port_s)), Handler)
|
||||
print(f"[relay-agent] listen {LISTEN} hls={HLS_BASE}")
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user