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:
Cesar Mendivil 2026-03-18 10:35:28 -07:00
parent 5c586a2aa3
commit bc97ee0a68
33 changed files with 1428 additions and 568 deletions

View File

@ -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
View File

@ -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*

View File

@ -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
} }

View File

@ -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.

View File

@ -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
View 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"]

View 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;
}
}

View 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
View 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.

View 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";
}

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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
View 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
View 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');
}

View File

@ -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) {},
}; };

View File

@ -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) {

View File

@ -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) {

View File

@ -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.');
} }

View File

@ -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) => () => {

View File

@ -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) => {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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.');
} }

716
yarn.lock

File diff suppressed because it is too large Load Diff