feat(nginx): add Docker setup for Nginx with templated configuration
- Create Dockerfile for Nginx with envsubst for dynamic configuration. - Add djmaster.conf.template for Nginx configuration with upstream services. - Implement docker-entrypoint.sh to substitute environment variables in the Nginx config. - Add README.md in nginx-examples for guidance on using the Nginx template. - Include djmaster.conf.template in nginx-examples for local setup. - Introduce utility functions for fetching YouTube video snippets and titles.
This commit is contained in:
parent
5c586a2aa3
commit
bc97ee0a68
@ -1,9 +1,11 @@
|
|||||||
REACT_APP_CORE_URL=http://192.168.1.15:8080
|
# Local overrides (gitignored). Put real secrets here on your development machine.
|
||||||
|
REACT_APP_YOUTUBE_API_KEY="AIzaSyABiXKk-1tcoR0wQnccZfutBDi0ijTr0Ko"
|
||||||
|
REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net
|
||||||
REACT_APP_WHIP_BASE_URL=http://192.168.1.15:8555
|
REACT_APP_WHIP_BASE_URL=http://192.168.1.15:8555
|
||||||
REACT_APP_YTDLP_URL=http://192.168.1.20:8282
|
REACT_APP_YTDLP_URL=http://144.217.82.82:8282
|
||||||
|
REACT_APP_YTDLP_URL_TITLES=http://100.73.244.28:8080
|
||||||
REACT_APP_FB_SERVER_URL=http://localhost:3002
|
REACT_APP_FB_SERVER_URL=http://localhost:3002
|
||||||
REACT_APP_LIVEKIT_API_KEY=APIBTqTGxf9htMK
|
REACT_APP_LIVEKIT_API_KEY=APIBTqTGxf9htMK
|
||||||
REACT_APP_LIVEKIT_API_SECRET=0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
|
REACT_APP_LIVEKIT_API_SECRET=0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
|
||||||
REACT_APP_LIVEKIT_WS_URL=wss://livekit-server.nextream.sytes.net
|
REACT_APP_LIVEKIT_WS_URL=wss://livekit-server.nextream.sytes.net
|
||||||
REACT_APP_WHIP_SERVER_URL=https://djmaster.nextream.sytes.net
|
REACT_APP_WHIP_SERVER_URL=https://djmaster.nextream.sytes.net
|
||||||
C
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,7 +18,7 @@ NONPUBLIC
|
|||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
90
Caddyfile
90
Caddyfile
@ -5,85 +5,89 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:3000
|
djmaster.nextream.sytes.net
|
||||||
|
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# ── Facebook OAuth2 microserver (Node.js en puerto 3002) ──────────────────────
|
# ── Facebook OAuth2 microserver (Node.js en puerto 3002) ──────────────────────
|
||||||
handle /fb-server/* {
|
handle /fb-server/* {
|
||||||
uri strip_prefix /fb-server
|
uri strip_prefix /fb-server
|
||||||
reverse_proxy 127.0.0.1:3002
|
reverse_proxy restreamer-ui:3002
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── LiveKit token endpoint (Node.js en puerto 3002) ───────────────────────────
|
# ── LiveKit token endpoint (Node.js en puerto 3002) ───────────────────────────
|
||||||
# POST /livekit/token → genera AccessToken JWT firmado
|
# POST /livekit/token → genera AccessToken JWT firmado
|
||||||
# GET /livekit/config → devuelve wsUrl público (sin secretos)
|
# GET /livekit/config → devuelve wsUrl público (sin secretos)
|
||||||
handle /livekit/* {
|
handle /livekit/* {
|
||||||
reverse_proxy 127.0.0.1:3002
|
reverse_proxy restreamer-ui:3002
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── WebRTC relay WebSocket + status (Node.js en puerto 3002) ─────────────────
|
# ── WebRTC relay WebSocket + status (Node.js en puerto 3002) ─────────────────
|
||||||
# 127.0.0.1 evita problema de resolución IPv6 en Alpine ("localhost" → ::1)
|
# 127.0.0.1 evita problema de resolución IPv6 en Alpine ("localhost" → ::1)
|
||||||
# HTTP/1.1 necesario para WebSocket upgrade (Caddy requiere versión explícita)
|
# HTTP/1.1 necesario para WebSocket upgrade (Caddy requiere versión explícita)
|
||||||
handle /webrtc-relay/* {
|
handle /webrtc-relay/* {
|
||||||
reverse_proxy 127.0.0.1:3002 {
|
reverse_proxy restreamer-ui:3002 {
|
||||||
transport http {
|
transport http {
|
||||||
versions 1.1
|
versions 1.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── WebRTC Room HTML (sala para el presentador) ───────────────────────────────
|
# ── WebRTC Room HTML (sala para el presentador) ───────────────────────────────
|
||||||
# Sirve la página estática sin fallback al index.html de la SPA
|
# Sirve la página estática sin fallback al index.html de la SPA
|
||||||
handle /webrtc-room/* {
|
handle /webrtc-room/* {
|
||||||
root * /ui/build
|
reverse_proxy restreamer-ui:3000
|
||||||
file_server
|
|
||||||
}
|
}
|
||||||
handle /webrtc-room {
|
handle /webrtc-room {
|
||||||
redir /webrtc-room/ 302
|
redir /webrtc-room/ 302
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── yt-dlp stream extractor (servicio externo configurable via env) ───────────
|
# ── yt-dlp stream extractor (servicio externo configurable via env) ───────────
|
||||||
# /yt-stream/{VIDEO_ID} → http://YTDLP_HOST/stream/{VIDEO_ID}
|
# /yt-stream/{VIDEO_ID} → http://YTDLP_HOST/stream/{VIDEO_ID}
|
||||||
# yt-dlp puede tardar 20-30s — timeouts extendidos a 120s
|
# yt-dlp puede tardar 20-30s — timeouts extendidos a 120s
|
||||||
handle_path /yt-stream/* {
|
handle_path /yt-stream/* {
|
||||||
rewrite * /stream{path}
|
rewrite * /stream{path}
|
||||||
reverse_proxy {env.YTDLP_HOST} {
|
reverse_proxy {env.YTDLP_HOST} {
|
||||||
transport http {
|
transport http {
|
||||||
dial_timeout 10s
|
dial_timeout 10s
|
||||||
response_header_timeout 120s
|
response_header_timeout 120s
|
||||||
read_timeout 120s
|
read_timeout 120s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── yt-dlp titles proxy (map /yt-titles/{id} → internal /stream/{id}) ───────
|
||||||
|
# Some deployments expose metadata at /stream; proxy to the internal titles service.
|
||||||
|
handle_path /yt-titles/* {
|
||||||
|
rewrite * /stream{path}
|
||||||
|
reverse_proxy {env.YTDLP_TITLES_HOST} {
|
||||||
|
transport http {
|
||||||
|
versions 1.1
|
||||||
|
dial_timeout 10s
|
||||||
|
response_header_timeout 15s
|
||||||
|
read_timeout 15s
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# OAuth2 callback page — must be served as a static HTML (not the SPA index)
|
# OAuth2 callback page — must be served as a static HTML (not the SPA index)
|
||||||
handle /oauth2callback {
|
handle /oauth2callback {
|
||||||
rewrite * /oauth2callback.html
|
rewrite * /oauth2callback.html
|
||||||
file_server {
|
reverse_proxy restreamer-ui:3000
|
||||||
root /ui/build
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Facebook OAuth2 callback popup — soporta tanto .html como .htm
|
# Facebook OAuth2 callback popup — soporta tanto .html como .htm
|
||||||
# .html → servir directamente
|
|
||||||
handle /oauth/facebook/callback.html {
|
handle /oauth/facebook/callback.html {
|
||||||
file_server {
|
reverse_proxy restreamer-ui:3000
|
||||||
root /ui/build
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# .htm → reescribir internamente a .html (misma página, misma URL visible para Facebook)
|
|
||||||
handle /oauth/facebook/callback.htm {
|
handle /oauth/facebook/callback.htm {
|
||||||
rewrite * /oauth/facebook/callback.html
|
rewrite * /oauth/facebook/callback.html
|
||||||
file_server {
|
reverse_proxy restreamer-ui:3000
|
||||||
root /ui/build
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sin extensión → redirigir a .html
|
|
||||||
handle /oauth/facebook/callback {
|
handle /oauth/facebook/callback {
|
||||||
redir /oauth/facebook/callback.html{query} 302
|
redir /oauth/facebook/callback.html{query} 302
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── LiveKit Ingress WHIP proxy: OBS publica vía WHIP al mismo dominio ─────────
|
# ── LiveKit Ingress WHIP proxy: OBS publica vía WHIP al mismo dominio ─────────
|
||||||
@ -91,26 +95,24 @@ handle /oauth/facebook/callback {
|
|||||||
# Caddy lo reenvía al servicio livekit-ingress interno (solo accesible localmente).
|
# Caddy lo reenvía al servicio livekit-ingress interno (solo accesible localmente).
|
||||||
# LIVEKIT_INGRESS_HOST se configura en docker-compose (p.ej. 192.168.1.20:8088).
|
# LIVEKIT_INGRESS_HOST se configura en docker-compose (p.ej. 192.168.1.20:8088).
|
||||||
handle /w/* {
|
handle /w/* {
|
||||||
reverse_proxy {env.LIVEKIT_INGRESS_HOST} {
|
reverse_proxy {env.LIVEKIT_INGRESS_HOST} {
|
||||||
header_up Host {upstream_hostport}
|
header_up Host {upstream_hostport}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── WHIP info API: genera sesión Ingress (Node en :3002) ─────────────────────
|
# ── WHIP info API: genera sesión Ingress (Node en :3002) ─────────────────────
|
||||||
handle /api/whip/* {
|
handle /api/whip/* {
|
||||||
reverse_proxy 127.0.0.1:3002
|
reverse_proxy restreamer-ui:3002
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── WHEP relay proxy: Core hace pull aquí → egress server ───────────────────
|
# ── WHEP relay proxy: Core hace pull aquí → egress server ───────────────────
|
||||||
# Core input: https://djmaster.nextream.sytes.net/whep/rooms/<channelId>
|
# Core input: https://djmaster.nextream.sytes.net/whep/rooms/<channelId>
|
||||||
# EGRESS_HOST se configura en docker-compose (URL del servidor egress).
|
# EGRESS_HOST se configura en docker-compose (URL del servidor egress).
|
||||||
handle /whep/* {
|
handle /whep/* {
|
||||||
reverse_proxy {env.EGRESS_HOST}
|
reverse_proxy {env.EGRESS_HOST}
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA — serve static files, fallback to index.html for client-side routing
|
# SPA — proxy al servidor interno de la aplicación (serve -s build)
|
||||||
handle {
|
handle {
|
||||||
root * /ui/build
|
reverse_proxy restreamer-ui:3000
|
||||||
try_files {path} /index.html
|
|
||||||
file_server
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
restreamer-ui:
|
restreamer-ui:
|
||||||
# NOTA: Primero compila con: yarn build
|
# Build image from repository so `docker compose up --build` works on another host
|
||||||
# Luego construye la imagen con: docker build --tag restreamer-ui-v2:latest .
|
build:
|
||||||
# O usa el script: build-docker.bat
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
image: restreamer-ui-v2:latest
|
image: restreamer-ui-v2:latest
|
||||||
container_name: restreamer-ui
|
container_name: restreamer-ui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
- "3002:3002"
|
||||||
environment:
|
environment:
|
||||||
# ── Restreamer Core ────────────────────────────────────────────────────
|
# ── Restreamer Core ────────────────────────────────────────────────────
|
||||||
# URL del Core al que se conecta la UI. Dejar vacío para auto-detectar
|
# URL del Core al que se conecta la UI. Dejar vacío para auto-detectar
|
||||||
@ -22,6 +24,11 @@ services:
|
|||||||
# YTDLP_URL: URL completa del servicio yt-dlp vista desde el NAVEGADOR.
|
# YTDLP_URL: URL completa del servicio yt-dlp vista desde el NAVEGADOR.
|
||||||
# Dejar vacío → la UI usará /yt-stream/ (Caddy proxy, mismo origen = sin CORS).
|
# Dejar vacío → la UI usará /yt-stream/ (Caddy proxy, mismo origen = sin CORS).
|
||||||
YTDLP_URL: ""
|
YTDLP_URL: ""
|
||||||
|
# Host:puerto del servicio titles (usado por Caddy para /yt-titles proxy)
|
||||||
|
YTDLP_TITLES_HOST: "100.73.244.28:8080"
|
||||||
|
|
||||||
|
# YouTube Data API key (used by the UI to fetch title/description)
|
||||||
|
YOUTUBE_API_KEY: "AIzaSyABiXKk-1tcoR0wQnccZfutBDi0ijTr0Ko"
|
||||||
|
|
||||||
# ── Facebook OAuth2 microserver ────────────────────────────────────────
|
# ── Facebook OAuth2 microserver ────────────────────────────────────────
|
||||||
# Dejar vacío → Caddy proxy /fb-server → localhost:3002 (sin CORS)
|
# Dejar vacío → Caddy proxy /fb-server → localhost:3002 (sin CORS)
|
||||||
@ -74,6 +81,9 @@ services:
|
|||||||
# devices:
|
# devices:
|
||||||
# - "/dev/video1:/dev/video1" # Descomentar si hay cámara USB disponible
|
# - "/dev/video1:/dev/video1" # Descomentar si hay cámara USB disponible
|
||||||
|
|
||||||
|
# Nginx service removed — using external reverse proxy instead
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
restreamer-ui-fb-data:
|
restreamer-ui-fb-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
# If you previously used caddy, you can remove caddy_data/caddy_config volumes.
|
||||||
|
|||||||
@ -11,9 +11,11 @@ cat > "$CONFIG_FILE" <<EOF
|
|||||||
window.__RESTREAMER_CONFIG__ = {
|
window.__RESTREAMER_CONFIG__ = {
|
||||||
CORE_ADDRESS: "${CORE_ADDRESS:-}",
|
CORE_ADDRESS: "${CORE_ADDRESS:-}",
|
||||||
YTDLP_URL: "${YTDLP_URL:-}",
|
YTDLP_URL: "${YTDLP_URL:-}",
|
||||||
|
YTDLP_TITLES_URL: "${YTDLP_TITLES_URL:-}",
|
||||||
FB_SERVER_URL: "${FB_SERVER_URL:-}",
|
FB_SERVER_URL: "${FB_SERVER_URL:-}",
|
||||||
FB_OAUTH_CALLBACK_URL: "${FB_OAUTH_CALLBACK_URL:-}",
|
FB_OAUTH_CALLBACK_URL: "${FB_OAUTH_CALLBACK_URL:-}",
|
||||||
YT_OAUTH_CALLBACK_URL: "${YT_OAUTH_CALLBACK_URL:-}",
|
YT_OAUTH_CALLBACK_URL: "${YT_OAUTH_CALLBACK_URL:-}",
|
||||||
|
YOUTUBE_API_KEY: "${YOUTUBE_API_KEY:-}",
|
||||||
};
|
};
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
15
docker/nginx/Dockerfile
Normal file
15
docker/nginx/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
# Install envsubst for templating the nginx config
|
||||||
|
RUN apk add --no-cache gettext
|
||||||
|
|
||||||
|
WORKDIR /etc/nginx
|
||||||
|
|
||||||
|
# Template will be processed at container start
|
||||||
|
COPY djmaster.conf.template /etc/nginx/templates/djmaster.conf.template
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
107
docker/nginx/djmaster.conf.template
Normal file
107
docker/nginx/djmaster.conf.template
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream restreamer_ui_3000 { server restreamer-ui:3000; }
|
||||||
|
upstream restreamer_ui_3002 { server restreamer-ui:3002; }
|
||||||
|
upstream yt_dlp_stream { server ${YTDLP_HOST}; }
|
||||||
|
upstream yt_dlp_titles { server ${YTDLP_TITLES_HOST}; }
|
||||||
|
upstream egress_host { server ${EGRESS_HOST}; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2 default_server;
|
||||||
|
server_name ${UI_HOST};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/${UI_HOST}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/${UI_HOST}/privkey.pem;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# FB server microservice: strip /fb-server/ prefix
|
||||||
|
location /fb-server/ {
|
||||||
|
rewrite ^/fb-server/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://restreamer_ui_3002/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# LiveKit & WebRTC relay (websocket support)
|
||||||
|
location /livekit/ {
|
||||||
|
proxy_pass http://restreamer_ui_3002/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
location /webrtc-relay/ {
|
||||||
|
proxy_pass http://restreamer_ui_3002/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebRTC room static proxied from the SPA server
|
||||||
|
location /webrtc-room/ {
|
||||||
|
proxy_pass http://restreamer_ui_3000/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# yt-dlp stream extractor (long timeouts)
|
||||||
|
location /yt-stream/ {
|
||||||
|
rewrite ^/yt-stream/(.*)$ /stream/$1 break;
|
||||||
|
proxy_pass http://yt_dlp_stream/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# yt-dlp titles proxy (metadata)
|
||||||
|
location /yt-titles/ {
|
||||||
|
rewrite ^/yt-titles/(.*)$ /stream/$1 break;
|
||||||
|
proxy_pass http://yt_dlp_titles/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# LiveKit ingress (WHIP) — adjust upstream to your LIVEKIT_INGRESS_HOST
|
||||||
|
location /w/ {
|
||||||
|
proxy_pass http://$LIVEKIT_INGRESS_HOST/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/whip/ {
|
||||||
|
proxy_pass http://restreamer_ui_3002/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WHEP relay to egress host
|
||||||
|
location /whep/ {
|
||||||
|
proxy_pass http://egress_host/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OAuth callback pages proxied to SPA server
|
||||||
|
location = /oauth2callback {
|
||||||
|
proxy_pass http://restreamer_ui_3000/oauth2callback.html;
|
||||||
|
}
|
||||||
|
location = /oauth/facebook/callback.html {
|
||||||
|
proxy_pass http://restreamer_ui_3000/oauth/facebook/callback.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
proxy_pass http://restreamer_ui_3000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_read_timeout 90s;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
docker/nginx/docker-entrypoint.sh
Normal file
14
docker/nginx/docker-entrypoint.sh
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Provide defaults
|
||||||
|
: ${YTDLP_HOST:=192.168.1.20:8282}
|
||||||
|
: ${YTDLP_TITLES_HOST:=100.73.244.28:8080}
|
||||||
|
: ${LIVEKIT_INGRESS_HOST:=192.168.1.20:8088}
|
||||||
|
: ${EGRESS_HOST:=llmchats-whep.zuqtxy.easypanel.host}
|
||||||
|
: ${UI_HOST:=djmaster.nextream.sytes.net}
|
||||||
|
|
||||||
|
# Replace template variables and write final nginx conf
|
||||||
|
envsubst '$YTDLP_HOST $YTDLP_TITLES_HOST $LIVEKIT_INGRESS_HOST $EGRESS_HOST $UI_HOST' < /etc/nginx/templates/djmaster.conf.template > /etc/nginx/conf.d/djmaster.conf
|
||||||
|
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
28
nginx-examples/README.md
Normal file
28
nginx-examples/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Nginx examples for ReStreamer UI
|
||||||
|
|
||||||
|
This folder contains minimal example Nginx configuration files to run a reverse proxy for the ReStreamer UI and its microserver. Use them as templates — replace variables and paths to match your server.
|
||||||
|
|
||||||
|
Quick steps
|
||||||
|
|
||||||
|
1. Copy `djmaster.conf.template` to the target server (e.g. `/tmp`).
|
||||||
|
2. Set variables and generate the real config with `envsubst` (or edit manually):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export UI_HOST=djmaster.nextream.sytes.net
|
||||||
|
export YTDLP_HOST=144.217.82.82:8282
|
||||||
|
export YTDLP_TITLES_HOST=100.73.244.28:8080
|
||||||
|
export LIVEKIT_INGRESS_HOST=192.168.1.20:8088
|
||||||
|
export LETSENCRYPT_PATH=/etc/letsencrypt
|
||||||
|
envsubst < djmaster.conf.template > /etc/nginx/sites-available/djmaster.conf
|
||||||
|
ln -s /etc/nginx/sites-available/djmaster.conf /etc/nginx/sites-enabled/
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- The template proxies the UI to `127.0.0.1:3000` and the microserver (OAuth/persistence) to `127.0.0.1:3002` by default.
|
||||||
|
- `/yt-stream/` and `/yt-titles/` are proxied to external YTDLP hosts to avoid CORS from the browser.
|
||||||
|
- If you want automatic HTTPS, use `certbot --nginx -d $UI_HOST` (adjust paths to cert files as needed).
|
||||||
|
- Ensure `client_max_body_size` and timeouts fit your needs (examples set moderately large values).
|
||||||
|
|
||||||
|
If you want me to adapt these to your exact hostnames/paths, tell me the values and I can render the final `djmaster.conf` for you.
|
||||||
94
nginx-examples/djmaster.conf.template
Normal file
94
nginx-examples/djmaster.conf.template
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# djmaster Nginx template
|
||||||
|
# Replace variables (or use `envsubst`) then install as an Nginx site.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${UI_HOST};
|
||||||
|
|
||||||
|
# ACME challenge served from this location (used by certbot)
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/letsencrypt;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect all other traffic to HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name ${UI_HOST};
|
||||||
|
|
||||||
|
ssl_certificate ${LETSENCRYPT_PATH}/live/${UI_HOST}/fullchain.pem;
|
||||||
|
ssl_certificate_key ${LETSENCRYPT_PATH}/live/${UI_HOST}/privkey.pem;
|
||||||
|
include ${LETSENCRYPT_PATH}/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam ${LETSENCRYPT_PATH}/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Serve the frontend (CRA dev/build) proxied to local UI server
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Microserver for OAuth and config persistence
|
||||||
|
location /fb-server/ {
|
||||||
|
proxy_pass http://127.0.0.1:3002/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# YT-DLP stream proxy (avoid CORS in browser)
|
||||||
|
location /yt-stream/ {
|
||||||
|
proxy_pass http://${YTDLP_HOST}/;
|
||||||
|
proxy_set_header Host ${YTDLP_HOST};
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# YT-DLP titles/metadata proxy
|
||||||
|
location /yt-titles/ {
|
||||||
|
proxy_pass http://${YTDLP_TITLES_HOST}/;
|
||||||
|
proxy_set_header Host ${YTDLP_TITLES_HOST};
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# LiveKit ingress (if used)
|
||||||
|
location /livekit-ingress/ {
|
||||||
|
proxy_pass http://${LIVEKIT_INGRESS_HOST}/;
|
||||||
|
proxy_set_header Host ${LIVEKIT_INGRESS_HOST};
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tuning
|
||||||
|
client_max_body_size 200M;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# Basic security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade";
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@
|
|||||||
window.__RESTREAMER_CONFIG__ = {
|
window.__RESTREAMER_CONFIG__ = {
|
||||||
CORE_ADDRESS: '',
|
CORE_ADDRESS: '',
|
||||||
YTDLP_URL: '',
|
YTDLP_URL: '',
|
||||||
|
YTDLP_TITLES_URL: '',
|
||||||
FB_SERVER_URL: '',
|
FB_SERVER_URL: '',
|
||||||
// URL pública del servidor egress (WHIP ingest + WHEP relay).
|
// URL pública del servidor egress (WHIP ingest + WHEP relay).
|
||||||
// Ej: 'https://llmchats-whep.zuqtxy.easypanel.host'
|
// Ej: 'https://llmchats-whep.zuqtxy.easypanel.host'
|
||||||
|
|||||||
@ -368,6 +368,7 @@ app.get('/health', (_, res) => {
|
|||||||
res.json({ ok: true, config: CFG_PATH, port: PORT, ts: new Date().toISOString() });
|
res.json({ ok: true, config: CFG_PATH, port: PORT, ts: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// WHIP INGRESS
|
// WHIP INGRESS
|
||||||
// Genera una sesión LiveKit Ingress (WHIP_INPUT) y devuelve al browser
|
// Genera una sesión LiveKit Ingress (WHIP_INPUT) y devuelve al browser
|
||||||
|
|||||||
@ -5,6 +5,15 @@ import makeStyles from '@mui/styles/makeStyles';
|
|||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||||
|
|
||||||
|
import { fetchYtTitles } from '../../utils/ytdlp';
|
||||||
|
import { fetchYoutubeSnippet } from '../../utils/youtube';
|
||||||
|
|
||||||
import TabPanel from '../TabPanel';
|
import TabPanel from '../TabPanel';
|
||||||
import TabsHorizontal from '../TabsHorizontal';
|
import TabsHorizontal from '../TabsHorizontal';
|
||||||
@ -21,6 +30,8 @@ function init(settings) {
|
|||||||
const initSettings = {
|
const initSettings = {
|
||||||
name: 'Livestream',
|
name: 'Livestream',
|
||||||
description: 'Live from earth. Powered by datarhei Restreamer.',
|
description: 'Live from earth. Powered by datarhei Restreamer.',
|
||||||
|
youtube_id: '',
|
||||||
|
youtube_url: '',
|
||||||
author: {},
|
author: {},
|
||||||
...settings,
|
...settings,
|
||||||
};
|
};
|
||||||
@ -34,10 +45,75 @@ function init(settings) {
|
|||||||
return initSettings;
|
return initSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractYouTubeVideoId = (url) => {
|
||||||
|
if (!url) return '';
|
||||||
|
const trimmed = url.trim();
|
||||||
|
const patterns = [
|
||||||
|
/[?&]v=([a-zA-Z0-9_-]{11})(?:[&?/]|$)/,
|
||||||
|
/youtu\.be\/([a-zA-Z0-9_-]{11})(?:[?&\/]|$)/,
|
||||||
|
/\/(?:live|embed|shorts|v)\/([a-zA-Z0-9_-]{11})(?:[?&\/]|$)/,
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = trimmed.match(pattern);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
export default function Control(props) {
|
export default function Control(props) {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const [$tab, setTab] = React.useState('content');
|
const [$tab, setTab] = React.useState('content');
|
||||||
const settings = init(props.settings);
|
const settings = init(props.settings);
|
||||||
|
const [fetching, setFetching] = React.useState(false);
|
||||||
|
const [fetchError, setFetchError] = React.useState('');
|
||||||
|
const youtubeUrlRef = React.useRef(null);
|
||||||
|
|
||||||
|
const handleFetchTitles = async () => {
|
||||||
|
setFetchError('');
|
||||||
|
let videoId = settings.youtube_id || (props.settings && props.settings.youtube_id) || '';
|
||||||
|
if (!videoId) {
|
||||||
|
const url = settings.youtube_url || (props.settings && props.settings.youtube_url) || '';
|
||||||
|
if (url) {
|
||||||
|
videoId = extractYouTubeVideoId(url);
|
||||||
|
if (videoId) settings.youtube_id = videoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!videoId) {
|
||||||
|
setFetchError('No YouTube ID found in metadata — introduce la URL en el campo de YouTube arriba.');
|
||||||
|
try { youtubeUrlRef.current && youtubeUrlRef.current.focus(); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFetching(true);
|
||||||
|
try {
|
||||||
|
// Prefer YouTube Data API on the client (runtime config or REACT_APP var)
|
||||||
|
const _rt = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
|
||||||
|
const ytApiKey = _rt.YOUTUBE_API_KEY || _rt.YT_API_KEY || process.env.REACT_APP_YOUTUBE_API_KEY || '';
|
||||||
|
let t = null;
|
||||||
|
if (ytApiKey) {
|
||||||
|
try {
|
||||||
|
t = await fetchYoutubeSnippet(videoId, ytApiKey, 10000);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[youtube api] failed:', err && err.message ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to yt-dlp titles/stream extractor if YouTube API is not available or fails
|
||||||
|
if (!t) {
|
||||||
|
const d = await fetchYtTitles(videoId);
|
||||||
|
if (d && (d.title || d.description)) t = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t && t.title) settings.name = t.title;
|
||||||
|
if (t && t.description) settings.description = t.description;
|
||||||
|
settings.youtube_id = videoId;
|
||||||
|
props.onChange(settings, false);
|
||||||
|
} catch (e) {
|
||||||
|
setFetchError(e && e.message ? e.message : 'Failed to fetch titles');
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Set the defaults
|
// Set the defaults
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -73,7 +149,52 @@ export default function Control(props) {
|
|||||||
<TabPanel value={$tab} index="content">
|
<TabPanel value={$tab} index="content">
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField variant="outlined" fullWidth label={<Trans>Name</Trans>} value={settings.name} onChange={handleChange('name')} />
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
label={<Trans>YouTube URL</Trans>}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=VIDEO_ID"
|
||||||
|
inputRef={youtubeUrlRef}
|
||||||
|
value={settings.youtube_url || ''}
|
||||||
|
onChange={handleChange('youtube_url')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
label={<Trans>Name</Trans>}
|
||||||
|
value={settings.name}
|
||||||
|
onChange={handleChange('name')}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={<Trans>Fetch title & description from stored YouTube ID</Trans>}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={fetching ? <CircularProgress size={16} color="inherit" /> : <YouTubeIcon />}
|
||||||
|
onClick={handleFetchTitles}
|
||||||
|
disabled={
|
||||||
|
fetching ||
|
||||||
|
!(settings.youtube_id || (settings.youtube_url && extractYouTubeVideoId(settings.youtube_url)))
|
||||||
|
}
|
||||||
|
style={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{fetching ? <Trans>Fetching...</Trans> : <Trans>Fetch from stream</Trans>}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{fetchError && (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<span style={{ color: '#f44336', fontSize: '0.8rem' }}>{fetchError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const fs = require('fs');
|
|||||||
*/
|
*/
|
||||||
const CORE_TARGET = process.env.REACT_APP_CORE_URL || 'http://localhost:8080';
|
const CORE_TARGET = process.env.REACT_APP_CORE_URL || 'http://localhost:8080';
|
||||||
const YTDLP_TARGET = process.env.REACT_APP_YTDLP_URL || 'http://localhost:8282';
|
const YTDLP_TARGET = process.env.REACT_APP_YTDLP_URL || 'http://localhost:8282';
|
||||||
|
const YTDLP_TITLES_TARGET = process.env.REACT_APP_YTDLP_URL_TITLES || 'http://localhost:8080';
|
||||||
const FB_SERVER_TARGET = process.env.REACT_APP_FB_SERVER_URL || 'http://localhost:3002';
|
const FB_SERVER_TARGET = process.env.REACT_APP_FB_SERVER_URL || 'http://localhost:3002';
|
||||||
// Dirección LOCAL del servidor egress/whip para el proxy de desarrollo.
|
// Dirección LOCAL del servidor egress/whip para el proxy de desarrollo.
|
||||||
// DISTINTO de REACT_APP_WHIP_SERVER_URL (que es la URL pública para el frontend).
|
// DISTINTO de REACT_APP_WHIP_SERVER_URL (que es la URL pública para el frontend).
|
||||||
@ -23,6 +24,7 @@ const LIVEKIT_INGRESS_TARGET = process.env.LIVEKIT_INGRESS_INTERNAL_URL || 'http
|
|||||||
console.log('\n[setupProxy] ─────────────────────────────────────');
|
console.log('\n[setupProxy] ─────────────────────────────────────');
|
||||||
console.log(`[setupProxy] Core → ${CORE_TARGET}`);
|
console.log(`[setupProxy] Core → ${CORE_TARGET}`);
|
||||||
console.log(`[setupProxy] yt-dlp → ${YTDLP_TARGET}`);
|
console.log(`[setupProxy] yt-dlp → ${YTDLP_TARGET}`);
|
||||||
|
console.log(`[setupProxy] yt-titles → ${YTDLP_TITLES_TARGET}`);
|
||||||
console.log(`[setupProxy] fb-server → ${FB_SERVER_TARGET}`);
|
console.log(`[setupProxy] fb-server → ${FB_SERVER_TARGET}`);
|
||||||
console.log(`[setupProxy] whip/egress → ${WHIP_SERVER_TARGET}`);
|
console.log(`[setupProxy] whip/egress → ${WHIP_SERVER_TARGET}`);
|
||||||
console.log('[setupProxy] ─────────────────────────────────────\n');
|
console.log('[setupProxy] ─────────────────────────────────────\n');
|
||||||
@ -87,6 +89,27 @@ module.exports = function (app) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// yt-dlp titles extractor: /yt-titles/{VIDEO_ID} → /info/{VIDEO_ID}
|
||||||
|
app.use(
|
||||||
|
'/yt-titles',
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: YTDLP_TITLES_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
ws: false,
|
||||||
|
proxyTimeout: 15000,
|
||||||
|
timeout: 15000,
|
||||||
|
pathRewrite: { '^/yt-titles': '/info' },
|
||||||
|
onError: (err, req, res) => {
|
||||||
|
console.error(`[setupProxy] yt-titles proxy error: ${err.code} — ${err.message}`);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Proxy error', target: YTDLP_TITLES_TARGET, message: err.message }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Facebook OAuth server + WebRTC relay: /fb-server/* → http://localhost:3002/*
|
// Facebook OAuth server + WebRTC relay: /fb-server/* → http://localhost:3002/*
|
||||||
app.use(
|
app.use(
|
||||||
'/fb-server',
|
'/fb-server',
|
||||||
|
|||||||
34
src/utils/youtube.js
Normal file
34
src/utils/youtube.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// Utility: fetch video snippet (title + description) via YouTube Data API v3
|
||||||
|
export async function fetchYoutubeSnippet(videoId, apiKey, timeoutMs = 10000) {
|
||||||
|
if (!videoId) throw new Error('No videoId provided');
|
||||||
|
if (!apiKey) throw new Error('No YouTube API key configured');
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet&id=${encodeURIComponent(videoId)}&key=${encodeURIComponent(apiKey)}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!res.ok) {
|
||||||
|
let body = '';
|
||||||
|
try { body = await res.text(); } catch (e) {}
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(body || '{}');
|
||||||
|
if (j.error && j.error.message) msg = j.error.message;
|
||||||
|
} catch (e) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.items || data.items.length === 0) throw new Error('Video not found');
|
||||||
|
const snippet = data.items[0].snippet || {};
|
||||||
|
return { title: snippet.title || '', description: snippet.description || '' };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw new Error('Tiempo de espera agotado al consultar YouTube');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { fetchYoutubeSnippet };
|
||||||
58
src/utils/ytdlp.js
Normal file
58
src/utils/ytdlp.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export async function fetchYtTitles(videoId, timeoutMs = 15000) {
|
||||||
|
const cfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
|
||||||
|
const titlesHost = cfg.YTDLP_TITLES_URL ? cfg.YTDLP_TITLES_URL.replace(/\/$/, '') : null;
|
||||||
|
const primaryHost = cfg.YTDLP_URL ? cfg.YTDLP_URL.replace(/\/$/, '') : null;
|
||||||
|
|
||||||
|
const tryFetchJson = async (url, ms) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), ms);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!resp.ok) return { ok: false, status: resp.status };
|
||||||
|
const data = await resp.json();
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return { ok: false, error: e };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Try titles host (/info then /stream)
|
||||||
|
if (titlesHost) {
|
||||||
|
// try /info
|
||||||
|
const infoUrl = `${titlesHost}/info/${videoId}`;
|
||||||
|
const infoResp = await tryFetchJson(infoUrl, timeoutMs);
|
||||||
|
if (infoResp.ok) {
|
||||||
|
const d = infoResp.data;
|
||||||
|
return { title: d.title || d.video_title || '', description: d.description || d.video_description || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// try /stream on the same host (some deployments expose metadata at /stream)
|
||||||
|
const streamUrlHost = `${titlesHost}/stream/${videoId}`;
|
||||||
|
const streamRespHost = await tryFetchJson(streamUrlHost, timeoutMs);
|
||||||
|
if (streamRespHost.ok) {
|
||||||
|
const d = streamRespHost.data;
|
||||||
|
return { title: d.title || d.video_title || '', description: d.description || d.video_description || '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback to primary yt-dlp extractor (prefer /stream with longer timeout)
|
||||||
|
if (primaryHost) {
|
||||||
|
const streamUrl = `${primaryHost}/stream/${videoId}`;
|
||||||
|
const streamResp = await tryFetchJson(streamUrl, 90000);
|
||||||
|
if (streamResp.ok) {
|
||||||
|
const d = streamResp.data;
|
||||||
|
return { title: d.title || d.video_title || '', description: d.description || d.video_description || '' };
|
||||||
|
}
|
||||||
|
// optionally try /info on primary host
|
||||||
|
const infoUrlPrim = `${primaryHost}/info/${videoId}`;
|
||||||
|
const infoRespPrim = await tryFetchJson(infoUrlPrim, timeoutMs);
|
||||||
|
if (infoRespPrim.ok) {
|
||||||
|
const d = infoRespPrim.data;
|
||||||
|
return { title: d.title || d.video_title || '', description: d.description || d.video_description || '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No titles available');
|
||||||
|
}
|
||||||
@ -810,5 +810,5 @@ Profile.defaultProps = {
|
|||||||
onStore: function (name, data) {
|
onStore: function (name, data) {
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
onYoutubeMetadata: function (title, description) {},
|
onYoutubeMetadata: function (videoId, title, description) {},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -145,7 +145,7 @@ SourceSelect.defaultProps = {
|
|||||||
onChange: function (type, device, settings) {},
|
onChange: function (type, device, settings) {},
|
||||||
onRefresh: function () {},
|
onRefresh: function () {},
|
||||||
onStore: function (name, data) {},
|
onStore: function (name, data) {},
|
||||||
onYoutubeMetadata: function (title, description) {},
|
onYoutubeMetadata: function (videoId, title, description) {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function Select(props) {
|
function Select(props) {
|
||||||
|
|||||||
@ -783,6 +783,9 @@ const _runtimeCfg = window.__RESTREAMER_CONFIG__ || {};
|
|||||||
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
||||||
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
||||||
: '/yt-stream/';
|
: '/yt-stream/';
|
||||||
|
const TITLES_SERVICE_BASE = _runtimeCfg.YTDLP_TITLES_URL
|
||||||
|
? _runtimeCfg.YTDLP_TITLES_URL.replace(/\/$/, '') + '/info/'
|
||||||
|
: '/yt-titles/';
|
||||||
|
|
||||||
const extractYouTubeVideoId = (url) => {
|
const extractYouTubeVideoId = (url) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@ -849,12 +852,34 @@ function Pull(props) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && data.stream_url) {
|
if (data && data.stream_url) {
|
||||||
props.onChange('', 'address')({ target: { value: data.stream_url } });
|
props.onChange('', 'address')({ target: { value: data.stream_url } });
|
||||||
|
// Store the YouTube ID in the settings so publications can reference it
|
||||||
|
if (videoId) {
|
||||||
|
try {
|
||||||
|
props.onChange('', 'youtube_id')({ target: { value: videoId } });
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
setExtractorError('');
|
setExtractorError('');
|
||||||
if (typeof props.onYoutubeMetadata === 'function') {
|
if (typeof props.onYoutubeMetadata === 'function') {
|
||||||
const title = data.title || data.video_title || '';
|
let title = data.title || data.video_title || '';
|
||||||
const description = data.description || data.video_description || '';
|
let description = data.description || data.video_description || '';
|
||||||
|
// After successful stream extraction, try the dedicated titles service
|
||||||
|
try {
|
||||||
|
const titlesController = new AbortController();
|
||||||
|
const titlesTimeout = setTimeout(() => titlesController.abort(), 15000);
|
||||||
|
const tResp = await fetch(TITLES_SERVICE_BASE + videoId, { signal: titlesController.signal });
|
||||||
|
clearTimeout(titlesTimeout);
|
||||||
|
if (tResp.ok) {
|
||||||
|
const tData = await tResp.json();
|
||||||
|
const tTitle = tData.title || tData.video_title || '';
|
||||||
|
const tDesc = tData.description || tData.video_description || '';
|
||||||
|
if (tTitle) title = tTitle;
|
||||||
|
if (tDesc) description = tDesc;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[yt-titles] fetch failed:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
if (title || description) {
|
if (title || description) {
|
||||||
props.onYoutubeMetadata(title, description);
|
props.onYoutubeMetadata(videoId, title, description);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1478,7 +1503,7 @@ Source.defaultProps = {
|
|||||||
skills: null,
|
skills: null,
|
||||||
onChange: function (settings) {},
|
onChange: function (settings) {},
|
||||||
onProbe: function (settings, inputs) {},
|
onProbe: function (settings, inputs) {},
|
||||||
onYoutubeMetadata: function (title, description) {},
|
onYoutubeMetadata: function (videoId, title, description) {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function SourceIcon(props) {
|
function SourceIcon(props) {
|
||||||
|
|||||||
@ -33,6 +33,9 @@ const _runtimeCfg = window.__RESTREAMER_CONFIG__ || {};
|
|||||||
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
||||||
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
||||||
: '/yt-stream/';
|
: '/yt-stream/';
|
||||||
|
const TITLES_SERVICE_BASE = _runtimeCfg.YTDLP_TITLES_URL
|
||||||
|
? _runtimeCfg.YTDLP_TITLES_URL.replace(/\/$/, '') + '/info/'
|
||||||
|
: '/yt-titles/';
|
||||||
|
|
||||||
const extractYouTubeVideoId = (url) => {
|
const extractYouTubeVideoId = (url) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@ -108,11 +111,12 @@ function Source(props) {
|
|||||||
const newSettings = { ...settings, address: data.stream_url };
|
const newSettings = { ...settings, address: data.stream_url };
|
||||||
handleChange(newSettings);
|
handleChange(newSettings);
|
||||||
setExtractorError('');
|
setExtractorError('');
|
||||||
if (typeof props.onYoutubeMetadata === 'function') {
|
// Store the YouTube ID in the wizard metadata via onYoutubeMetadata
|
||||||
const title = data.title || data.video_title || '';
|
if (videoId && typeof props.onYoutubeMetadata === 'function') {
|
||||||
const description = data.description || data.video_description || '';
|
try {
|
||||||
if (title || description) props.onYoutubeMetadata(title, description);
|
props.onYoutubeMetadata(videoId);
|
||||||
}
|
} catch (e) {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setExtractorError('No stream_url found in service response.');
|
setExtractorError('No stream_url found in service response.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -217,15 +217,16 @@ export default function Wizard(props) {
|
|||||||
navigate(`/${_channelid}/edit`);
|
navigate(`/${_channelid}/edit`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleYoutubeMetadata = (title, description) => {
|
const handleYoutubeMetadata = (videoId, title, description) => {
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
meta: {
|
meta: {
|
||||||
...prev.meta,
|
...prev.meta,
|
||||||
name: title || prev.meta?.name || '',
|
...(videoId ? { youtube_id: videoId } : {}),
|
||||||
description: description || prev.meta?.description || '',
|
name: title || prev.meta?.name || '',
|
||||||
},
|
description: description || prev.meta?.description || '',
|
||||||
}));
|
},
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHelp = (what) => () => {
|
const handleHelp = (what) => () => {
|
||||||
|
|||||||
@ -278,18 +278,19 @@ export default function Edit(props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleYoutubeMetadata = (title, description) => {
|
const handleYoutubeMetadata = (videoId, title, description) => {
|
||||||
setData((prev) => ({
|
setData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
meta: {
|
meta: {
|
||||||
...prev.meta,
|
...prev.meta,
|
||||||
...(title ? { name: title } : {}),
|
...(videoId ? { youtube_id: videoId } : {}),
|
||||||
...(description ? { description: description } : {}),
|
...(title ? { name: title } : {}),
|
||||||
},
|
...(description ? { description: description } : {}),
|
||||||
}));
|
},
|
||||||
if (title || description) {
|
}));
|
||||||
notify.Dispatch('success', 'youtube:metadata', i18n._(t`Title and description filled from YouTube`));
|
if (title || description) {
|
||||||
}
|
notify.Dispatch('success', 'youtube:metadata', i18n._(t`Title and description filled from YouTube`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLicenseChange = (license) => {
|
const handleLicenseChange = (license) => {
|
||||||
|
|||||||
@ -450,6 +450,7 @@ export default function Edit(props) {
|
|||||||
onChange={handleServiceChange}
|
onChange={handleServiceChange}
|
||||||
channelId={_channelid}
|
channelId={_channelid}
|
||||||
publicationId={id}
|
publicationId={id}
|
||||||
|
restreamer={props.restreamer}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import TextField from '@mui/material/TextField';
|
|||||||
import Logo from './logos/dlive.svg';
|
import Logo from './logos/dlive.svg';
|
||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
|
||||||
const id = 'dlive';
|
const id = 'dlive';
|
||||||
const name = 'dlive';
|
const name = 'dlive';
|
||||||
@ -94,11 +97,7 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings();
|
|
||||||
}} />
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -107,6 +106,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata && props.metadata.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={<Trans>Reset to stream title</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings();
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -119,6 +142,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata && props.metadata.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title={<Trans>Reset to stream description</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings();
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -22,14 +22,17 @@ import Typography from '@mui/material/Typography';
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
|
||||||
import Checkbox from '../../../misc/Checkbox';
|
import Checkbox from '../../../misc/Checkbox';
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import fbOAuth from '../../../utils/fbOAuth';
|
import fbOAuth from '../../../utils/fbOAuth';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
import { fetchYtTitles } from '../../../utils/ytdlp';
|
||||||
|
import { fetchYoutubeSnippet } from '../../../utils/youtube';
|
||||||
|
|
||||||
const id = 'facebook';
|
const id = 'facebook';
|
||||||
const name = 'Facebook Live';
|
const name = 'Facebook Live';
|
||||||
@ -450,11 +453,7 @@ function Service(props) {
|
|||||||
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
|
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
props.onChange(createOutput(settings), settings);
|
|
||||||
}} />
|
|
||||||
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField variant="outlined" fullWidth
|
<TextField variant="outlined" fullWidth
|
||||||
@ -462,6 +461,32 @@ function Service(props) {
|
|||||||
placeholder={props.metadata?.name || 'My live stream'}
|
placeholder={props.metadata?.name || 'My live stream'}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata?.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title="Reset to stream title">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reset] failed to save metadata', err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@ -471,6 +496,32 @@ function Service(props) {
|
|||||||
placeholder={props.metadata?.description || ''}
|
placeholder={props.metadata?.description || ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata?.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title="Reset to stream description">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reset] failed to save metadata', err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
|
||||||
|
|
||||||
const id = 'instagram';
|
const id = 'instagram';
|
||||||
const name = 'Instagram';
|
const name = 'Instagram';
|
||||||
@ -97,11 +100,6 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings();
|
|
||||||
}} />
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -110,6 +108,29 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata?.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title="Reset to stream title">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try { if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -122,6 +143,29 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata?.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title="Reset to stream description">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try { if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
|||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
|
||||||
const id = 'linkedin';
|
const id = 'linkedin';
|
||||||
const name = 'LinkedIn';
|
const name = 'LinkedIn';
|
||||||
@ -108,11 +111,7 @@ function Service(props) {
|
|||||||
placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}"
|
placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings();
|
|
||||||
}} />
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -121,6 +120,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata && props.metadata.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={<Trans>Reset to stream title</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings();
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -133,6 +156,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata && props.metadata.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title={<Trans>Reset to stream description</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings();
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import TextField from '@mui/material/TextField';
|
|||||||
|
|
||||||
import Logo from './logos/rumble.svg';
|
import Logo from './logos/rumble.svg';
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
|
||||||
const id = 'rumble';
|
const id = 'rumble';
|
||||||
const name = 'Rumble';
|
const name = 'Rumble';
|
||||||
@ -119,11 +122,7 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings();
|
|
||||||
}} />
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -132,6 +131,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata && props.metadata.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={<Trans>Reset to stream title</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings();
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -144,6 +167,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata && props.metadata.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title={<Trans>Reset to stream description</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings();
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import { Trans } from '@lingui/macro';
|
|||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
|
||||||
|
|
||||||
const id = 'twitch';
|
const id = 'twitch';
|
||||||
const name = 'Twitch';
|
const name = 'Twitch';
|
||||||
@ -167,11 +170,6 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings();
|
|
||||||
}} />
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -180,6 +178,32 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata?.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title="Reset to stream title">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reset] failed to save metadata', err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -192,6 +216,32 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata?.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title="Reset to stream description">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reset] failed to save metadata', err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -8,10 +8,13 @@ import Grid from '@mui/material/Grid';
|
|||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
|
||||||
|
|
||||||
const id = 'twitter';
|
const id = 'twitter';
|
||||||
const name = 'Twitter';
|
const name = 'Twitter';
|
||||||
@ -191,11 +194,6 @@ function Service(props) {
|
|||||||
<Trans>GET</Trans>
|
<Trans>GET</Trans>
|
||||||
</FormInlineButton>
|
</FormInlineButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings();
|
|
||||||
}} />
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<TextField
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -204,6 +202,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata?.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title="Reset to stream title">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -216,6 +238,30 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata?.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title="Reset to stream description">
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
props.onChange(createOutput(settings), settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[reset] failed to save metadata', err); }
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -10,11 +10,17 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
import MuiTextField from '@mui/material/TextField';
|
import MuiTextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
import Checkbox from '../../../misc/Checkbox';
|
import Checkbox from '../../../misc/Checkbox';
|
||||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||||
import Select from '../../../misc/Select';
|
import Select from '../../../misc/Select';
|
||||||
import ytOAuth from '../../../utils/ytOAuth';
|
import ytOAuth from '../../../utils/ytOAuth';
|
||||||
import YtMetadataInput from './YtMetadataInput';
|
import { fetchYtTitles } from '../../../utils/ytdlp';
|
||||||
|
import { fetchYoutubeSnippet } from '../../../utils/youtube';
|
||||||
|
|
||||||
const id = 'youtube';
|
const id = 'youtube';
|
||||||
const name = 'YouTube Live';
|
const name = 'YouTube Live';
|
||||||
@ -229,13 +235,17 @@ function Service(props) {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
// Enviar ACK al popup para que se cierre
|
// Enviar ACK al popup para que se cierre
|
||||||
|
// Enviar ACK al popup para que se cierre (acceder a .closed dentro de try/catch
|
||||||
|
// para evitar warnings cuando COOP/COEP bloquea el acceso a propiedades cross-origin)
|
||||||
try {
|
try {
|
||||||
if (authWindow && !authWindow.closed) {
|
let isClosed = false;
|
||||||
authWindow.postMessage({ service: 'youtube', ack: true }, '*');
|
try { isClosed = !!authWindow.closed; } catch (e) { isClosed = false; }
|
||||||
// Dar 1 s al popup para procesar el ACK y cerrarse solo
|
if (authWindow && !isClosed) {
|
||||||
setTimeout(() => { try { if (!authWindow.closed) authWindow.close(); } catch(e){} }, 1200);
|
try { authWindow.postMessage({ service: 'youtube', ack: true }, '*'); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
} catch (e) { /* popup puede haber cerrado ya */ }
|
// Dar 1 s al popup para procesar el ACK y cerrarse solo. Intento de cierre protegido.
|
||||||
|
setTimeout(() => { try { if (!authWindow.closed) authWindow.close(); } catch (e) { /* ignore */ } }, 1200);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
if (event.data.error) {
|
if (event.data.error) {
|
||||||
setApiError('Authorization denied: ' + event.data.error);
|
setApiError('Authorization denied: ' + event.data.error);
|
||||||
@ -328,16 +338,22 @@ function Service(props) {
|
|||||||
|
|
||||||
window.addEventListener('message', listener);
|
window.addEventListener('message', listener);
|
||||||
|
|
||||||
// Detectar si el usuario cierra el popup manualmente
|
// Detectar si el usuario cierra el popup manualmente. Acceder a authWindow.closed
|
||||||
const pollClosed = setInterval(() => {
|
// dentro de try/catch para evitar COOP/COEP warnings; si el acceso está bloqueado,
|
||||||
if (authWindow.closed) {
|
// confiar en el listener/postMessage para resolver el flujo.
|
||||||
clearInterval(pollClosed);
|
const pollClosed = setInterval(() => {
|
||||||
// Si todavía estamos esperando (el listener no disparó), limpiar
|
try {
|
||||||
window.removeEventListener('message', listener);
|
if (!authWindow || authWindow.closed) {
|
||||||
clearTimeout(timeoutId);
|
clearInterval(pollClosed);
|
||||||
setConnecting(false);
|
// Si todavía estamos esperando (el listener no disparó), limpiar
|
||||||
}
|
window.removeEventListener('message', listener);
|
||||||
}, 500);
|
clearTimeout(timeoutId);
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Acceso bloqueado por políticas COOP/COEP — ignorar y dejar que el listener maneje el cierre
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Pegar token manualmente ──────────────────────────────────────────
|
// ─── Pegar token manualmente ──────────────────────────────────────────
|
||||||
@ -557,11 +573,7 @@ function Service(props) {
|
|||||||
<Trans>Optionally paste a YouTube URL or Video ID to auto-fill the title and description below.</Trans>
|
<Trans>Optionally paste a YouTube URL or Video ID to auto-fill the title and description below.</Trans>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<YtMetadataInput onFetch={(title, desc) => {
|
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||||
if (title) settings.title = title;
|
|
||||||
if (desc) settings.description = desc;
|
|
||||||
pushSettings(settings);
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* ── Título y descripción ────────────────────────────────── */}
|
{/* ── Título y descripción ────────────────────────────────── */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -572,6 +584,32 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||||
value={settings.title}
|
value={settings.title}
|
||||||
onChange={handleChange('title')}
|
onChange={handleChange('title')}
|
||||||
|
InputProps={props.metadata && props.metadata.name ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={<Trans>Reset to stream title</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings(settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reset] failed to save metadata', err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
@ -584,6 +622,32 @@ function Service(props) {
|
|||||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||||
value={settings.description}
|
value={settings.description}
|
||||||
onChange={handleChange('description')}
|
onChange={handleChange('description')}
|
||||||
|
InputProps={props.metadata && props.metadata.description ? {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" style={{ alignSelf: 'flex-start', marginTop: 10 }}>
|
||||||
|
<Tooltip title={<Trans>Reset to stream description</Trans>}>
|
||||||
|
<IconButton size="small" onClick={async () => {
|
||||||
|
const meta = props.metadata || {};
|
||||||
|
const t = meta.name || meta.title || '';
|
||||||
|
const d = meta.description || '';
|
||||||
|
if (!t && !d) return;
|
||||||
|
if (t) settings.title = t;
|
||||||
|
if (d) settings.description = d;
|
||||||
|
pushSettings(settings);
|
||||||
|
try {
|
||||||
|
if (props.restreamer && typeof props.restreamer.SetEgressMetadata === 'function') {
|
||||||
|
await props.restreamer.SetEgressMetadata(props.channelId, props.publicationId, { name: settings.title || '', description: settings.description || '' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[reset] failed to save metadata', err);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import InputAdornment from '@mui/material/InputAdornment';
|
|||||||
import MuiTextField from '@mui/material/TextField';
|
import MuiTextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||||
|
import { fetchYoutubeSnippet } from '../../../utils/youtube';
|
||||||
|
|
||||||
// ── yt-dlp service base ────────────────────────────────────────────────────
|
// ── yt-dlp service base ────────────────────────────────────────────────────
|
||||||
const _runtimeCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
|
const _runtimeCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
|
||||||
@ -25,6 +26,12 @@ const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
|||||||
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
||||||
: '/yt-stream/';
|
: '/yt-stream/';
|
||||||
|
|
||||||
|
const TITLES_SERVICE_BASE = _runtimeCfg.YTDLP_TITLES_URL
|
||||||
|
? _runtimeCfg.YTDLP_TITLES_URL.replace(/\/$/, '') + '/info/'
|
||||||
|
: '/yt-titles/';
|
||||||
|
|
||||||
|
const YOUTUBE_API_KEY = _runtimeCfg.YOUTUBE_API_KEY || _runtimeCfg.YT_API_KEY || process.env.REACT_APP_YOUTUBE_API_KEY || '';
|
||||||
|
|
||||||
export const extractYouTubeVideoId = (url) => {
|
export const extractYouTubeVideoId = (url) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
@ -55,17 +62,52 @@ export default function YtMetadataInput({ onFetch }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
|
// Prefer the dedicated titles service first (fast). If it fails, fallback to full stream extraction.
|
||||||
|
// If a YouTube API key is configured, prefer querying the YouTube Data API first.
|
||||||
|
if (YOUTUBE_API_KEY) {
|
||||||
|
try {
|
||||||
|
const y = await fetchYoutubeSnippet(videoId, YOUTUBE_API_KEY, 10000);
|
||||||
|
if (y && (y.title || y.description)) {
|
||||||
|
if (typeof onFetch === 'function') onFetch(videoId, y.title || '', y.description || '');
|
||||||
|
setFetching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[youtube api] failed:', e && e.message ? e.message : e);
|
||||||
|
// continue to titles service fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const titlesController = new AbortController();
|
||||||
|
const titlesTimeout = setTimeout(() => titlesController.abort(), 15000);
|
||||||
|
const tResp = await fetch(TITLES_SERVICE_BASE + videoId, { signal: titlesController.signal });
|
||||||
|
clearTimeout(titlesTimeout);
|
||||||
|
if (tResp.ok) {
|
||||||
|
const tData = await tResp.json();
|
||||||
|
const title = tData.title || tData.video_title || '';
|
||||||
|
const description = tData.description || tData.video_description || '';
|
||||||
|
if (title || description) {
|
||||||
|
if (typeof onFetch === 'function') onFetch(videoId, title, description);
|
||||||
|
setFetching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[yt-titles] fetch failed:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to full stream extractor if titles service didn't return data
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 90000);
|
const timeoutId = setTimeout(() => controller.abort(), 90000);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(STREAM_SERVICE_BASE + videoId, { signal: controller.signal });
|
const res = await fetch(STREAM_SERVICE_BASE + videoId, { signal: controller.signal });
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' – ' + res.statusText);
|
if (!res.ok) throw new Error('HTTP ' + res.status + ' – ' + res.statusText);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const title = data.title || data.video_title || '';
|
const title = data.title || data.video_title || '';
|
||||||
const description = data.description || data.video_description || '';
|
const description = data.description || data.video_description || '';
|
||||||
if (title || description) {
|
if (title || description) {
|
||||||
if (typeof onFetch === 'function') onFetch(title, description);
|
if (typeof onFetch === 'function') onFetch(videoId, title, description);
|
||||||
} else {
|
} else {
|
||||||
setError('No se encontró título ni descripción en la respuesta del servicio.');
|
setError('No se encontró título ni descripción en la respuesta del servicio.');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user