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

@ -18,7 +18,7 @@ NONPUBLIC
.VSCodeCounter
.env
.env.development.local
.env.test.local
.env.local
.env.production.local
npm-debug.log*

View File

@ -5,28 +5,28 @@
}
}
: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
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 {
reverse_proxy restreamer-ui:3002 {
transport http {
versions 1.1
}
@ -36,8 +36,7 @@ handle /webrtc-relay/* {
# ── 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
@ -57,31 +56,36 @@ handle_path /yt-stream/* {
}
}
# ── 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
}
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
}
reverse_proxy restreamer-ui:3000
}
# Sin extensión redirigir a .html
handle /oauth/facebook/callback {
redir /oauth/facebook/callback.html{query} 302
}
@ -98,7 +102,7 @@ handle /w/* {
# ── 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 ───────────────────
@ -108,9 +112,7 @@ handle /whep/* {
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
}

View File

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

View File

@ -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
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__ = {
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'

View File

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

View File

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

View File

@ -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
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) {
return '';
},
onYoutubeMetadata: function (title, description) {},
onYoutubeMetadata: function (videoId, title, description) {},
};

View File

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

View File

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

View File

@ -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,10 +111,11 @@ 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.');

View File

@ -217,11 +217,12 @@ export default function Wizard(props) {
navigate(`/${_channelid}/edit`);
};
const handleYoutubeMetadata = (title, 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 || '',
},

View File

@ -278,11 +278,12 @@ export default function Edit(props) {
});
};
const handleYoutubeMetadata = (title, description) => {
const handleYoutubeMetadata = (videoId, title, description) => {
setData((prev) => ({
...prev,
meta: {
...prev.meta,
...(videoId ? { youtube_id: videoId } : {}),
...(title ? { name: title } : {}),
...(description ? { description: description } : {}),
},

View File

@ -450,6 +450,7 @@ export default function Edit(props) {
onChange={handleServiceChange}
channelId={_channelid}
publicationId={id}
restreamer={props.restreamer}
/>
</Grid>
</TabContent>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,15 +338,21 @@ function Service(props) {
window.addEventListener('message', listener);
// Detectar si el usuario cierra el popup manualmente
// 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(() => {
if (authWindow.closed) {
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);
};
@ -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>

View File

@ -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,6 +62,41 @@ 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);
try {
@ -65,7 +107,7 @@ export default function YtMetadataInput({ onFetch }) {
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.');
}

716
yarn.lock

File diff suppressed because it is too large Load Diff