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_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_LIVEKIT_API_KEY=APIBTqTGxf9htMK
|
||||
REACT_APP_LIVEKIT_API_SECRET=0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
|
||||
REACT_APP_LIVEKIT_WS_URL=wss://livekit-server.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
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
|
||||
90
Caddyfile
90
Caddyfile
@ -5,85 +5,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
:3000
|
||||
djmaster.nextream.sytes.net
|
||||
|
||||
encode zstd gzip
|
||||
|
||||
# ── Facebook OAuth2 microserver (Node.js en puerto 3002) ──────────────────────
|
||||
handle /fb-server/* {
|
||||
uri strip_prefix /fb-server
|
||||
reverse_proxy 127.0.0.1:3002
|
||||
uri strip_prefix /fb-server
|
||||
reverse_proxy restreamer-ui:3002
|
||||
}
|
||||
|
||||
# ── LiveKit token endpoint (Node.js en puerto 3002) ───────────────────────────
|
||||
# POST /livekit/token → genera AccessToken JWT firmado
|
||||
# GET /livekit/config → devuelve wsUrl público (sin secretos)
|
||||
handle /livekit/* {
|
||||
reverse_proxy 127.0.0.1:3002
|
||||
reverse_proxy restreamer-ui:3002
|
||||
}
|
||||
|
||||
# ── WebRTC relay WebSocket + status (Node.js en puerto 3002) ─────────────────
|
||||
# 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)
|
||||
handle /webrtc-relay/* {
|
||||
reverse_proxy 127.0.0.1:3002 {
|
||||
transport http {
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
reverse_proxy restreamer-ui:3002 {
|
||||
transport http {
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── WebRTC Room HTML (sala para el presentador) ───────────────────────────────
|
||||
# Sirve la página estática sin fallback al index.html de la SPA
|
||||
handle /webrtc-room/* {
|
||||
root * /ui/build
|
||||
file_server
|
||||
reverse_proxy restreamer-ui:3000
|
||||
}
|
||||
handle /webrtc-room {
|
||||
redir /webrtc-room/ 302
|
||||
redir /webrtc-room/ 302
|
||||
}
|
||||
|
||||
# ── yt-dlp stream extractor (servicio externo configurable via env) ───────────
|
||||
# /yt-stream/{VIDEO_ID} → http://YTDLP_HOST/stream/{VIDEO_ID}
|
||||
# yt-dlp puede tardar 20-30s — timeouts extendidos a 120s
|
||||
handle_path /yt-stream/* {
|
||||
rewrite * /stream{path}
|
||||
reverse_proxy {env.YTDLP_HOST} {
|
||||
transport http {
|
||||
dial_timeout 10s
|
||||
response_header_timeout 120s
|
||||
read_timeout 120s
|
||||
}
|
||||
}
|
||||
rewrite * /stream{path}
|
||||
reverse_proxy {env.YTDLP_HOST} {
|
||||
transport http {
|
||||
dial_timeout 10s
|
||||
response_header_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)
|
||||
handle /oauth2callback {
|
||||
rewrite * /oauth2callback.html
|
||||
file_server {
|
||||
root /ui/build
|
||||
}
|
||||
rewrite * /oauth2callback.html
|
||||
reverse_proxy restreamer-ui:3000
|
||||
}
|
||||
|
||||
# Facebook OAuth2 callback popup — soporta tanto .html como .htm
|
||||
# .html → servir directamente
|
||||
handle /oauth/facebook/callback.html {
|
||||
file_server {
|
||||
root /ui/build
|
||||
}
|
||||
reverse_proxy restreamer-ui:3000
|
||||
}
|
||||
|
||||
# .htm → reescribir internamente a .html (misma página, misma URL visible para Facebook)
|
||||
handle /oauth/facebook/callback.htm {
|
||||
rewrite * /oauth/facebook/callback.html
|
||||
file_server {
|
||||
root /ui/build
|
||||
}
|
||||
rewrite * /oauth/facebook/callback.html
|
||||
reverse_proxy restreamer-ui:3000
|
||||
}
|
||||
|
||||
# Sin extensión → redirigir a .html
|
||||
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 ─────────
|
||||
@ -91,26 +95,24 @@ handle /oauth/facebook/callback {
|
||||
# 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).
|
||||
handle /w/* {
|
||||
reverse_proxy {env.LIVEKIT_INGRESS_HOST} {
|
||||
header_up Host {upstream_hostport}
|
||||
}
|
||||
reverse_proxy {env.LIVEKIT_INGRESS_HOST} {
|
||||
header_up Host {upstream_hostport}
|
||||
}
|
||||
}
|
||||
|
||||
# ── WHIP info API: genera sesión Ingress (Node en :3002) ─────────────────────
|
||||
handle /api/whip/* {
|
||||
reverse_proxy 127.0.0.1:3002
|
||||
reverse_proxy restreamer-ui:3002
|
||||
}
|
||||
|
||||
# ── WHEP relay proxy: Core hace pull aquí → egress server ───────────────────
|
||||
# Core input: https://djmaster.nextream.sytes.net/whep/rooms/<channelId>
|
||||
# EGRESS_HOST se configura en docker-compose (URL del servidor egress).
|
||||
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 {
|
||||
root * /ui/build
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
reverse_proxy restreamer-ui:3000
|
||||
}
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
services:
|
||||
restreamer-ui:
|
||||
# NOTA: Primero compila con: yarn build
|
||||
# Luego construye la imagen con: docker build --tag restreamer-ui-v2:latest .
|
||||
# O usa el script: build-docker.bat
|
||||
# Build image from repository so `docker compose up --build` works on another host
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: restreamer-ui-v2:latest
|
||||
container_name: restreamer-ui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3002:3002"
|
||||
environment:
|
||||
# ── Restreamer Core ────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# Dejar vacío → la UI usará /yt-stream/ (Caddy proxy, mismo origen = sin CORS).
|
||||
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 ────────────────────────────────────────
|
||||
# Dejar vacío → Caddy proxy /fb-server → localhost:3002 (sin CORS)
|
||||
@ -74,6 +81,9 @@ services:
|
||||
# devices:
|
||||
# - "/dev/video1:/dev/video1" # Descomentar si hay cámara USB disponible
|
||||
|
||||
# Nginx service removed — using external reverse proxy instead
|
||||
|
||||
volumes:
|
||||
restreamer-ui-fb-data:
|
||||
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__ = {
|
||||
CORE_ADDRESS: "${CORE_ADDRESS:-}",
|
||||
YTDLP_URL: "${YTDLP_URL:-}",
|
||||
YTDLP_TITLES_URL: "${YTDLP_TITLES_URL:-}",
|
||||
FB_SERVER_URL: "${FB_SERVER_URL:-}",
|
||||
FB_OAUTH_CALLBACK_URL: "${FB_OAUTH_CALLBACK_URL:-}",
|
||||
YT_OAUTH_CALLBACK_URL: "${YT_OAUTH_CALLBACK_URL:-}",
|
||||
YOUTUBE_API_KEY: "${YOUTUBE_API_KEY:-}",
|
||||
};
|
||||
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__ = {
|
||||
CORE_ADDRESS: '',
|
||||
YTDLP_URL: '',
|
||||
YTDLP_TITLES_URL: '',
|
||||
FB_SERVER_URL: '',
|
||||
// URL pública del servidor egress (WHIP ingest + WHEP relay).
|
||||
// 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() });
|
||||
});
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// WHIP INGRESS
|
||||
// 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 Tab from '@mui/material/Tab';
|
||||
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 TabsHorizontal from '../TabsHorizontal';
|
||||
@ -21,6 +30,8 @@ function init(settings) {
|
||||
const initSettings = {
|
||||
name: 'Livestream',
|
||||
description: 'Live from earth. Powered by datarhei Restreamer.',
|
||||
youtube_id: '',
|
||||
youtube_url: '',
|
||||
author: {},
|
||||
...settings,
|
||||
};
|
||||
@ -34,10 +45,75 @@ function init(settings) {
|
||||
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) {
|
||||
const classes = useStyles();
|
||||
const [$tab, setTab] = React.useState('content');
|
||||
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
|
||||
React.useEffect(() => {
|
||||
@ -73,7 +149,52 @@ export default function Control(props) {
|
||||
<TabPanel value={$tab} index="content">
|
||||
<Grid container spacing={2}>
|
||||
<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 item xs={12}>
|
||||
<TextField
|
||||
|
||||
@ -12,6 +12,7 @@ const fs = require('fs');
|
||||
*/
|
||||
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_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';
|
||||
// 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).
|
||||
@ -23,6 +24,7 @@ const LIVEKIT_INGRESS_TARGET = process.env.LIVEKIT_INGRESS_INTERNAL_URL || 'http
|
||||
console.log('\n[setupProxy] ─────────────────────────────────────');
|
||||
console.log(`[setupProxy] Core → ${CORE_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] whip/egress → ${WHIP_SERVER_TARGET}`);
|
||||
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/*
|
||||
app.use(
|
||||
'/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) {
|
||||
return '';
|
||||
},
|
||||
onYoutubeMetadata: function (title, description) {},
|
||||
onYoutubeMetadata: function (videoId, title, description) {},
|
||||
};
|
||||
|
||||
@ -145,7 +145,7 @@ SourceSelect.defaultProps = {
|
||||
onChange: function (type, device, settings) {},
|
||||
onRefresh: function () {},
|
||||
onStore: function (name, data) {},
|
||||
onYoutubeMetadata: function (title, description) {},
|
||||
onYoutubeMetadata: function (videoId, title, description) {},
|
||||
};
|
||||
|
||||
function Select(props) {
|
||||
|
||||
@ -783,6 +783,9 @@ const _runtimeCfg = window.__RESTREAMER_CONFIG__ || {};
|
||||
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
||||
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
||||
: '/yt-stream/';
|
||||
const TITLES_SERVICE_BASE = _runtimeCfg.YTDLP_TITLES_URL
|
||||
? _runtimeCfg.YTDLP_TITLES_URL.replace(/\/$/, '') + '/info/'
|
||||
: '/yt-titles/';
|
||||
|
||||
const extractYouTubeVideoId = (url) => {
|
||||
if (!url) return '';
|
||||
@ -849,12 +852,34 @@ function Pull(props) {
|
||||
const data = await response.json();
|
||||
if (data && 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('');
|
||||
if (typeof props.onYoutubeMetadata === 'function') {
|
||||
const title = data.title || data.video_title || '';
|
||||
const description = data.description || data.video_description || '';
|
||||
let title = data.title || data.video_title || '';
|
||||
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) {
|
||||
props.onYoutubeMetadata(title, description);
|
||||
props.onYoutubeMetadata(videoId, title, description);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -1478,7 +1503,7 @@ Source.defaultProps = {
|
||||
skills: null,
|
||||
onChange: function (settings) {},
|
||||
onProbe: function (settings, inputs) {},
|
||||
onYoutubeMetadata: function (title, description) {},
|
||||
onYoutubeMetadata: function (videoId, title, description) {},
|
||||
};
|
||||
|
||||
function SourceIcon(props) {
|
||||
|
||||
@ -33,6 +33,9 @@ const _runtimeCfg = window.__RESTREAMER_CONFIG__ || {};
|
||||
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
||||
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
|
||||
: '/yt-stream/';
|
||||
const TITLES_SERVICE_BASE = _runtimeCfg.YTDLP_TITLES_URL
|
||||
? _runtimeCfg.YTDLP_TITLES_URL.replace(/\/$/, '') + '/info/'
|
||||
: '/yt-titles/';
|
||||
|
||||
const extractYouTubeVideoId = (url) => {
|
||||
if (!url) return '';
|
||||
@ -108,11 +111,12 @@ function Source(props) {
|
||||
const newSettings = { ...settings, address: data.stream_url };
|
||||
handleChange(newSettings);
|
||||
setExtractorError('');
|
||||
if (typeof props.onYoutubeMetadata === 'function') {
|
||||
const title = data.title || data.video_title || '';
|
||||
const description = data.description || data.video_description || '';
|
||||
if (title || description) props.onYoutubeMetadata(title, description);
|
||||
}
|
||||
// Store the YouTube ID in the wizard metadata via onYoutubeMetadata
|
||||
if (videoId && typeof props.onYoutubeMetadata === 'function') {
|
||||
try {
|
||||
props.onYoutubeMetadata(videoId);
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
setExtractorError('No stream_url found in service response.');
|
||||
}
|
||||
|
||||
@ -217,15 +217,16 @@ export default function Wizard(props) {
|
||||
navigate(`/${_channelid}/edit`);
|
||||
};
|
||||
|
||||
const handleYoutubeMetadata = (title, description) => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
meta: {
|
||||
...prev.meta,
|
||||
name: title || prev.meta?.name || '',
|
||||
description: description || prev.meta?.description || '',
|
||||
},
|
||||
}));
|
||||
const handleYoutubeMetadata = (videoId, title, description) => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
meta: {
|
||||
...prev.meta,
|
||||
...(videoId ? { youtube_id: videoId } : {}),
|
||||
name: title || prev.meta?.name || '',
|
||||
description: description || prev.meta?.description || '',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleHelp = (what) => () => {
|
||||
|
||||
@ -278,18 +278,19 @@ export default function Edit(props) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleYoutubeMetadata = (title, description) => {
|
||||
const handleYoutubeMetadata = (videoId, title, description) => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
meta: {
|
||||
...prev.meta,
|
||||
...(title ? { name: title } : {}),
|
||||
...(description ? { description: description } : {}),
|
||||
},
|
||||
}));
|
||||
if (title || description) {
|
||||
notify.Dispatch('success', 'youtube:metadata', i18n._(t`Title and description filled from YouTube`));
|
||||
}
|
||||
...prev,
|
||||
meta: {
|
||||
...prev.meta,
|
||||
...(videoId ? { youtube_id: videoId } : {}),
|
||||
...(title ? { name: title } : {}),
|
||||
...(description ? { description: description } : {}),
|
||||
},
|
||||
}));
|
||||
if (title || description) {
|
||||
notify.Dispatch('success', 'youtube:metadata', i18n._(t`Title and description filled from YouTube`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLicenseChange = (license) => {
|
||||
|
||||
@ -450,6 +450,7 @@ export default function Edit(props) {
|
||||
onChange={handleServiceChange}
|
||||
channelId={_channelid}
|
||||
publicationId={id}
|
||||
restreamer={props.restreamer}
|
||||
/>
|
||||
</Grid>
|
||||
</TabContent>
|
||||
|
||||
@ -7,7 +7,10 @@ import TextField from '@mui/material/TextField';
|
||||
import Logo from './logos/dlive.svg';
|
||||
|
||||
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 name = 'dlive';
|
||||
@ -94,11 +97,7 @@ function Service(props) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
@ -107,6 +106,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -119,6 +142,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
@ -22,14 +22,17 @@ import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import RestoreIcon from '@mui/icons-material/RestoreOutlined';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
|
||||
import Checkbox from '../../../misc/Checkbox';
|
||||
import FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import Select from '../../../misc/Select';
|
||||
import fbOAuth from '../../../utils/fbOAuth';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
import { fetchYtTitles } from '../../../utils/ytdlp';
|
||||
import { fetchYoutubeSnippet } from '../../../utils/youtube';
|
||||
|
||||
const id = 'facebook';
|
||||
const name = 'Facebook Live';
|
||||
@ -450,11 +453,7 @@ function Service(props) {
|
||||
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
|
||||
</Grid>
|
||||
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
props.onChange(createOutput(settings), settings);
|
||||
}} />
|
||||
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField variant="outlined" fullWidth
|
||||
@ -462,6 +461,32 @@ function Service(props) {
|
||||
placeholder={props.metadata?.name || 'My live stream'}
|
||||
value={settings.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>
|
||||
|
||||
@ -471,6 +496,32 @@ function Service(props) {
|
||||
placeholder={props.metadata?.description || ''}
|
||||
value={settings.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>
|
||||
|
||||
|
||||
@ -5,9 +5,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Grid from '@mui/material/Grid';
|
||||
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 YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'instagram';
|
||||
const name = 'Instagram';
|
||||
@ -97,11 +100,6 @@ function Service(props) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
@ -110,6 +108,29 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -122,6 +143,29 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
@ -7,7 +7,10 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '../../../misc/Select';
|
||||
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 name = 'LinkedIn';
|
||||
@ -108,11 +111,7 @@ function Service(props) {
|
||||
placeholder="{custom_id}.channel.media.azure.net:2935/live/{custom_id}"
|
||||
/>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
@ -121,6 +120,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -133,6 +156,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
@ -7,7 +7,10 @@ import TextField from '@mui/material/TextField';
|
||||
|
||||
import Logo from './logos/rumble.svg';
|
||||
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 name = 'Rumble';
|
||||
@ -119,11 +122,7 @@ function Service(props) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
@ -132,6 +131,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -144,6 +167,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
@ -6,10 +6,13 @@ import { Trans } from '@lingui/macro';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
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 Select from '../../../misc/Select';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'twitch';
|
||||
const name = 'Twitch';
|
||||
@ -167,11 +170,6 @@ function Service(props) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
@ -180,6 +178,32 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -192,6 +216,32 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
@ -8,10 +8,13 @@ import Grid from '@mui/material/Grid';
|
||||
import Link from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
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 Select from '../../../misc/Select';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
|
||||
const id = 'twitter';
|
||||
const name = 'Twitter';
|
||||
@ -191,11 +194,6 @@ function Service(props) {
|
||||
<Trans>GET</Trans>
|
||||
</FormInlineButton>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings();
|
||||
}} />
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
@ -204,6 +202,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -216,6 +238,30 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
@ -10,11 +10,17 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
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 FormInlineButton from '../../../misc/FormInlineButton';
|
||||
import Select from '../../../misc/Select';
|
||||
import ytOAuth from '../../../utils/ytOAuth';
|
||||
import YtMetadataInput from './YtMetadataInput';
|
||||
import { fetchYtTitles } from '../../../utils/ytdlp';
|
||||
import { fetchYoutubeSnippet } from '../../../utils/youtube';
|
||||
|
||||
const id = 'youtube';
|
||||
const name = 'YouTube Live';
|
||||
@ -229,13 +235,17 @@ function Service(props) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 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 {
|
||||
if (authWindow && !authWindow.closed) {
|
||||
authWindow.postMessage({ service: 'youtube', ack: true }, '*');
|
||||
// Dar 1 s al popup para procesar el ACK y cerrarse solo
|
||||
setTimeout(() => { try { if (!authWindow.closed) authWindow.close(); } catch(e){} }, 1200);
|
||||
let isClosed = false;
|
||||
try { isClosed = !!authWindow.closed; } catch (e) { isClosed = false; }
|
||||
if (authWindow && !isClosed) {
|
||||
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) {
|
||||
setApiError('Authorization denied: ' + event.data.error);
|
||||
@ -328,16 +338,22 @@ function Service(props) {
|
||||
|
||||
window.addEventListener('message', listener);
|
||||
|
||||
// Detectar si el usuario cierra el popup manualmente
|
||||
const pollClosed = setInterval(() => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(pollClosed);
|
||||
// Si todavía estamos esperando (el listener no disparó), limpiar
|
||||
window.removeEventListener('message', listener);
|
||||
clearTimeout(timeoutId);
|
||||
setConnecting(false);
|
||||
}
|
||||
}, 500);
|
||||
// Detectar si el usuario cierra el popup manualmente. Acceder a authWindow.closed
|
||||
// dentro de try/catch para evitar COOP/COEP warnings; si el acceso está bloqueado,
|
||||
// confiar en el listener/postMessage para resolver el flujo.
|
||||
const pollClosed = setInterval(() => {
|
||||
try {
|
||||
if (!authWindow || authWindow.closed) {
|
||||
clearInterval(pollClosed);
|
||||
// Si todavía estamos esperando (el listener no disparó), limpiar
|
||||
window.removeEventListener('message', listener);
|
||||
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 ──────────────────────────────────────────
|
||||
@ -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>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<YtMetadataInput onFetch={(title, desc) => {
|
||||
if (title) settings.title = title;
|
||||
if (desc) settings.description = desc;
|
||||
pushSettings(settings);
|
||||
}} />
|
||||
{/* YouTube URL input removed — Reset will restore from stored metadata */}
|
||||
|
||||
{/* ── Título y descripción ────────────────────────────────── */}
|
||||
<Grid item xs={12}>
|
||||
@ -572,6 +584,32 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
|
||||
value={settings.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 item xs={12}>
|
||||
@ -584,6 +622,32 @@ function Service(props) {
|
||||
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
|
||||
value={settings.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>
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import InputAdornment from '@mui/material/InputAdornment';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
import { fetchYoutubeSnippet } from '../../../utils/youtube';
|
||||
|
||||
// ── yt-dlp service base ────────────────────────────────────────────────────
|
||||
const _runtimeCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
|
||||
@ -25,6 +26,12 @@ const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
|
||||
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/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) => {
|
||||
if (!url) return '';
|
||||
const trimmed = url.trim();
|
||||
@ -55,17 +62,52 @@ export default function YtMetadataInput({ onFetch }) {
|
||||
return;
|
||||
}
|
||||
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 timeoutId = setTimeout(() => controller.abort(), 90000);
|
||||
const timeoutId = setTimeout(() => controller.abort(), 90000);
|
||||
try {
|
||||
const res = await fetch(STREAM_SERVICE_BASE + videoId, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' – ' + res.statusText);
|
||||
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 || '';
|
||||
if (title || description) {
|
||||
if (typeof onFetch === 'function') onFetch(title, description);
|
||||
if (typeof onFetch === 'function') onFetch(videoId, title, description);
|
||||
} else {
|
||||
setError('No se encontró título ni descripción en la respuesta del servicio.');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user