Add Docker support and enhance YouTube/Facebook stream key management

This commit is contained in:
CesarMendivil 2026-03-01 17:11:38 -07:00
parent 2455251423
commit 3dba88cedd
57 changed files with 13587 additions and 5454 deletions

View File

@ -5,9 +5,28 @@ Dockerfile*
README.md
CHANGELOG.md
node_modules/
server/node_modules/
docker-build.log
yarn-build.log
build-docker.ps1
build-docker.bat
start-docker.bat
run-docker.ps1
src/
public/
.yarn/cache
.eslintcache
.github
.github_build
.build
NONPUBLIC/
NONPUBLIC/
package.json
package-lock.json
yarn.lock
.yarnrc.yml
.env
.env.local
.linguirc
.prettierignore
.prettierrc
.eslintignore

2
.gitignore vendored
View File

@ -2,7 +2,7 @@
# dependencies
/NONPUBLIC
/node_modules
node_modules
/.pnp
.pnp.js
.yarn/

View File

@ -1,6 +1,37 @@
:3000
encode zstd gzip
file_server {
root ./build
# ── Facebook OAuth2 microserver (Node.js en puerto 3002) ──────────────────────
handle /fb-server/* {
uri strip_prefix /fb-server
reverse_proxy localhost:3002
}
# ── yt-dlp stream extractor (servicio externo configurable via env) ───────────
handle /yt-stream/* {
uri strip_prefix /yt-stream
reverse_proxy {env.YTDLP_HOST}
}
# OAuth2 callback page must be served as a static HTML (not the SPA index)
handle /oauth2callback {
rewrite * /oauth2callback.html
file_server {
root /ui/build
}
}
# Facebook OAuth2 callback popup
handle /oauth/facebook/callback.html {
file_server {
root /ui/build
}
}
# SPA serve static files, fallback to index.html for client-side routing
handle {
root * /ui/build
try_files {path} /index.html
file_server
}

View File

@ -1,25 +1,43 @@
ARG NODE_IMAGE=node:21-alpine3.20
ARG CADDY_IMAGE=caddy:2.8.4-alpine
ARG NODE_IMAGE=node:21-alpine3.20
FROM $NODE_IMAGE AS builder
ENV PUBLIC_URL="./"
COPY . /ui
WORKDIR /ui
RUN cd /ui && \
yarn install && \
yarn build
# ── Stage 1: Install server deps (tiny only express+cors) ──────────────────
FROM $NODE_IMAGE AS server-deps
WORKDIR /srv
COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev --no-audit --prefer-offline
# ── Stage 2: Production image (Caddy + Node.js) ───────────────────────────────
FROM $CADDY_IMAGE
COPY --from=builder /ui/build /ui/build
COPY --from=builder /ui/Caddyfile /ui/Caddyfile
# Install Node.js to run the Facebook OAuth2 microserver
RUN apk add --no-cache nodejs
# Copy pre-built React app (built on host with: npm run build / yarn build)
COPY build /ui/build
# Copy Caddy config
COPY Caddyfile /ui/Caddyfile
# Copy Node.js FB server + its deps
COPY server /ui/server
COPY --from=server-deps /srv/node_modules /ui/server/node_modules
# Copy entrypoint script
COPY docker-entrypoint.sh /ui/docker-entrypoint.sh
RUN chmod +x /ui/docker-entrypoint.sh
# Persistent volume for FB OAuth2 tokens (config.json)
VOLUME ["/data/fb"]
WORKDIR /ui
EXPOSE 3000
CMD [ "caddy", "run", "--config", "/ui/Caddyfile" ]
# Runtime environment variables (overridden at runtime via -e or docker-compose)
ENV CORE_ADDRESS=""
ENV YTDLP_URL=""
ENV FB_SERVER_URL=""
ENV YTDLP_HOST="192.168.1.20:8282"
CMD ["/ui/docker-entrypoint.sh"]

70
build-docker.bat Normal file
View File

@ -0,0 +1,70 @@
@echo off
setlocal
set IMAGE_NAME=restreamer-ui-v2
set TAG=latest
set CONTAINER_NAME=restreamer-ui-test
echo.
echo ====================================================
echo PASO 1: Compilar React en el host (yarn build)
echo ====================================================
echo.
cd /d %~dp0
call yarn build
if %ERRORLEVEL% NEQ 0 (
echo === yarn build FALLIDO ===
exit /b %ERRORLEVEL%
)
echo.
echo ====================================================
echo PASO 2: Construir imagen Docker
echo ====================================================
echo.
docker build --tag %IMAGE_NAME%:%TAG% --file Dockerfile .
if %ERRORLEVEL% NEQ 0 (
echo === Docker build FALLIDO ===
exit /b %ERRORLEVEL%
)
echo.
echo === BUILD DOCKER EXITOSO ===
echo.
docker images %IMAGE_NAME%:%TAG%
echo.
echo ====================================================
echo PASO 3: Arrancar contenedor en http://localhost:3000
echo ====================================================
echo.
docker stop %CONTAINER_NAME% 2>nul
docker rm %CONTAINER_NAME% 2>nul
docker run -d ^
--name %CONTAINER_NAME% ^
--restart unless-stopped ^
-p 3000:3000 ^
-e "CORE_ADDRESS=https://restreamer.nextream.sytes.net" ^
-e "YTDLP_HOST=192.168.1.20:8282" ^
-e "YTDLP_URL=" ^
-e "FB_SERVER_URL=" ^
-e "FB_ENCRYPTION_SECRET=restreamer-ui-fb-secret-key-32x!" ^
-v "restreamer-ui-fb-data:/data/fb" ^
%IMAGE_NAME%:%TAG%
if %ERRORLEVEL% NEQ 0 (
echo === Contenedor FALLIDO al arrancar ===
exit /b %ERRORLEVEL%
)
echo.
echo === Contenedor corriendo ===
echo.
echo UI: http://localhost:3000/ui/
echo.
docker logs -f %CONTAINER_NAME%

80
build-docker.ps1 Normal file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env pwsh
# build-docker.ps1 - Build and run the restreamer-ui-v2 Docker image
$ErrorActionPreference = "Stop"
$ProjectDir = $PSScriptRoot
$ImageName = "restreamer-ui-v2"
$Tag = "latest"
$LogFile = "$ProjectDir\docker-build.log"
Write-Host "=== Building Docker image: ${ImageName}:${Tag} ===" -ForegroundColor Cyan
Write-Host "Project dir: $ProjectDir"
Write-Host "Log file: $LogFile"
Write-Host ""
Set-Location $ProjectDir
# Build
$buildOutput = docker build `
--tag "${ImageName}:${Tag}" `
--file "$ProjectDir\Dockerfile" `
--progress plain `
"$ProjectDir" 2>&1
$buildOutput | Out-File -FilePath $LogFile -Encoding utf8
$buildOutput | Write-Host
if ($LASTEXITCODE -ne 0) {
Write-Host "`n=== BUILD FAILED (exit $LASTEXITCODE) ===" -ForegroundColor Red
Write-Host "See log: $LogFile"
exit $LASTEXITCODE
}
Write-Host "`n=== BUILD SUCCEEDED ===" -ForegroundColor Green
# Show image size
docker images "${ImageName}:${Tag}"
# ── Ask user if they want to run ──────────────────────────────────────────────
$run = Read-Host "`nRun container now? [Y/n]"
if ($run -eq '' -or $run -match '^[Yy]') {
# Stop existing container if running
$existing = docker ps -a --filter "name=restreamer-ui-test" --format "{{.ID}}" 2>&1
if ($existing) {
Write-Host "Stopping existing container..." -ForegroundColor Yellow
docker stop restreamer-ui-test 2>&1 | Out-Null
docker rm restreamer-ui-test 2>&1 | Out-Null
}
Write-Host "`nStarting container on http://localhost:3000 ..." -ForegroundColor Cyan
Write-Host "Environment:"
Write-Host " CORE_ADDRESS = https://restreamer.nextream.sytes.net"
Write-Host " YTDLP_HOST = 192.168.1.20:8282"
Write-Host " FB_SERVER_URL = http://localhost:3000/fb-server"
Write-Host ""
docker run -d `
--name restreamer-ui-test `
--restart unless-stopped `
-p 3000:3000 `
-e CORE_ADDRESS="https://restreamer.nextream.sytes.net" `
-e YTDLP_HOST="192.168.1.20:8282" `
-e YTDLP_URL="http://localhost:3000/yt-stream" `
-e FB_SERVER_URL="http://localhost:3000/fb-server" `
-e FB_ENCRYPTION_SECRET="restreamer-ui-fb-secret-key-32x!" `
-v "restreamer-ui-fb-data:/data/fb" `
"${ImageName}:${Tag}"
if ($LASTEXITCODE -eq 0) {
Write-Host "`n=== Container started ===" -ForegroundColor Green
Write-Host "UI: http://localhost:3000/ui/"
Write-Host ""
Write-Host "Logs (Ctrl+C to stop watching):"
docker logs -f restreamer-ui-test
} else {
Write-Host "Failed to start container." -ForegroundColor Red
exit 1
}
}

39
docker-compose.yml Normal file
View File

@ -0,0 +1,39 @@
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
image: restreamer-ui-v2:latest
container_name: restreamer-ui-test
restart: unless-stopped
ports:
- "3000:3000"
environment:
# ── Restreamer Core ────────────────────────────────────────────────────
# URL del Core al que se conecta la UI. Dejar vacío para auto-detectar
# desde window.location (cuando la UI está embebida dentro del Core).
CORE_ADDRESS: "https://restreamer.nextream.sytes.net"
# ── yt-dlp / stream extractor ──────────────────────────────────────────
# Host:puerto del servicio extractor (usado por Caddy para reverse_proxy).
# Caddy expondrá el servicio en http://localhost:3000/yt-stream/
YTDLP_HOST: "192.168.1.20:8282"
# 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: ""
# ── Facebook OAuth2 microserver ────────────────────────────────────────
# Dejar vacío → Caddy proxy /fb-server → localhost:3002 (sin CORS)
FB_SERVER_URL: ""
# Clave de cifrado para tokens almacenados (cámbiala en producción)
FB_ENCRYPTION_SECRET: "restreamer-ui-fb-secret-key-32x!"
volumes:
# Persistencia de tokens OAuth2 (Facebook, YouTube, etc.)
- restreamer-ui-fb-data:/data/fb
volumes:
restreamer-ui-fb-data:
driver: local

43
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,43 @@
#!/bin/sh
set -e
# ── Generate runtime config from env vars ────────────────────────────────────
CONFIG_FILE="/ui/build/config.js"
cat > "$CONFIG_FILE" <<EOF
/**
* Restreamer UI - Runtime Configuration (auto-generated by docker-entrypoint.sh)
*/
window.__RESTREAMER_CONFIG__ = {
CORE_ADDRESS: "${CORE_ADDRESS:-}",
YTDLP_URL: "${YTDLP_URL:-}",
FB_SERVER_URL: "${FB_SERVER_URL:-}",
};
EOF
echo "[entrypoint] config.js generated:"
cat "$CONFIG_FILE"
# ── Set YTDLP_HOST for Caddy reverse_proxy (default: external service or localhost) ─
export YTDLP_HOST="${YTDLP_HOST:-192.168.1.20:8282}"
# ── Persist FB data directory ─────────────────────────────────────────────────
mkdir -p /data/fb
export FB_DATA_DIR="${FB_DATA_DIR:-/data/fb}"
# ── Start Facebook OAuth2 microserver in background ──────────────────────────
echo "[entrypoint] Starting Facebook OAuth2 server on :3002 ..."
FB_SERVER_PORT=3002 \
FB_DATA_DIR="$FB_DATA_DIR" \
FB_ENCRYPTION_SECRET="${FB_ENCRYPTION_SECRET:-restreamer-ui-fb-secret-key-32x!}" \
node /ui/server/index.js &
FB_PID=$!
echo "[entrypoint] FB server PID: $FB_PID"
# ── Wait briefly for FB server to bind ───────────────────────────────────────
sleep 1
# ── Start Caddy (foreground) ──────────────────────────────────────────────────
echo "[entrypoint] Starting Caddy on :3000 ..."
exec caddy run --config /ui/Caddyfile

View File

@ -6,17 +6,25 @@
*
* In Docker, mount this file or generate it via entrypoint script.
*
* CORE_ADDRESS: Restreamer Core URL. Leave empty to auto-detect from window.location.
* YTDLP_URL: yt-dlp stream extractor service URL (used to extract stream_url from YouTube).
* In development this is proxied via /yt-stream by setupProxy.js.
* In production set it to the actual service URL.
* CORE_ADDRESS: Restreamer Core URL. Leave empty to auto-detect from window.location.
* YTDLP_URL: yt-dlp stream extractor service URL (used to extract stream_url from YouTube).
* In development this is proxied via /yt-stream by setupProxy.js.
* In production set it to the actual service URL or leave empty
* to use the built-in Caddy reverse proxy at /yt-stream/.
* FB_SERVER_URL: Facebook OAuth2 microservice URL.
* In development proxied via /fb-server by setupProxy.js.
* In production leave empty to use the built-in Caddy reverse proxy at /fb-server/.
*
* Examples:
* CORE_ADDRESS = 'https://restreamer.nextream.sytes.net'
* YTDLP_URL = 'http://192.168.1.20:8282'
* CORE_ADDRESS = 'https://restreamer.nextream.sytes.net'
* YTDLP_URL = 'http://192.168.1.20:8282'
* FB_SERVER_URL = '' (leave empty Caddy proxies /fb-server localhost:3002)
*/
window.__RESTREAMER_CONFIG__ = {
CORE_ADDRESS: '',
YTDLP_URL: '',
FB_SERVER_URL: '',
};

View File

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Facebook OAuth2 Callback</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #18191a;
color: #e4e6eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
text-align: center;
}
.card {
background: #242526;
border-radius: 12px;
padding: 36px 44px;
max-width: 440px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.icon { font-size: 3.2rem; margin-bottom: 16px; }
h2 { font-size: 1.25rem; margin-bottom: 10px; font-weight: 600; }
p { font-size: 0.9rem; color: #aaa; line-height: 1.5; }
.error { color: #f44336; }
.success { color: #4caf50; }
.warn { color: #ff9800; }
.spinner {
width: 36px; height: 36px;
border: 3px solid rgba(45,136,255,0.2);
border-top-color: #2D88FF;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.debug {
margin-top: 16px;
background: #1a1a1a;
border-radius: 6px;
padding: 10px 14px;
font-size: 0.75rem;
color: #666;
text-align: left;
word-break: break-all;
white-space: pre-wrap;
display: none;
}
</style>
</head>
<body>
<div class="card">
<div class="spinner" id="spinner"></div>
<div class="icon" id="icon" style="display:none"></div>
<h2 id="title">Procesando autorización…</h2>
<p id="msg">Por favor espera</p>
<div class="debug" id="debug"></div>
</div>
<script>
(function () {
'use strict';
var TARGET_ORIGIN = '*';
function show(icon, title, msg, cls) {
document.getElementById('spinner').style.display = 'none';
var iconEl = document.getElementById('icon');
iconEl.style.display = 'block';
iconEl.textContent = icon;
document.getElementById('title').textContent = title;
var msgEl = document.getElementById('msg');
msgEl.textContent = msg;
msgEl.className = cls || '';
}
function showDebug(text) {
var el = document.getElementById('debug');
el.style.display = 'block';
el.textContent = text;
}
function parseParams(str) {
var params = {};
if (!str) return params;
str.replace(/^[?#]/, '').split('&').forEach(function (part) {
var idx = part.indexOf('=');
if (idx > -1) {
params[decodeURIComponent(part.slice(0, idx))] =
decodeURIComponent(part.slice(idx + 1).replace(/\+/g, ' '));
}
});
return params;
}
var hashParams = parseParams(window.location.hash);
var queryParams = parseParams(window.location.search);
// ── ERROR de Facebook ─────────────────────────────────────────────────
var errorCode = hashParams.error || queryParams.error;
var errorDesc = hashParams.error_description || queryParams.error_description
|| hashParams.error_reason || queryParams.error_reason
|| 'Autorización cancelada o denegada';
if (errorCode) {
show('❌', 'Autorización fallida', errorCode + ': ' + errorDesc, 'error');
showDebug('error: ' + errorCode + '\ndescription: ' + errorDesc);
if (window.opener) {
window.opener.postMessage({ type: 'fb_oauth_result', error: errorCode + ': ' + errorDesc }, TARGET_ORIGIN);
}
setTimeout(function () { window.close(); }, 4000);
return;
}
// ── AUTH CODE (response_type=code) — flujo preferido con backend ──────
// El backend (server/index.js) intercambia: code → short-lived → long-lived (60 días)
var code = queryParams.code;
var state = queryParams.state || '';
if (code) {
show('🔄', 'Intercambiando código…',
'Obteniendo token de larga duración (60 días)…', 'success');
// Enviar el code a la ventana principal; ella llamará al backend
if (window.opener) {
window.opener.postMessage(
{ type: 'fb_oauth_result', flow: 'code', code: code, state: state,
redirect_uri: window.location.origin + '/oauth/facebook/callback.html' },
TARGET_ORIGIN
);
show('✅', '¡Código enviado!', 'Procesando token de larga duración…', 'success');
setTimeout(function () { window.close(); }, 2500);
} else {
show('⚠️', 'Ventana sin opener',
'Esta página debe abrirse como popup desde Restreamer.', 'warn');
showDebug('code recibido pero window.opener es null.\ncode: ' + code.slice(0, 20) + '…');
}
return;
}
// ── TOKEN IMPLÍCITO (response_type=token) — fallback 2h ──────────────
// Se usa cuando el App Secret no está configurado en el backend.
var accessToken = hashParams.access_token || queryParams.access_token;
var expiresIn = parseInt(hashParams.expires_in || queryParams.expires_in || '0', 10);
if (accessToken) {
show('✅', '¡Token recibido!', 'Enviando a Restreamer para upgrade…', 'success');
if (window.opener) {
window.opener.postMessage(
{ type: 'fb_oauth_result', flow: 'token',
access_token: accessToken, expires_in: expiresIn },
TARGET_ORIGIN
);
setTimeout(function () { window.close(); }, 1800);
} else {
show('⚠️', 'Ventana sin opener',
'Copia el token manualmente.', 'warn');
showDebug('access_token: ' + accessToken.slice(0, 30) + '…\nexpires_in: ' + expiresIn);
}
return;
}
// ── Sin datos útiles ──────────────────────────────────────────────────
show('⚠️', 'Sin datos de autorización',
'No se recibió code ni token. Revisa la configuración de tu App de Facebook.', 'warn');
showDebug('hash: ' + window.location.hash + '\nsearch: ' + window.location.search);
if (window.opener) {
window.opener.postMessage({ type: 'fb_oauth_result', error: 'no_data_received' }, TARGET_ORIGIN);
}
setTimeout(function () { window.close(); }, 6000);
})();
</script>
</body>
</html>

202
public/oauth2callback.html Normal file
View File

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>YouTube OAuth2 Callback</title>
<style>
body {
background: #1a1a1a;
color: #fff;
font-family: sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
flex-direction: column;
}
.card {
background: #2a2a2a;
border-radius: 8px;
padding: 32px 40px;
text-align: center;
max-width: 460px;
width: 90%;
}
.icon { font-size: 3rem; margin-bottom: 12px; }
h2 { margin: 0 0 8px; font-size: 1.2rem; }
p { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.error { color: #f44336; }
.success { color: #4caf50; }
.warn { color: #ff9800; }
pre {
background: #111;
padding: 12px;
border-radius: 4px;
word-break: break-all;
font-size: 0.75rem;
margin-top: 12px;
color: #4fc3f7;
text-align: left;
max-height: 120px;
overflow-y: auto;
}
button {
margin-top: 12px;
padding: 8px 20px;
background: #1976d2;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
button:hover { background: #1565c0; }
.progress {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #4caf50;
animation: progress 1.5s linear forwards;
}
@keyframes progress { from { width: 0%; } to { width: 100%; } }
</style>
</head>
<body>
<div class="card" id="container">
<div class="icon" id="icon"></div>
<h2 id="title">Processing authorization...</h2>
<p id="message">Please wait, do not close this window.</p>
</div>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var code = params.get('code');
var error = params.get('error');
var state = params.get('state');
var icon = document.getElementById('icon');
var title = document.getElementById('title');
var message = document.getElementById('message');
var container = document.getElementById('container');
function setStatus(ic, ti, msg, cls) {
icon.textContent = ic;
title.textContent = ti;
message.textContent = msg;
if (cls) message.className = cls;
}
/* ── ERROR ───────────────────────────────────────────────── */
if (error) {
setStatus('❌', 'Authorization denied', error, 'error');
if (window.opener) {
window.opener.postMessage({ service: 'youtube', error: error }, '*');
}
setTimeout(function () { window.close(); }, 3000);
return;
}
/* ── CODE (authorization code flow) ─────────────────────── */
if (code) {
setStatus('✅', 'Authorization successful!', 'Sending to Restreamer…', 'success');
// Barra de progreso visual
var prog = document.createElement('div');
prog.className = 'progress';
var bar = document.createElement('div');
bar.className = 'progress-bar';
prog.appendChild(bar);
container.appendChild(prog);
if (window.opener) {
// Enviar con reintentos hasta que el padre responda "ack"
var ackReceived = false;
var attempts = 0;
var maxAttempts = 10;
// Escuchar el ACK del padre
window.addEventListener('message', function onAck(ev) {
if (ev.data && ev.data.service === 'youtube' && ev.data.ack === true) {
ackReceived = true;
window.removeEventListener('message', onAck);
setStatus('✅', 'Done!', 'This window will close automatically.', 'success');
setTimeout(function () { window.close(); }, 800);
}
});
function sendCode() {
if (ackReceived || attempts >= maxAttempts) return;
attempts++;
try {
window.opener.postMessage(
{ service: 'youtube', code: code, state: state },
window.location.origin // mismo origen
);
} catch (e) {
// Si falla por política de origen, intentar con '*'
try { window.opener.postMessage({ service: 'youtube', code: code, state: state }, '*'); } catch (e2) {}
}
setTimeout(sendCode, 600);
}
sendCode();
// Si tras 8 s no hubo ack, mostrar código manual
setTimeout(function () {
if (!ackReceived) {
setStatus('⚠️', 'Could not reach Restreamer', 'Copy this code and paste it manually:', 'warn');
var pre = document.createElement('pre');
pre.textContent = code;
container.appendChild(pre);
var btn = document.createElement('button');
btn.textContent = '📋 Copy code';
btn.onclick = function () {
navigator.clipboard.writeText(code).then(function () { btn.textContent = '✅ Copied!'; });
};
container.appendChild(btn);
}
}, 8000);
} else {
// Abierto directamente (sin window.opener)
setStatus('⚠️', 'No parent window found', 'Copy this authorization code:', 'warn');
var pre = document.createElement('pre');
pre.textContent = code;
container.appendChild(pre);
var btn = document.createElement('button');
btn.textContent = '📋 Copy code';
btn.onclick = function () {
navigator.clipboard.writeText(code).then(function () { btn.textContent = '✅ Copied!'; });
};
container.appendChild(btn);
}
return;
}
/* ── TOKEN IMPLÍCITO (hash) ──────────────────────────────── */
var hashParams = new URLSearchParams(window.location.hash.replace('#', '?'));
var token = hashParams.get('access_token');
if (token) {
setStatus('✅', 'Token received!', 'Sending to Restreamer…', 'success');
if (window.opener) {
window.opener.postMessage({ service: 'youtube', access_token: token }, '*');
setTimeout(function () { window.close(); }, 1500);
}
return;
}
/* ── NADA ÚTIL ───────────────────────────────────────────── */
setStatus('⚠️', 'No authorization data found', 'This page should only be opened by the OAuth2 flow.', 'warn');
})();
</script>
</body>
</html>

55
run-docker.ps1 Normal file
View File

@ -0,0 +1,55 @@
# ============================================================
# run-docker.ps1 — Arrancar restreamer-ui-v2 en Docker
# Uso: .\run-docker.ps1
# ============================================================
$IMAGE = "restreamer-ui-v2:latest"
$CONTAINER = "restreamer-ui-test"
$PORT = 3000
Write-Host "`n=== Restreamer UI Docker Launcher ===" -ForegroundColor Cyan
# Detener y eliminar contenedor previo
$existing = & docker ps -a --filter "name=$CONTAINER" --format "{{.Names}}" 2>$null
if ($existing -eq $CONTAINER) {
Write-Host "Deteniendo contenedor anterior..."
& docker stop $CONTAINER | Out-Null
& docker rm $CONTAINER | Out-Null
}
# Verificar que la imagen existe
$img = & docker images $IMAGE --format "{{.Repository}}:{{.Tag}}" 2>$null
if (-not $img) {
Write-Host "`nImagen $IMAGE no encontrada." -ForegroundColor Red
Write-Host "Ejecuta primero: yarn build y luego: docker build --tag restreamer-ui-v2:latest ." -ForegroundColor Yellow
exit 1
}
Write-Host "Arrancando contenedor $CONTAINER en puerto $PORT ..." -ForegroundColor Green
& docker run -d `
--name $CONTAINER `
--restart unless-stopped `
-p "${PORT}:3000" `
-e "CORE_ADDRESS=https://restreamer.nextream.sytes.net" `
-e "YTDLP_HOST=192.168.1.20:8282" `
-e "YTDLP_URL=" `
-e "FB_SERVER_URL=" `
-e "FB_ENCRYPTION_SECRET=restreamer-ui-fb-secret-key-32x!" `
-v "restreamer-ui-fb-data:/data/fb" `
$IMAGE
if ($LASTEXITCODE -ne 0) {
Write-Host "`nError al arrancar el contenedor." -ForegroundColor Red
exit $LASTEXITCODE
}
Start-Sleep -Seconds 2
Write-Host "`n=== Contenedor corriendo ===" -ForegroundColor Green
Write-Host " UI: http://localhost:$PORT/ui/"
Write-Host " Health: http://localhost:$PORT/"
Write-Host ""
Write-Host "Logs en vivo (Ctrl+C para salir):" -ForegroundColor Yellow
& docker logs -f $CONTAINER

510
server/index.js Normal file
View File

@ -0,0 +1,510 @@
'use strict';
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const https = require('https');
const http = require('http');
const crypto = require('crypto');
const PORT = parseInt(process.env.FB_SERVER_PORT || '3002', 10);
// FB_DATA_DIR env var allows Docker to mount a persistent volume for tokens
// Default: <server_dir>/data (development)
const DATA_DIR = process.env.FB_DATA_DIR
? path.resolve(process.env.FB_DATA_DIR)
: path.resolve(__dirname, 'data');
const CFG_PATH = path.join(DATA_DIR, 'config.json');
// ── Encryption helpers (AES-256-GCM) ─────────────────────────────────────────
// Key is derived from a secret stored in config; if none, tokens are stored as-is.
const ENCRYPTION_SECRET = process.env.FB_ENCRYPTION_SECRET || 'restreamer-ui-fb-secret-key-32x!';
function deriveKey(secret) {
return crypto.createHash('sha256').update(secret).digest(); // 32 bytes
}
function encrypt(text) {
try {
const key = deriveKey(ENCRYPTION_SECRET);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return iv.toString('hex') + ':' + tag.toString('hex') + ':' + enc.toString('hex');
} catch (_) {
return text; // fallback: store plain if crypto fails
}
}
function decrypt(data) {
try {
if (!data || !data.includes(':')) return data;
const [ivHex, tagHex, encHex] = data.split(':');
const key = deriveKey(ENCRYPTION_SECRET);
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const encBuf = Buffer.from(encHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(encBuf, undefined, 'utf8') + decipher.final('utf8');
} catch (_) {
return data; // fallback: return as-is if decryption fails
}
}
// ── config.json persistence ───────────────────────────────────────────────────
/**
* Schema in config.json:
* {
* "__config": { "app_id": "...", "app_secret": "..." },
* "<fb_user_id>": {
* "fb_user_id": string, // PK — Facebook User ID
* "name": string, // Display name
* "token_type": "USER"|"PAGE", // Token type
* "access_token": string, // AES-256-GCM encrypted long-lived token
* "expires_at": number, // Unix ms — when the token expires
* "scope_granted": string[], // Scopes accepted by the user
* "pages": Page[], // List of managed pages (each has its own long-lived token)
* "updated_at": number // Last update Unix ms
* }
* }
*/
function loadCfg() {
if (!fs.existsSync(CFG_PATH)) return {};
try { return JSON.parse(fs.readFileSync(CFG_PATH, 'utf8')); }
catch (_) { return {}; }
}
function saveCfg(data) {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CFG_PATH, JSON.stringify(data, null, 2), 'utf8');
}
// ── Facebook Graph API helpers ────────────────────────────────────────────────
/**
* Simple GET to Facebook Graph API returning parsed JSON.
*/
function fbGet(url) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
lib.get(url, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
try { resolve(JSON.parse(body)); }
catch (e) { reject(new Error('Invalid JSON from Facebook: ' + body.slice(0, 200))); }
});
}).on('error', reject);
});
}
/**
* POST form-encoded data to Facebook Graph API.
*/
function fbPost(url, params) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(params).toString();
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
let buf = '';
res.on('data', (c) => { buf += c; });
res.on('end', () => {
try { resolve(JSON.parse(buf)); }
catch (e) { reject(new Error('Invalid JSON: ' + buf.slice(0, 200))); }
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
/**
* Exchange short-lived user token long-lived user token (~60 days).
*/
async function exchangeToLongLived(appId, appSecret, shortToken) {
const data = await fbGet(
`https://graph.facebook.com/v19.0/oauth/access_token` +
`?grant_type=fb_exchange_token` +
`&client_id=${encodeURIComponent(appId)}` +
`&client_secret=${encodeURIComponent(appSecret)}` +
`&fb_exchange_token=${encodeURIComponent(shortToken)}`
);
if (data.error) throw new Error(`FB exchange error: ${data.error.message}`);
return data; // { access_token, token_type, expires_in }
}
/**
* Exchange auth code short-lived token long-lived token.
*/
async function exchangeCodeToLongLived(appId, appSecret, code, redirectUri) {
// Step 1: code → short-lived user token
const step1 = await fbGet(
`https://graph.facebook.com/v19.0/oauth/access_token` +
`?client_id=${encodeURIComponent(appId)}` +
`&client_secret=${encodeURIComponent(appSecret)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&code=${encodeURIComponent(code)}`
);
if (step1.error) throw new Error(`Code exchange error: ${step1.error.message}`);
const shortToken = step1.access_token;
// Step 2: short-lived → long-lived (~60 days)
const step2 = await exchangeToLongLived(appId, appSecret, shortToken);
return { shortToken, ...step2 };
}
/**
* Fetch long-lived tokens for all pages managed by the user.
* Page tokens from /me/accounts with a long-lived user token are already long-lived.
*/
async function fetchPages(longLivedUserToken) {
const data = await fbGet(
`https://graph.facebook.com/v19.0/me/accounts` +
`?fields=id,name,access_token,category,tasks` +
`&access_token=${encodeURIComponent(longLivedUserToken)}`
);
if (data.error) throw new Error(`fetchPages error: ${data.error.message}`);
return (data.data || []).map((p) => ({
id: p.id,
name: p.name,
category: p.category || '',
tasks: p.tasks || [],
access_token: p.access_token, // long-lived page token
token_type: 'PAGE',
}));
}
/**
* Fetch basic user info.
*/
async function fetchUserInfo(token) {
const data = await fbGet(
`https://graph.facebook.com/v19.0/me` +
`?fields=id,name` +
`&access_token=${encodeURIComponent(token)}`
);
if (data.error) throw new Error(data.error.message);
return data;
}
/**
* Parse granted scopes from token debug info.
*/
async function debugToken(appId, appSecret, token) {
const data = await fbGet(
`https://graph.facebook.com/v19.0/debug_token` +
`?input_token=${encodeURIComponent(token)}` +
`&access_token=${encodeURIComponent(appId + '|' + appSecret)}`
);
if (data.error) return { scopes: [], expires_at: 0 };
const d = data.data || {};
return {
scopes: d.scopes || [],
expires_at: d.expires_at ? d.expires_at * 1000 : 0, // convert to ms
};
}
// ── Serialize / deserialize account (encrypts access_token) ──────────────────
function serializeAccount(acc) {
return {
...acc,
access_token: encrypt(acc.access_token || ''),
pages: (acc.pages || []).map((p) => ({
...p,
access_token: encrypt(p.access_token || ''),
})),
};
}
function deserializeAccount(acc) {
if (!acc) return null;
return {
...acc,
access_token: decrypt(acc.access_token || ''),
pages: (acc.pages || []).map((p) => ({
...p,
access_token: decrypt(p.access_token || ''),
})),
};
}
/** Strip access_token from account before sending to client UI */
function publicAccount(acc) {
// eslint-disable-next-line no-unused-vars
const { access_token, pages, ...rest } = acc;
return {
...rest,
pages: (pages || []).map(({ access_token: _t, ...p }) => p),
};
}
// ── Express app ───────────────────────────────────────────────────────────────
const app = express();
app.use(cors({ origin: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type'] }));
app.use(express.json());
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (_, res) => {
res.json({ ok: true, config: CFG_PATH, port: PORT, ts: new Date().toISOString() });
});
// ── App config (app_id + app_secret) ─────────────────────────────────────────
app.get('/fb/config', (_, res) => {
const cfg = loadCfg();
const c = cfg.__config || {};
res.json({ app_id: c.app_id || '', has_secret: !!(c.app_secret) });
});
app.put('/fb/config', (req, res) => {
const { app_id, app_secret } = req.body || {};
const cfg = loadCfg();
cfg.__config = {
...(cfg.__config || {}),
...(app_id !== undefined ? { app_id: String(app_id) } : {}),
...(app_secret !== undefined ? { app_secret: String(app_secret) } : {}),
};
saveCfg(cfg);
res.json({ ok: true });
});
// ── List accounts (no tokens exposed) ────────────────────────────────────────
app.get('/fb/accounts', (_, res) => {
const cfg = loadCfg();
const accounts = Object.values(cfg)
.filter((v) => v && v.fb_user_id && v.fb_user_id !== '__config')
.map(deserializeAccount)
.map(publicAccount);
res.json(accounts);
});
// ── Get single account token (for internal use — stream key generation) ───────
// Returns full account including decrypted token
app.get('/fb/accounts/:id/token', (req, res) => {
const cfg = loadCfg();
const raw = cfg[req.params.id];
if (!raw) return res.status(404).json({ error: 'Account not found' });
const acc = deserializeAccount(raw);
res.json({
fb_user_id: acc.fb_user_id,
name: acc.name,
token_type: acc.token_type,
access_token: acc.access_token,
expires_at: acc.expires_at,
scope_granted: acc.scope_granted || [],
pages: acc.pages || [],
});
});
// ── Delete account ────────────────────────────────────────────────────────────
app.delete('/fb/accounts/:id', (req, res) => {
const cfg = loadCfg();
if (!cfg[req.params.id]) return res.status(404).json({ error: 'Account not found' });
delete cfg[req.params.id];
saveCfg(cfg);
res.json({ ok: true });
});
// ── MAIN FLOW: Exchange auth code → short-lived → long-lived token ─────────────
/**
* POST /fb/exchange
* Body: { code: string, redirect_uri: string }
*
* Flow:
* 1. code short-lived user token (Graph /oauth/access_token)
* 2. short long-lived user token (Graph /oauth/access_token?grant_type=fb_exchange_token)
* 3. Fetch user info (id, name)
* 4. Debug token (scopes, expires_at)
* 5. Fetch pages with long-lived page tokens
* 6. Persist encrypted in config.json
* 7. Return public account (no tokens)
*/
app.post('/fb/exchange', async (req, res) => {
const { code, redirect_uri } = req.body || {};
if (!code || !redirect_uri) {
return res.status(400).json({ error: 'code and redirect_uri are required' });
}
const cfg = loadCfg();
const c = cfg.__config || {};
if (!c.app_id || !c.app_secret) {
return res.status(400).json({ error: 'Facebook App ID and App Secret are not configured. Go to Settings → Integrations.' });
}
try {
// Step 1 + 2: code → long-lived user token
const { access_token: longToken, expires_in } = await exchangeCodeToLongLived(
c.app_id, c.app_secret, code, redirect_uri
);
// expires_in is in seconds; Facebook long-lived tokens expire in ~60 days
const expires_at = expires_in
? Date.now() + parseInt(expires_in, 10) * 1000
: Date.now() + 60 * 24 * 60 * 60 * 1000; // fallback: 60 days
// Step 3: user info
const userInfo = await fetchUserInfo(longToken);
// Step 4: scopes
const { scopes } = await debugToken(c.app_id, c.app_secret, longToken);
// Step 5: pages (already long-lived when fetched with long-lived user token)
let pages = [];
try { pages = await fetchPages(longToken); } catch (_) { /* user may have no pages */ }
// Step 6: persist
const account = {
fb_user_id: userInfo.id,
name: userInfo.name,
token_type: 'USER',
access_token: longToken, // will be encrypted by serializeAccount
expires_at,
scope_granted: scopes,
pages,
updated_at: Date.now(),
};
cfg[userInfo.id] = serializeAccount(account);
saveCfg(cfg);
// Step 7: respond with public info
res.json({ ok: true, account: publicAccount(account) });
} catch (err) {
console.error('[fb/exchange] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Refresh: exchange a still-valid token for a new long-lived one ────────────
/**
* POST /fb/refresh/:id
* Re-exchanges the stored long-lived token for a fresh one.
* Facebook supports this while the token is still valid.
*/
app.post('/fb/refresh/:id', async (req, res) => {
const cfg = loadCfg();
const raw = cfg[req.params.id];
if (!raw) return res.status(404).json({ error: 'Account not found' });
const c = cfg.__config || {};
if (!c.app_id || !c.app_secret) {
return res.status(400).json({ error: 'App ID / Secret not configured' });
}
const acc = deserializeAccount(raw);
try {
const { access_token: newToken, expires_in } = await exchangeToLongLived(
c.app_id, c.app_secret, acc.access_token
);
const expires_at = expires_in
? Date.now() + parseInt(expires_in, 10) * 1000
: Date.now() + 60 * 24 * 60 * 60 * 1000;
const { scopes } = await debugToken(c.app_id, c.app_secret, newToken);
let pages = acc.pages || [];
try { pages = await fetchPages(newToken); } catch (_) { /* keep old */ }
const updated = {
...acc,
access_token: newToken,
expires_at,
scope_granted: scopes,
pages,
updated_at: Date.now(),
};
cfg[req.params.id] = serializeAccount(updated);
saveCfg(cfg);
res.json({ ok: true, account: publicAccount(updated) });
} catch (err) {
console.error('[fb/refresh] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Manually save a short-lived token and upgrade it ─────────────────────────
/**
* POST /fb/upgrade
* Body: { access_token: string } (short-lived token from implicit flow)
* Upgrades to long-lived and saves.
*/
app.post('/fb/upgrade', async (req, res) => {
const { access_token: shortToken } = req.body || {};
if (!shortToken) return res.status(400).json({ error: 'access_token is required' });
const cfg = loadCfg();
const c = cfg.__config || {};
if (!c.app_id || !c.app_secret) {
return res.status(400).json({ error: 'App ID / Secret not configured. Cannot upgrade token.' });
}
try {
const { access_token: longToken, expires_in } = await exchangeToLongLived(
c.app_id, c.app_secret, shortToken
);
const expires_at = expires_in
? Date.now() + parseInt(expires_in, 10) * 1000
: Date.now() + 60 * 24 * 60 * 60 * 1000;
const userInfo = await fetchUserInfo(longToken);
const { scopes } = await debugToken(c.app_id, c.app_secret, longToken);
let pages = [];
try { pages = await fetchPages(longToken); } catch (_) { /* no pages */ }
const account = {
fb_user_id: userInfo.id,
name: userInfo.name,
token_type: 'USER',
access_token: longToken,
expires_at,
scope_granted: scopes,
pages,
updated_at: Date.now(),
};
cfg[userInfo.id] = serializeAccount(account);
saveCfg(cfg);
res.json({ ok: true, account: publicAccount(account) });
} catch (err) {
console.error('[fb/upgrade] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Start server ──────────────────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n[fb-server] ✅ http://0.0.0.0:${PORT}`);
console.log(`[fb-server] 💾 Config: ${CFG_PATH}`);
console.log(`[fb-server] 🔐 Encryption: AES-256-GCM\n`);
console.log(' GET /health');
console.log(' GET /fb/config');
console.log(' PUT /fb/config { app_id, app_secret }');
console.log(' GET /fb/accounts');
console.log(' GET /fb/accounts/:id/token');
console.log(' DELETE /fb/accounts/:id');
console.log(' POST /fb/exchange { code, redirect_uri } ← Auth Code flow');
console.log(' POST /fb/refresh/:id ← Renew long-lived token');
console.log(' POST /fb/upgrade { access_token } ← Upgrade short-lived token\n');
});
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));

18
server/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "restreamer-ui-server",
"version": "1.0.0",
"description": "Microservicio Express + SQLite para cuentas OAuth de Restreamer UI",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2"
},
"engines": {
"node": ">=18"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
const path = require('path');
const fs = require('fs');
/**
* Development proxy (CRA - only active with `npm start`).
@ -6,13 +8,16 @@ const { createProxyMiddleware } = require('http-proxy-middleware');
* .env / .env.local:
* REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net
* REACT_APP_YTDLP_URL=http://192.168.1.20:8282
* REACT_APP_FB_SERVER_URL=http://localhost:3002 (optional, default shown)
*/
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 CORE_TARGET = process.env.REACT_APP_CORE_URL || 'http://localhost:8080';
const YTDLP_TARGET = process.env.REACT_APP_YTDLP_URL || 'http://localhost:8282';
const FB_SERVER_TARGET = process.env.REACT_APP_FB_SERVER_URL || 'http://localhost:3002';
console.log('\n[setupProxy] ─────────────────────────────────────');
console.log(`[setupProxy] Core → ${CORE_TARGET}`);
console.log(`[setupProxy] yt-dlp → ${YTDLP_TARGET}`);
console.log(`[setupProxy] Core → ${CORE_TARGET}`);
console.log(`[setupProxy] yt-dlp → ${YTDLP_TARGET}`);
console.log(`[setupProxy] fb-server → ${FB_SERVER_TARGET}`);
console.log('[setupProxy] ─────────────────────────────────────\n');
let coreUrl;
@ -69,4 +74,44 @@ module.exports = function (app) {
},
}),
);
// Facebook OAuth server: /fb-server/* → http://localhost:3002/*
// Routes: /fb/config, /fb/accounts, /fb/exchange, /fb/refresh/:id, /fb/upgrade
app.use(
'/fb-server',
createProxyMiddleware({
target: FB_SERVER_TARGET,
changeOrigin: true,
secure: false,
pathRewrite: { '^/fb-server': '' },
onError: (err, req, res) => {
console.error(`[setupProxy] fb-server proxy error: ${err.code}${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'fb-server unavailable', message: err.message }));
}
},
}),
);
// OAuth2 callback: sirve el HTML de callback para YouTube y otras plataformas
app.get('/oauth2callback', (req, res) => {
const callbackFile = path.join(__dirname, '..', 'public', 'oauth2callback.html');
if (fs.existsSync(callbackFile)) {
res.sendFile(callbackFile);
} else {
res.status(404).send('oauth2callback.html not found');
}
});
// Facebook OAuth2 callback popup
app.get('/oauth/facebook/callback.html', (req, res) => {
const callbackFile = path.join(__dirname, '..', 'public', 'oauth', 'facebook', 'callback.html');
if (fs.existsSync(callbackFile)) {
res.sendFile(callbackFile);
} else {
res.status(404).send('Facebook callback.html not found');
}
});
};

213
src/utils/autoStreamKey.js Normal file
View File

@ -0,0 +1,213 @@
/**
* autoStreamKey.js
*
* Auto-regenera el stream key para YouTube (OAuth2) y Facebook (Graph API)
* justo antes de activar una publication desde el switch de la lista.
*/
import ytOAuth from './ytOAuth';
const YT_API = 'https://www.googleapis.com/youtube/v3';
const FB_API = 'https://graph.facebook.com/v19.0';
// ─── helpers ──────────────────────────────────────────────────────────────────
function fbExtractKey(streamUrl) {
if (!streamUrl) return '';
const parts = streamUrl.split('/');
return parts[parts.length - 1] || '';
}
// ─── YouTube ──────────────────────────────────────────────────────────────────
async function refreshYoutube(restreamer, channelid, egressId, svc) {
const { account_key, title, description, privacy_status, scheduled_start, mode, primary, backup } = svc;
if (!account_key) return false;
let token;
try {
token = await ytOAuth.getValidToken(account_key);
} catch (e) {
console.warn('[autoStreamKey] YouTube token refresh failed:', e.message);
return false;
}
const headers = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' };
const startTime = scheduled_start
? new Date(scheduled_start).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
try {
// 1) LiveBroadcast
const bResp = await fetch(`${YT_API}/liveBroadcasts?part=snippet,status,contentDetails`, {
method: 'POST', headers,
body: JSON.stringify({
snippet: { title: title || 'Live Stream', description: description || '', scheduledStartTime: startTime },
status: { privacyStatus: privacy_status || 'public', selfDeclaredMadeForKids: false },
contentDetails: { enableAutoStart: true, enableAutoStop: true, enableDvr: true, recordFromStart: true },
}),
});
const bData = await bResp.json();
if (!bResp.ok) throw new Error('Broadcast: ' + (bData.error ? bData.error.message : `HTTP ${bResp.status}`));
const broadcastId = bData.id;
// 2) LiveStream
const sResp = await fetch(`${YT_API}/liveStreams?part=snippet,cdn,status`, {
method: 'POST', headers,
body: JSON.stringify({
snippet: { title: title || 'Live Stream' },
cdn: { frameRate: 'variable', ingestionType: 'rtmp', resolution: 'variable' },
contentDetails: { isReusable: false },
}),
});
const sData = await sResp.json();
if (!sResp.ok) throw new Error('Stream: ' + (sData.error ? sData.error.message : `HTTP ${sResp.status}`));
const streamId = sData.id;
const streamKey = sData.cdn?.ingestionInfo?.streamName || '';
// 3) Bind
await fetch(`${YT_API}/liveBroadcasts/bind?id=${broadcastId}&part=id,contentDetails&streamId=${streamId}`, {
method: 'POST', headers,
});
// 4) Persistir nuevos settings en metadata
const newSvc = { ...svc, stream_key: streamKey, oauth_broadcast_id: broadcastId, oauth_stream_id: streamId };
// Obtener metadata completa, parchear settings, guardar
const meta = await restreamer.GetEgressMetadata(channelid, egressId);
meta.settings = newSvc;
await restreamer.SetEgressMetadata(channelid, egressId, meta);
// 5) Actualizar outputs del proceso ffmpeg
const newOutputs = buildYoutubeOutputs(newSvc);
if (newOutputs.length > 0) {
await restreamer.UpdateEgressStreamKey(channelid, egressId, newOutputs);
}
console.info('[autoStreamKey] YouTube stream key refreshed:', streamKey, '| broadcast:', broadcastId);
return true;
} catch (e) {
console.warn('[autoStreamKey] YouTube refresh error:', e.message);
return false;
}
}
function buildYoutubeOutputs(s) {
const outputs = [];
if (!s.stream_key) return outputs;
const options = ['-f', 'flv'];
if (s.primary !== false) {
outputs.push({ address: 'rtmps://a.rtmp.youtube.com/live2/' + s.stream_key, options: [...options] });
}
if (s.backup) {
outputs.push({ address: 'rtmps://b.rtmp.youtube.com/live2?backup=1/' + s.stream_key, options: [...options] });
}
return outputs;
}
// ─── Facebook ─────────────────────────────────────────────────────────────────
async function refreshFacebook(restreamer, channelid, egressId, svc) {
const { page_access_token, account_type, page_id, title, description } = svc;
if (!page_access_token) return false;
try {
const subject = account_type === 'page' && page_id ? encodeURIComponent(page_id) : 'me';
const form = new URLSearchParams();
if (title) form.append('title', title);
if (description) form.append('description', description);
form.append('status', 'LIVE_NOW');
form.append('access_token', page_access_token);
const resp = await fetch(`${FB_API}/${subject}/live_videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
const data = await resp.json();
if (!resp.ok || data.error) {
console.warn('[autoStreamKey] Facebook error:', data.error ? `[#${data.error.code}] ${data.error.message}` : `HTTP ${resp.status}`);
return false;
}
const streamUrl = data.stream_url || data.secure_stream_url || '';
if (!streamUrl) {
console.warn('[autoStreamKey] Facebook did not return a stream_url');
return false;
}
const key = fbExtractKey(streamUrl);
const newSvc = { ...svc, stream_key_primary: key };
// Persistir settings actualizados
const meta = await restreamer.GetEgressMetadata(channelid, egressId);
meta.settings = newSvc;
await restreamer.SetEgressMetadata(channelid, egressId, meta);
// Actualizar outputs del proceso ffmpeg
const newOutputs = buildFacebookOutputs(newSvc);
if (newOutputs.length > 0) {
await restreamer.UpdateEgressStreamKey(channelid, egressId, newOutputs);
}
console.info('[autoStreamKey] Facebook stream key refreshed:', key, '| live_id:', data.id);
return true;
} catch (e) {
console.warn('[autoStreamKey] Facebook refresh error:', e.message);
return false;
}
}
function buildFacebookOutputs(s) {
const outputs = [];
const options = ['-f', 'flv'];
if (s.stream_key_primary && s.rtmp_primary !== false) {
outputs.push({ address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + s.stream_key_primary, options: [...options] });
}
if (s.stream_key_backup && s.rtmp_backup) {
outputs.push({ address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + s.stream_key_backup, options: [...options] });
}
return outputs;
}
// ─── Entry point ──────────────────────────────────────────────────────────────
/**
* Intenta refrescar el stream key del egress antes de iniciarlo.
* Solo actúa en YouTube con account_key OAuth2 o Facebook con page_access_token.
*
* @param {object} restreamer
* @param {string} channelid
* @param {string} egressId - id completo (restreamer-ui:egress:youtube:xxx)
* @param {string} service - 'youtube' | 'facebook' | ...
* @returns {Promise<boolean>} true si actualizó algo
*/
export async function refreshEgressStreamKey(restreamer, channelid, egressId, service) {
if (service !== 'youtube' && service !== 'facebook') return false;
let meta;
try {
meta = await restreamer.GetEgressMetadata(channelid, egressId);
} catch (e) {
console.warn('[autoStreamKey] Could not read egress metadata:', e.message);
return false;
}
// Los settings del servicio están en meta.settings (guardados por Edit.js)
const svc = (meta && meta.settings) ? meta.settings : {};
if (service === 'youtube') {
// Solo refresh automático si hay account_key (OAuth2 conectado)
if (!svc.account_key) return false;
return await refreshYoutube(restreamer, channelid, egressId, svc);
}
if (service === 'facebook') {
// Solo refresh automático si hay access token
if (!svc.page_access_token) return false;
return await refreshFacebook(restreamer, channelid, egressId, svc);
}
return false;
}

413
src/utils/fbOAuth.js Normal file
View File

@ -0,0 +1,413 @@
/**
* fbOAuth.js Facebook OAuth2 client utility for Restreamer UI.
*
* Flow priority:
* 1. Auth Code flow (response_type=code) server/index.js intercambia:
* code short-lived token (2h) long-lived token (60 días)
* Requiere App Secret configurado en Settings Integrations.
*
* 2. Implicit flow (response_type=token) token 2h desde browser.
* Si el backend tiene App Secret configurado, lo upgradea a long-lived (60 días).
* Fallback cuando App Secret NO está configurado.
*
* Persistencia:
* - Cuentas guardadas en server/data/config.json (vía REST API /fb/*)
* - UI cache en localStorage para render inmediato sin round-trip.
*
* Estructura de cuenta en config.json:
* {
* fb_user_id: string, // PK
* name: string,
* token_type: 'USER'|'PAGE',
* access_token: string, // AES-256-GCM encrypted (server-side)
* expires_at: number, // Unix ms
* scope_granted: string[],
* pages: Page[], // long-lived page tokens (server-side)
* updated_at: number,
* }
*/
const LS_KEY = '@@restreamer-ui@@fb_oauth_v2';
// In development: /fb-server is proxied by setupProxy.js → http://localhost:3002
// In production: reads window.__RESTREAMER_CONFIG__.FB_SERVER_URL (set in public/config.js)
// If empty, falls back to relative /fb-server (Caddy reverse proxy)
const _rtCfg = window.__RESTREAMER_CONFIG__ || {};
const SERVER_BASE = _rtCfg.FB_SERVER_URL
? _rtCfg.FB_SERVER_URL.replace(/\/$/, '')
: '/fb-server';
// ── localStorage cache (UI-only, no tokens stored here) ──────────────────────
function _loadCache() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch { return {}; }
}
function _saveCache(data) {
try { localStorage.setItem(LS_KEY, JSON.stringify(data)); } catch (_) { /* quota */ }
}
// ── Server API helpers ────────────────────────────────────────────────────────
async function _serverGet(path) {
const res = await fetch(SERVER_BASE + path, { headers: { 'Content-Type': 'application/json' } });
return res.json();
}
async function _serverPost(path, body) {
const res = await fetch(SERVER_BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
async function _serverPut(path, body) {
const res = await fetch(SERVER_BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
async function _serverDelete(path) {
const res = await fetch(SERVER_BASE + path, { method: 'DELETE' });
return res.json();
}
// ── Config (App ID + App Secret presence) ────────────────────────────────────
/**
* Get app_id and whether app_secret is configured from server.
* Falls back to localStorage cache.
* @returns {Promise<{ app_id: string, has_secret: boolean }>}
*/
const getConfig = async () => {
try {
const data = await _serverGet('/fb/config');
// cache app_id locally
const c = _loadCache();
c.__config = { app_id: data.app_id || '', has_secret: !!data.has_secret };
_saveCache(c);
return c.__config;
} catch (_) {
const c = _loadCache();
return c.__config || { app_id: '', has_secret: false };
}
};
/**
* Read app_id from localStorage cache (sync, for render).
*/
const getAppId = () => {
const c = _loadCache();
return c.__config?.app_id || '';
};
/**
* Save app_id (and optionally app_secret) to server + cache.
*/
const saveConfig = async ({ app_id, app_secret }) => {
const body = {};
if (app_id !== undefined) body.app_id = app_id;
if (app_secret !== undefined) body.app_secret = app_secret;
const data = await _serverPut('/fb/config', body);
if (data.ok) {
const c = _loadCache();
c.__config = { ...(c.__config || {}), app_id: app_id ?? c.__config?.app_id ?? '', has_secret: true };
_saveCache(c);
}
return data;
};
// ── Accounts ──────────────────────────────────────────────────────────────────
/**
* List accounts from server (no tokens).
* Updates localStorage cache.
*/
const listAccounts = async () => {
try {
const accounts = await _serverGet('/fb/accounts');
if (Array.isArray(accounts)) {
const c = _loadCache();
c.__accounts = accounts;
_saveCache(c);
return accounts;
}
return [];
} catch (_) {
const c = _loadCache();
return c.__accounts || [];
}
};
/**
* List accounts from localStorage cache (sync, for render).
*/
const listAccountsSync = () => {
const c = _loadCache();
return c.__accounts || [];
};
/**
* Get full account including decrypted token (from server).
*/
const getAccountToken = async (fbUserId) => {
return _serverGet(`/fb/accounts/${fbUserId}/token`);
};
/**
* Delete an account from server + cache.
*/
const removeAccount = async (fbUserId) => {
try {
await _serverDelete(`/fb/accounts/${fbUserId}`);
} catch (_) { /* server might be down */ }
const c = _loadCache();
if (c.__accounts) {
c.__accounts = c.__accounts.filter((a) => a.fb_user_id !== fbUserId);
_saveCache(c);
}
};
/**
* Check if a cached account's token is expired or close to expiry (within 5 min).
*/
const isTokenExpired = (account) => {
const expiry = account?.expires_at || 0;
if (!expiry) return false;
return Date.now() > expiry - 5 * 60 * 1000;
};
/**
* Format expires_at as a human-readable string.
*/
const formatExpiry = (expiresAt) => {
if (!expiresAt) return 'Sin expiración';
const d = new Date(expiresAt);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
};
// ── OAuth2 Popup ──────────────────────────────────────────────────────────────
/**
* Open Facebook OAuth2 popup.
*
* Tries Auth Code flow first (response_type=code) if App Secret is configured.
* Falls back to Implicit flow (response_type=token) if not.
*
* @param {string} appId
* @param {boolean} hasSecret whether App Secret is saved in server
* @returns {Promise<{ flow: 'code'|'token', code?: string, redirect_uri?: string, access_token?: string, expires_in?: number }>}
*/
const authorizeWithPopup = (appId, hasSecret = false) => {
const id = appId || getAppId();
if (!id) {
return Promise.reject(new Error('No Facebook App ID configured. Go to Settings → Integrations.'));
}
return new Promise((resolve, reject) => {
const redirectUri = window.location.origin + '/oauth/facebook/callback.html';
const scope = 'publish_video,pages_manage_posts,pages_read_engagement,pages_show_list,public_profile';
// Prefer Auth Code flow when App Secret is available (gives 60-day tokens)
const responseType = hasSecret ? 'code' : 'token';
const authUrl =
'https://www.facebook.com/v19.0/dialog/oauth' +
`?client_id=${encodeURIComponent(id)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(scope)}` +
`&response_type=${responseType}` +
'&auth_type=rerequest';
const popup = window.open(authUrl, 'fb_oauth', 'width=650,height=620,scrollbars=yes,resizable=yes');
if (!popup) {
return reject(new Error('Popup blocked. Allow popups for this site.'));
}
let resolved = false;
const onMessage = (event) => {
// Accept both old 'fb_oauth_token' type and new 'fb_oauth_result' type
if (event.data && (event.data.type === 'fb_oauth_result' || event.data.type === 'fb_oauth_token')) {
resolved = true;
window.removeEventListener('message', onMessage);
clearInterval(pollTimer);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve({
flow: event.data.flow || 'token',
code: event.data.code,
redirect_uri: event.data.redirect_uri,
access_token: event.data.access_token,
expires_in: event.data.expires_in,
});
}
}
};
window.addEventListener('message', onMessage);
const pollTimer = setInterval(() => {
if (popup.closed && !resolved) {
clearInterval(pollTimer);
window.removeEventListener('message', onMessage);
reject(new Error('Authorization cancelled.'));
}
}, 500);
});
};
// ── Full connect flow ─────────────────────────────────────────────────────────
/**
* Complete OAuth2 flow:
* 1. Open popup get code OR short-lived token
* 2a. If code POST /fb/exchange (server does shortlong exchange, saves to config.json)
* 2b. If token POST /fb/upgrade (server upgrades shortlong, saves to config.json)
* If server unavailable save short-lived token in localStorage only (2h fallback)
* 3. Refresh list cache
*
* @param {string} appId
* @param {boolean} hasSecret
* @returns {Promise<{ account: object, tokenType: 'long'|'short', expiresAt: number }>}
*/
const connectAccount = async (appId, hasSecret) => {
const result = await authorizeWithPopup(appId, hasSecret);
if (result.flow === 'code' && result.code) {
// ── Auth Code flow → server does everything ──────────────────────────
const data = await _serverPost('/fb/exchange', {
code: result.code,
redirect_uri: result.redirect_uri,
});
if (data.error) throw new Error(data.error);
// refresh cache
await listAccounts();
return { account: data.account, tokenType: 'long', expiresAt: data.account.expires_at };
}
if (result.flow === 'token' && result.access_token) {
// ── Implicit flow → try to upgrade via server ────────────────────────
try {
const data = await _serverPost('/fb/upgrade', { access_token: result.access_token });
if (data.ok) {
await listAccounts();
return { account: data.account, tokenType: 'long', expiresAt: data.account.expires_at };
}
} catch (_) { /* server unavailable — fall through to localStorage */ }
// Fallback: store short-lived token in localStorage only
const userInfo = await fetchUserInfo(result.access_token);
const pages = await fetchUserPages(result.access_token).catch(() => []);
const shortAcc = {
fb_user_id: userInfo.id,
name: userInfo.name,
token_type: 'USER',
access_token: result.access_token,
expires_at: result.expires_in ? Date.now() + result.expires_in * 1000 : 0,
scope_granted: [],
pages,
updated_at: Date.now(),
};
// cache in localStorage (no server persistence)
const c = _loadCache();
if (!c.__accounts) c.__accounts = [];
const idx = c.__accounts.findIndex((a) => a.fb_user_id === userInfo.id);
const pub = { ...shortAcc }; delete pub.access_token;
if (idx >= 0) c.__accounts[idx] = pub; else c.__accounts.push(pub);
// store token separately for local use
c[userInfo.id] = shortAcc;
_saveCache(c);
return { account: shortAcc, tokenType: 'short', expiresAt: shortAcc.expires_at };
}
throw new Error('No authorization data received from Facebook.');
};
// ── Refresh (renew long-lived token) ─────────────────────────────────────────
/**
* Refresh a stored account's token via server.
*/
const refreshAccount = async (fbUserId) => {
const data = await _serverPost(`/fb/refresh/${fbUserId}`, {});
if (data.error) throw new Error(data.error);
await listAccounts();
return data.account;
};
// ── Direct Graph API calls (for short-lived local tokens) ────────────────────
const fetchUserInfo = async (accessToken) => {
const res = await fetch(`https://graph.facebook.com/v19.0/me?fields=id,name&access_token=${encodeURIComponent(accessToken)}`);
const data = await res.json();
if (data.error) throw new Error(data.error.message);
return data;
};
const fetchUserPages = async (accessToken) => {
const res = await fetch(`https://graph.facebook.com/v19.0/me/accounts?fields=id,name,access_token,category&access_token=${encodeURIComponent(accessToken)}`);
const data = await res.json();
if (data.error) throw new Error(data.error.message);
return (data.data || []).map((p) => ({ ...p, token_type: 'PAGE' }));
};
/**
* Get usable access token for a given account entry (from server if possible).
* entry: { fb_user_id, pageId? }
*/
const getTokenForEntry = async (fbUserId, pageId) => {
try {
const full = await getAccountToken(fbUserId);
if (pageId) {
const page = (full.pages || []).find((p) => p.id === pageId);
return page ? page.access_token : null;
}
return full.access_token;
} catch (_) {
// fallback to localStorage short-lived token
const c = _loadCache();
const acc = c[fbUserId];
if (!acc) return null;
if (pageId) {
const page = (acc.pages || []).find((p) => p.id === pageId);
return page ? page.access_token : null;
}
return acc.access_token || null;
}
};
// ── Legacy compatibility (localStorage-only, for components not yet migrated) ─
const setAppId = (app_id) => {
const c = _loadCache();
c.__config = { ...(c.__config || {}), app_id };
_saveCache(c);
};
export default {
// Config
getConfig,
getAppId,
setAppId,
saveConfig,
// Accounts
listAccounts,
listAccountsSync,
getAccountToken,
removeAccount,
isTokenExpired,
formatExpiry,
// OAuth flows
authorizeWithPopup,
connectAccount,
refreshAccount,
// Graph API (direct)
fetchUserInfo,
fetchUserPages,
getTokenForEntry,
};

View File

@ -2603,6 +2603,27 @@ class Restreamer {
}
// Update an egress process
// Update only the RTMP outputs of an egress (used for auto stream-key refresh before start)
async UpdateEgressStreamKey(channelid, id, newOutputs) {
const channel = this.GetChannel(channelid);
if (!channel) return false;
if (!channel.egresses.includes(id)) return false;
// Read current process config
const config = await this._getProcessConfig(id);
if (!config) return false;
// Replace outputs keeping everything else intact
config.output = newOutputs.map((o, i) => ({
id: 'output_' + i,
address: o.address,
options: Array.isArray(o.options) ? o.options.map((x) => '' + x) : [],
}));
const [, err] = await this._upsertProcess(id, config);
return err === null;
}
async UpdateEgress(channelid, id, global, inputs, outputs, control) {
const channel = this.GetChannel(channelid);
if (!channel) {

169
src/utils/ytOAuth.js Normal file
View File

@ -0,0 +1,169 @@
/**
* ytOAuth.js Almacenamiento global de credenciales OAuth2 de YouTube en localStorage.
*
* Las claves se guardan bajo el prefijo @@restreamer-ui@@yt_oauth_
* de modo que persisten entre sesiones y son compartidas por todas
* las publicaciones de YouTube del mismo canal.
*
* Estructura almacenada:
* {
* client_id: string,
* client_secret: string,
* accounts: {
* [accountKey]: {
* label: string, // nombre amigable ej. "Mi Canal"
* access_token: string,
* refresh_token: string,
* token_expiry: number, // timestamp ms
* email: string, // email de la cuenta Google (opcional)
* channel_title: string, // nombre del canal YouTube (opcional)
* channel_id: string, // UCxxxx
* }
* }
* }
*/
const STORAGE_KEY = 'yt_oauth';
const PREFIX = '@@restreamer-ui@@';
const _read = () => {
try {
const raw = window.localStorage.getItem(PREFIX + STORAGE_KEY);
return raw ? JSON.parse(raw) : { client_id: '', client_secret: '', accounts: {} };
} catch {
return { client_id: '', client_secret: '', accounts: {} };
}
};
const _write = (data) => {
try {
window.localStorage.setItem(PREFIX + STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn('[ytOAuth] Cannot write to localStorage:', e);
}
};
/** Obtener toda la config */
const getAll = () => _read();
/** Guardar client_id y client_secret globales */
const setCredentials = (client_id, client_secret) => {
const data = _read();
data.client_id = client_id || '';
data.client_secret = client_secret || '';
_write(data);
};
/** Obtener credenciales globales */
const getCredentials = () => {
const data = _read();
return { client_id: data.client_id || '', client_secret: data.client_secret || '' };
};
/** Guardar / actualizar una cuenta conectada */
const saveAccount = (accountKey, accountData) => {
const data = _read();
if (!data.accounts) data.accounts = {};
data.accounts[accountKey] = {
...(data.accounts[accountKey] || {}),
...accountData,
updated_at: Date.now(),
};
_write(data);
};
/** Obtener todas las cuentas conectadas */
const getAccounts = () => {
const data = _read();
return data.accounts || {};
};
/** Obtener una cuenta por key */
const getAccount = (accountKey) => {
const data = _read();
return (data.accounts || {})[accountKey] || null;
};
/** Eliminar una cuenta */
const removeAccount = (accountKey) => {
const data = _read();
if (data.accounts && data.accounts[accountKey]) {
delete data.accounts[accountKey];
_write(data);
}
};
/** Listar cuentas como array [{key, label, email, channel_title, ...}] */
const listAccounts = () => {
const data = _read();
return Object.entries(data.accounts || {}).map(([key, val]) => ({ key, ...val }));
};
/** Refrescar access_token usando el refresh_token almacenado */
const refreshAccount = async (accountKey) => {
const creds = getCredentials();
const account = getAccount(accountKey);
if (!account || !account.refresh_token) throw new Error('No refresh token for account: ' + accountKey);
if (!creds.client_id || !creds.client_secret) throw new Error('Global OAuth2 credentials not set in Settings → Integrations');
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: creds.client_id,
client_secret: creds.client_secret,
refresh_token: account.refresh_token,
grant_type: 'refresh_token',
}).toString(),
});
const data = await resp.json();
if (data.error) throw new Error(data.error_description || data.error);
saveAccount(accountKey, {
access_token: data.access_token,
token_expiry: Date.now() + (data.expires_in || 3600) * 1000,
});
return data.access_token;
};
/** Obtener token válido (refresca automáticamente si está expirado) */
const getValidToken = async (accountKey) => {
const account = getAccount(accountKey);
if (!account || !account.access_token) throw new Error('Account not connected: ' + accountKey);
const isExpired = account.token_expiry && Date.now() > account.token_expiry - 60000;
if (isExpired) return await refreshAccount(accountKey);
return account.access_token;
};
/** Fetch info del canal de YouTube para enriquecer la cuenta */
const fetchChannelInfo = async (accessToken) => {
try {
const resp = await fetch(
'https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true',
{ headers: { Authorization: 'Bearer ' + accessToken } }
);
const data = await resp.json();
if (data.items && data.items.length > 0) {
return {
channel_title: data.items[0].snippet.title,
channel_id: data.items[0].id,
};
}
} catch {}
return {};
};
export default {
getAll,
setCredentials,
getCredentials,
saveAccount,
getAccounts,
getAccount,
removeAccount,
listAccounts,
refreshAccount,
getValidToken,
fetchChannelInfo,
};

View File

@ -1,15 +1,56 @@
import React from 'react';
import { Trans } from '@lingui/macro';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/SettingsEthernet';
import makeStyles from '@mui/styles/makeStyles';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import YouTubeIcon from '@mui/icons-material/YouTube';
import * as S from '../../Sources/Network';
import Checkbox from '../../../../misc/Checkbox';
import Password from '../../../../misc/Password';
const useStyles = makeStyles(() => ({
youtubeButton: {
height: '56px',
minWidth: '56px',
color: 'rgba(255,255,255,0.5)',
borderColor: 'rgba(128,128,128,0.35)',
'&:hover': {
borderColor: '#FF0000',
color: '#FF0000',
backgroundColor: 'transparent',
},
},
}));
// ── yt-dlp helpers (shared with Edit/Sources/Network) ──────────────────────
const _runtimeCfg = window.__RESTREAMER_CONFIG__ || {};
const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
? _runtimeCfg.YTDLP_URL.replace(/\/$/, '') + '/stream/'
: '/yt-stream/';
const extractYouTubeVideoId = (url) => {
if (!url) return '';
const patterns = [
/[?&]v=([a-zA-Z0-9_-]{11})/,
/youtu\.be\/([a-zA-Z0-9_-]{11})/,
/\/embed\/([a-zA-Z0-9_-]{11})/,
/\/shorts\/([a-zA-Z0-9_-]{11})/,
/\/v\/([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
if (/^[a-zA-Z0-9_-]{11}$/.test(url.trim())) return url.trim();
return '';
};
const initSettings = (initialSettings, config) => {
const settings = {
...S.func.initSettings(initialSettings, config),
@ -20,20 +61,24 @@ const initSettings = (initialSettings, config) => {
};
function Source(props) {
const classes = useStyles();
const config = S.func.initConfig(props.config);
const settings = initSettings(props.settings, config);
const skills = S.func.initSkills(props.skills);
const [extractorLoading, setExtractorLoading] = React.useState(false);
const [extractorError, setExtractorError] = React.useState('');
const detectedYouTubeId = extractYouTubeVideoId(settings.address);
const handleChange = (newSettings) => {
newSettings = newSettings || settings;
props.onChange(S.id, newSettings, S.func.createInputs(newSettings, config, skills), S.func.isValidURL(newSettings.address));
};
const update = (protocol, what) => (event) => {
const value = event.target.value;
const newSettings = settings;
if (protocol === 'rtsp') {
if (['udp'].includes(what)) {
newSettings.rtsp[what] = !newSettings.rtsp[what];
@ -41,10 +86,40 @@ function Source(props) {
} else {
newSettings[what] = value;
}
handleChange(newSettings);
};
const handleExtract = async () => {
setExtractorError('');
const videoId = extractYouTubeVideoId(settings.address);
if (!videoId) {
setExtractorError('No YouTube URL detected.');
return;
}
setExtractorLoading(true);
try {
const response = await fetch(STREAM_SERVICE_BASE + videoId);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (data && data.stream_url) {
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);
}
} else {
setExtractorError('No stream_url found in service response.');
}
} catch (e) {
setExtractorError('Error: ' + e.message);
} finally {
setExtractorLoading(false);
}
};
React.useEffect(() => {
handleChange();
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -60,17 +135,42 @@ function Source(props) {
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
variant="outlined"
fullWidth
label={<Trans>Address</Trans>}
placeholder="rtsp://ip:port/path"
value={settings.address}
onChange={update('', 'address')}
/>
<Typography variant="caption">
<Trans>Supports HTTP (HLS, DASH), RTP, RTSP, RTMP, SRT and more.</Trans>
</Typography>
<Grid container spacing={1} alignItems="flex-start">
<Grid item xs>
<TextField
variant="outlined"
fullWidth
label={<Trans>Address</Trans>}
placeholder="rtsp://ip:port/path or YouTube URL"
value={settings.address}
onChange={(e) => {
setExtractorError('');
update('', 'address')(e);
}}
/>
<Typography variant="caption">
<Trans>Supports HTTP (HLS, DASH), RTP, RTSP, RTMP, SRT and more.</Trans>
</Typography>
{extractorError && (
<Typography variant="caption" style={{ color: '#f44336', display: 'block' }}>
{extractorError}
</Typography>
)}
</Grid>
{detectedYouTubeId && (
<Grid item>
<Button
variant="outlined"
onClick={handleExtract}
disabled={extractorLoading}
className={classes.youtubeButton}
title="Extract M3U8 from YouTube URL"
>
{extractorLoading ? <CircularProgress size={20} color="inherit" /> : <YouTubeIcon />}
</Button>
</Grid>
)}
</Grid>
</Grid>
{protocol === 'rtsp' && (
<Grid item xs={12}>
@ -112,6 +212,7 @@ Source.defaultProps = {
config: null,
skills: null,
onChange: function (type, settings, inputs, ready) {},
onYoutubeMetadata: null,
};
function SourceIcon(props) {

View File

@ -217,6 +217,17 @@ export default function Wizard(props) {
navigate(`/${_channelid}/edit`);
};
const handleYoutubeMetadata = (title, description) => {
setData((prev) => ({
...prev,
meta: {
...prev.meta,
name: title || prev.meta?.name || '',
description: description || prev.meta?.description || '',
},
}));
};
const handleHelp = (what) => () => {
H('wizard-' + what);
};
@ -357,6 +368,7 @@ export default function Wizard(props) {
skills={$skills}
onChange={handleChange}
onRefresh={handleRefresh}
onYoutubeMetadata={handleYoutubeMetadata}
/>
<Backdrop open={$skillsRefresh}>
<CircularProgress color="inherit" />

View File

@ -9,6 +9,7 @@ import IconButton from '@mui/material/IconButton';
import OndemandVideoIcon from '@mui/icons-material/OndemandVideo';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Services from '../Publication/Services';
@ -57,8 +58,62 @@ const useStyles = makeStyles((theme) => ({
fontSize: '1.5rem',
},
},
accountBadge: {
display: 'block',
fontSize: '0.68rem',
lineHeight: 1.2,
marginTop: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 180,
},
accountBadgeConnected: {
color: '#4caf50',
},
accountBadgeExpired: {
color: '#ff9800',
},
tooltipContent: {
padding: '4px 2px',
lineHeight: 1.6,
},
}));
// ─── Contenido del tooltip ─────────────────────────────────────────────────────
function AccountTooltipContent({ accountInfo }) {
if (!accountInfo) return null;
const icon = accountInfo.expired ? '⚠️' : '🟢';
const statusText = accountInfo.expired ? 'Token expired' : 'Connected';
return (
<div style={{ padding: '4px 2px', lineHeight: 1.7, minWidth: 160 }}>
<div style={{ fontWeight: 700, fontSize: '0.82rem', marginBottom: 2 }}>
{icon} {statusText}
</div>
<div style={{ fontSize: '0.8rem' }}>
<strong>{accountInfo.label}</strong>
</div>
{accountInfo.sublabel && (
<div style={{ fontSize: '0.72rem', color: 'rgba(255,255,255,0.6)', marginTop: 2 }}>
{accountInfo.sublabel}
</div>
)}
{accountInfo.platform === 'facebook' && (
<div style={{ fontSize: '0.7rem', color: 'rgba(255,255,255,0.5)', marginTop: 4 }}>
Facebook
</div>
)}
{accountInfo.platform === 'youtube' && (
<div style={{ fontSize: '0.7rem', color: 'rgba(255,255,255,0.5)', marginTop: 4 }}>
YouTube
</div>
)}
</div>
);
}
export default function Egress(props) {
const classes = useStyles();
const [$order, setOrder] = React.useState('stop');
@ -135,15 +190,44 @@ export default function Egress(props) {
break;
}
// ─── Badge de cuenta conectada ────────────────────────────────────────────
const { accountInfo } = props;
const showAccountBadge = !!(accountInfo && accountInfo.connected);
// Ícono con o sin tooltip de cuenta
const iconButton = accountInfo ? (
<Tooltip
title={<AccountTooltipContent accountInfo={accountInfo} />}
arrow
placement="right"
>
<IconButton size="small" className="egress-left">
{icon}
</IconButton>
</Tooltip>
) : (
<IconButton size="small" className="egress-left">
{icon}
</IconButton>
);
return (
<Grid container className={classes.egressBar}>
<Grid item xs={12}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={0}>
<Stack className="egress-left" direction="row" alignItems="center" spacing={0}>
<IconButton size="small" className="egress-left">
{icon}
</IconButton>
<Typography className="egress-name">{name}</Typography>
{iconButton}
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: 'hidden' }}>
<Typography className="egress-name">{name}</Typography>
{showAccountBadge && (
<span
className={`${classes.accountBadge} ${accountInfo.expired ? classes.accountBadgeExpired : classes.accountBadgeConnected}`}
title={accountInfo.sublabel ? `${accountInfo.label} · ${accountInfo.sublabel}` : accountInfo.label}
>
{accountInfo.expired ? '⚠️' : '●'} {accountInfo.label}
</span>
)}
</div>
</Stack>
<Stack direction="row" alignItems="center" spacing={0}>
{props.service !== 'player' && (
@ -172,6 +256,7 @@ Egress.defaultProps = {
state: 'disconnected',
order: 'stop',
reconnect: true,
accountInfo: null,
onEdit: function () {},
onOrder: function (order) {},
};

View File

@ -16,6 +16,64 @@ import Number from '../../misc/Number';
import Paper from '../../misc/Paper';
import PaperHeader from '../../misc/PaperHeader';
import Services from '../Publication/Services';
import { refreshEgressStreamKey } from '../../utils/autoStreamKey';
import ytOAuth from '../../utils/ytOAuth';
// ─── helpers para extraer info de cuenta desde metadata ───────────────────────
function extractAccountInfo(service, meta) {
if (!meta) return null;
const s = (meta.settings) || {};
if (service === 'facebook') {
const hasToken = !!(s.page_access_token);
if (!hasToken) return null;
const isPage = s.account_type !== 'user';
// Nombre resuelto: page_name siempre tiene prioridad (viene de Graph API /me o /{page_id})
// Si no hay nombre aún, mostramos fallback descriptivo mientras carga
let label;
if (s.page_name) {
label = s.page_name;
} else if (isPage && s.page_id) {
label = `Page ${s.page_id}`;
} else {
label = 'Facebook account';
}
// Sublabel: tipo de cuenta + id si aplica
let sublabel = null;
if (isPage && s.page_id) {
sublabel = `Page · ID: ${s.page_id}`;
} else if (!isPage) {
sublabel = s.page_id ? `Personal · UID: ${s.page_id}` : 'Personal profile';
}
return {
label,
sublabel,
connected: true,
platform: 'facebook',
};
}
if (service === 'youtube') {
const accountKey = s.account_key;
if (!accountKey) return null;
const account = ytOAuth.getAccount(accountKey);
if (!account) return null;
const expired = account.token_expiry && Date.now() > account.token_expiry - 60000;
return {
label: account.channel_title || account.label || accountKey,
sublabel: account.channel_id || null,
connected: !!(account.access_token),
expired,
platform: 'youtube',
};
}
return null;
}
const useStyles = makeStyles((theme) => ({
viewerCount: {
@ -76,12 +134,24 @@ export default function Publication(props) {
const processes = await props.restreamer.ListIngestEgresses(props.channelid, services);
for (let p of processes) {
// Cargar metadata del egress para extraer info de cuenta conectada
let accountInfo = null;
if (p.service === 'facebook' || p.service === 'youtube') {
try {
const meta = await props.restreamer.GetEgressMetadata(props.channelid, p.id);
accountInfo = extractAccountInfo(p.service, meta);
} catch (e) {
// silencioso
}
}
egresses.push({
id: p.id,
name: p.name,
service: p.service,
index: p.index,
progress: p.progress,
accountInfo,
});
}
@ -113,9 +183,19 @@ export default function Publication(props) {
navigate(target);
};
const handleOrderChange = (id) => async (order) => {
const handleOrderChange = (id, service) => async (order) => {
let res = false;
if (order === 'start' || order === 'restart') {
// Auto-refresh stream key for YouTube (OAuth2) and Facebook (Graph API)
// before starting the process. Errors are non-fatal — we start anyway.
try {
await refreshEgressStreamKey(props.restreamer, props.channelid, id, service);
} catch (e) {
console.warn('[Publication] autoStreamKey error (non-fatal):', e.message);
}
}
if (order === 'start') {
res = await props.restreamer.StartEgress(props.channelid, id);
} else if (order === 'restart') {
@ -149,8 +229,9 @@ export default function Publication(props) {
state={e.progress.state}
order={e.progress.order}
reconnect={e.progress.reconnect !== -1}
accountInfo={e.accountInfo}
onEdit={handleServiceEdit(e.service, e.index)}
onOrder={handleOrderChange(e.id)}
onOrder={handleOrderChange(e.id, e.service)}
/>
</Grid>
</React.Fragment>

View File

@ -3,15 +3,32 @@ import React from 'react';
import { faFacebook } from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Trans } from '@lingui/macro';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Link from '@mui/material/Link';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
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 RefreshIcon from '@mui/icons-material/Refresh';
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';
const id = 'facebook';
const name = 'Facebook Live';
@ -43,24 +60,61 @@ function init(settings) {
stream_key_backup: '',
rtmp_primary: true,
rtmp_backup: false,
// API fields
account_type: 'page', // 'page' | 'user'
_selected_fb_user_id: '',
_selected_page_id: '',
account_type: 'page',
page_id: '',
page_name: '',
page_access_token: '',
title: '',
description: '',
create_live: false,
...settings,
};
}
function AccountAvatar({ name, isPage }) {
const initials = (name || '?').split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
return (
<Avatar style={{ background: isPage ? '#2D88FF' : '#555', width: 36, height: 36, fontSize: '0.85rem' }}>
{isPage ? '📄' : initials}
</Avatar>
);
}
function TokenBadge({ expired, tokenType, expiresAt }) {
if (expired) {
return (
<Chip
icon={<WarningIcon style={{ fontSize: '0.8rem' }} />}
label="Token expirado"
size="small"
style={{ background: 'rgba(244,67,54,0.15)', color: '#f44336', border: '1px solid #f44336', fontSize: '0.7rem', height: 20 }}
/>
);
}
const label = tokenType === 'short'
? '⚡ Token 2h'
: expiresAt
? `✅ ~${Math.max(0, Math.round((expiresAt - Date.now()) / 86400000))}d restantes`
: '✅ Conectado';
return (
<Chip
icon={<CheckCircleIcon style={{ fontSize: '0.8rem' }} />}
label={label}
size="small"
style={{
background: tokenType === 'short' ? 'rgba(255,152,0,0.15)' : 'rgba(76,175,80,0.15)',
color: tokenType === 'short' ? '#ff9800' : '#4caf50',
border: `1px solid ${tokenType === 'short' ? '#ff9800' : '#4caf50'}`,
fontSize: '0.7rem', height: 20,
}}
/>
);
}
function Service(props) {
const settings = init(props.settings);
const [$loading, setLoading] = React.useState(false);
const [$apiError, setApiError] = React.useState('');
const [$apiSuccess, setApiSuccess] = React.useState('');
// Pre-fill title/description from channel metadata if not already set
if (!settings.title && props.metadata && props.metadata.name) {
settings.title = props.metadata.name;
}
@ -68,296 +122,437 @@ function Service(props) {
settings.description = props.metadata.description;
}
const [$loading, setLoading] = React.useState(false);
const [$authorizing, setAuthorizing] = React.useState(false);
const [$error, setError] = React.useState('');
const [$success, setSuccess] = React.useState('');
const [$hasSecret, setHasSecret] = React.useState(false);
const [$accounts, setAccounts] = React.useState([]);
const [$selected, setSelected] = React.useState(null);
const $appId = fbOAuth.getAppId();
const buildList = React.useCallback((rawAccounts) => {
const list = [];
(rawAccounts || []).forEach((acc) => {
const expired = fbOAuth.isTokenExpired(acc);
const isShort = !acc.expires_at || (acc.expires_at - Date.now() < 3 * 60 * 60 * 1000);
list.push({
key: acc.fb_user_id + '__user',
fbUserId: acc.fb_user_id,
pageId: '',
name: acc.name || 'Personal profile',
isPage: false,
expired,
tokenType: isShort ? 'short' : 'long',
expiresAt: acc.expires_at || 0,
scopes: acc.scope_granted || [],
});
(acc.pages || []).forEach((page) => {
list.push({
key: acc.fb_user_id + '__page__' + page.id,
fbUserId: acc.fb_user_id,
pageId: page.id,
name: page.name,
isPage: true,
expired: false,
tokenType: 'long',
expiresAt: 0,
scopes: [],
});
});
});
return list;
}, []);
const loadAccounts = React.useCallback(async () => {
const cached = fbOAuth.listAccountsSync();
setAccounts(buildList(cached));
try {
const fresh = await fbOAuth.listAccounts();
const freshList = buildList(fresh);
setAccounts(freshList);
const prevKey = settings._selected_fb_user_id
+ (settings._selected_page_id ? '__page__' + settings._selected_page_id : '__user');
const found = freshList.find((e) => e.key === prevKey);
if (found) setSelected(found);
} catch (_) {}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect(() => {
fbOAuth.getConfig().then(({ has_secret }) => setHasSecret(!!has_secret)).catch(() => {});
loadAccounts();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSelect = (entry) => {
setError(''); setSuccess('');
setSelected(entry);
settings._selected_fb_user_id = entry.fbUserId;
settings._selected_page_id = entry.pageId;
settings.page_id = entry.pageId;
settings.page_name = entry.name;
settings.account_type = entry.isPage ? 'page' : 'user';
props.onChange(createOutput(settings), settings);
};
const handleAddAccount = async () => {
setError(''); setSuccess('');
if (!$appId) { setError('⚠️ No hay App ID configurado. Ve a Settings → Integrations.'); return; }
setAuthorizing(true);
try {
const { account, tokenType, expiresAt } = await fbOAuth.connectAccount($appId, $hasSecret);
await loadAccounts();
const typeLabel = tokenType === 'long'
? `✅ Token de larga duración (~${Math.round((expiresAt - Date.now()) / 86400000)} días)`
: '⚡ Token corto (2h) — configura App Secret para obtener tokens de 60 días';
setSuccess(`Cuenta "${account.name}" conectada. ${typeLabel}`);
} catch (err) {
setError('❌ ' + err.message);
} finally {
setAuthorizing(false);
}
};
const handleRefreshAccount = async (fbUserId) => {
setError(''); setAuthorizing(true);
try {
const account = await fbOAuth.refreshAccount(fbUserId);
await loadAccounts();
setSuccess(`✅ Token renovado para "${account.name}" (~${Math.round((account.expires_at - Date.now()) / 86400000)} días).`);
} catch (err) {
if (!$appId) { setError('❌ ' + err.message); setAuthorizing(false); return; }
try {
const { account, tokenType } = await fbOAuth.connectAccount($appId, $hasSecret);
await loadAccounts();
setSuccess(`✅ Token renovado para "${account.name}". (${tokenType === 'long' ? '60 días' : '2h'})`);
} catch (err2) { setError('❌ ' + err2.message); }
} finally { setAuthorizing(false); }
};
const handleRemoveAccount = async (fbUserId) => {
await fbOAuth.removeAccount(fbUserId);
if ($selected && $selected.fbUserId === fbUserId) {
setSelected(null);
settings._selected_fb_user_id = '';
settings._selected_page_id = '';
settings.page_access_token = '';
settings.page_id = '';
settings.page_name = '';
props.onChange(createOutput(settings), settings);
}
await loadAccounts();
};
const handleChange = (what) => (event) => {
const value = event && event.target ? event.target.value : event;
setApiError('');
setApiSuccess('');
if (['rtmp_primary', 'rtmp_backup', 'create_live'].includes(what)) {
setError(''); setSuccess('');
if (['rtmp_primary', 'rtmp_backup'].includes(what)) {
settings[what] = !settings[what];
} else {
settings[what] = value;
}
props.onChange(createOutput(settings), settings);
};
const createOutput = (settings) => {
const createOutput = (s) => {
const outputs = [];
if (settings.stream_key_primary.length !== 0 && settings.rtmp_primary) {
outputs.push({
address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + settings.stream_key_primary,
options: ['-f', 'flv'],
});
}
if (settings.stream_key_backup.length !== 0 && settings.rtmp_backup) {
outputs.push({
address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + settings.stream_key_backup,
options: ['-f', 'flv'],
});
}
if (s.stream_key_primary && s.rtmp_primary)
outputs.push({ address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + s.stream_key_primary, options: ['-f', 'flv'] });
if (s.stream_key_backup && s.rtmp_backup)
outputs.push({ address: 'rtmps://live-api-s.facebook.com:443/rtmp/' + s.stream_key_backup, options: ['-f', 'flv'] });
return outputs;
};
// Extraer stream key desde stream_url de Facebook
const extractKey = (streamUrl) => {
if (!streamUrl) return '';
const parts = streamUrl.split('/');
return parts[parts.length - 1] || '';
};
// Crea un Facebook Live via Graph API
const createFacebookLive = async () => {
setApiError('');
setApiSuccess('');
if (!settings.page_access_token) {
setApiError('You must provide a Page Access Token (or User Access Token for personal profile).');
return;
}
// Para tipo 'page' se requiere page_id
if (settings.account_type === 'page' && !settings.page_id) {
setApiError('You must provide the Page ID when using a Page account. For personal profile, switch to "Personal profile".');
return;
}
const handleStartLive = async () => {
setError(''); setSuccess('');
if (!$selected) { setError('Selecciona una cuenta conectada.'); return; }
setLoading(true);
try {
// El subject puede ser el page_id (para páginas) o 'me' (para perfil personal)
const subject = settings.account_type === 'page'
? encodeURIComponent(settings.page_id)
: 'me';
const token = await fbOAuth.getTokenForEntry($selected.fbUserId, $selected.pageId);
if (!token) { setError('No se pudo obtener el token. Reconecta la cuenta.'); return; }
const url = `https://graph.facebook.com/v19.0/${subject}/live_videos`;
const form = new URLSearchParams();
if (settings.title) form.append('title', settings.title);
const subject = $selected.isPage ? encodeURIComponent($selected.pageId) : 'me';
const form = new URLSearchParams();
if (settings.title) form.append('title', settings.title);
if (settings.description) form.append('description', settings.description);
form.append('status', 'LIVE_NOW');
form.append('access_token', settings.page_access_token);
form.append('status', 'LIVE_NOW');
form.append('access_token', token);
const resp = await fetch(url, {
const resp = await fetch(`https://graph.facebook.com/v19.0/${subject}/live_videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
const data = await resp.json();
if (!resp.ok || data.error) {
const errMsg = data.error
? `[#${data.error.code}] ${data.error.message}`
: `HTTP ${resp.status}`;
// Mensajes de error específicos con solución
if (data.error && data.error.code === 100) {
setApiError(
'Error #100: The subject must be a Page account, not a personal profile. ' +
'Switch to "Personal profile" mode or provide a valid Page ID with a Page Access Token. ' +
'Get your Page Access Token at: https://developers.facebook.com/tools/explorer/'
);
} else if (data.error && data.error.code === 190) {
setApiError('Error #190: Invalid or expired Access Token. Generate a new one at https://developers.facebook.com/tools/explorer/');
} else if (data.error && data.error.code === 200 || data.error && data.error.code === 10) {
setApiError('Permission error: Your token needs the "publish_video" permission. Check your app settings at developers.facebook.com');
} else {
setApiError('Facebook API error: ' + errMsg);
}
const code = data.error ? data.error.code : resp.status;
const msg = data.error ? data.error.message : `HTTP ${resp.status}`;
if (code === 190) setError('⚠️ Token expirado (#190). Usa 🔄 para renovar.');
else if (code === 100) setError('⚠️ Error #100: ' + msg);
else if (code === 200 || code === 10) setError(`⚠️ Sin permiso (${code}): reconecta con "publish_video".`);
else setError(`❌ Facebook API [#${code}]: ${msg}`);
return;
}
// Extraer stream_url
const streamUrl = data.stream_url || data.secure_stream_url || '';
if (!streamUrl) {
setApiError('Facebook did not return a stream URL. Check that your token has the "publish_video" permission.');
return;
}
const key = extractKey(streamUrl);
settings.stream_key_primary = key;
settings.create_live = true;
if (!streamUrl) { setError('Facebook no devolvió stream URL. Verifica permiso "publish_video".'); return; }
settings.stream_key_primary = extractKey(streamUrl);
props.onChange(createOutput(settings), settings);
setApiSuccess(`✅ Facebook Live created! Stream key filled automatically. Live ID: ${data.id}`);
setSuccess(`✅ Live creado! Stream key configurado. Live ID: ${data.id}`);
} catch (err) {
setApiError('Network error: ' + err.message + '. Check if CORS is enabled or try from a server-side proxy.');
setError('❌ Error: ' + err.message);
} finally {
setLoading(false);
}
};
const isSelectedEntry = (entry) => $selected && $selected.key === entry.key;
return (
<Grid container spacing={2}>
{/* Stream keys */}
{settings.rtmp_primary === true && (
<Grid item xs={12} md={9}>
<TextField
variant="outlined"
fullWidth
label={<Trans>Primary stream key</Trans>}
value={settings.stream_key_primary}
onChange={handleChange('stream_key_primary')}
/>
</Grid>
)}
{settings.rtmp_primary === true && (
<Grid item xs={12} md={3}>
<FormInlineButton target="blank" href={stream_key_link} component="a">
<Trans>GET</Trans>
</FormInlineButton>
</Grid>
)}
{settings.rtmp_backup === true && (
<Grid item xs={12} md={9}>
<TextField
variant="outlined"
fullWidth
label={<Trans>Backup stream key</Trans>}
value={settings.stream_key_backup}
onChange={handleChange('stream_key_backup')}
/>
</Grid>
)}
{settings.rtmp_backup === true && (
<Grid item xs={12} md={3}>
<FormInlineButton target="blank" href={stream_key_link} component="a">
<Trans>GET</Trans>
</FormInlineButton>
</Grid>
)}
<Grid item xs={12}>
<Checkbox label={<Trans>Enable primary stream</Trans>} checked={settings.rtmp_primary} onChange={handleChange('rtmp_primary')} />
<Checkbox label={<Trans>Enable backup stream</Trans>} checked={settings.rtmp_backup} onChange={handleChange('rtmp_backup')} />
</Grid>
{/* Divider visual */}
{/* ══ Cuentas conectadas ══ */}
<Grid item xs={12}>
<Typography variant="h4" style={{ marginTop: 8 }}>
<Trans>Create Live via Facebook API (optional)</Trans>
</Typography>
<Typography variant="caption" style={{ color: '#aaa' }}>
<Trans>Fill in the fields below to automatically create the live broadcast and get the stream key.</Trans>{' '}
<Link color="secondary" target="_blank" href="https://developers.facebook.com/tools/explorer/">
<Trans>Get your token here</Trans>
</Link>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<Typography variant="h4" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FontAwesomeIcon icon={faFacebook} style={{ color: '#2D88FF' }} />
Facebook connected accounts
</Typography>
<Button
variant="outlined" size="small"
startIcon={$authorizing ? <CircularProgress size={14} color="inherit" /> : <AddIcon />}
onClick={handleAddAccount} disabled={$authorizing || !$appId}
style={{ borderColor: '#2D88FF', color: '#2D88FF', textTransform: 'none' }}
>
{$authorizing ? 'Connecting…' : '+ Add account'}
</Button>
</div>
{!$appId && (
<Typography variant="caption" style={{ color: '#f44336', display: 'block', marginBottom: 8 }}>
No App ID configured. Go to Settings Integrations Facebook App ID.
</Typography>
)}
<Typography variant="caption" style={{ color: $hasSecret ? '#4caf50' : '#ff9800', display: 'block', marginBottom: 4 }}>
{$hasSecret
? '🔐 App Secret configured — 60-day tokens active'
: '⚡ No App Secret — 2h tokens (configure App Secret for 60-day tokens)'}
</Typography>
{$accounts.length === 0 ? (
<div style={{ border: '1px dashed rgba(255,255,255,0.15)', borderRadius: 8, padding: '18px 16px', textAlign: 'center' }}>
<FontAwesomeIcon icon={faFacebook} style={{ fontSize: '1.8rem', marginBottom: 8, color: '#333' }} />
<Typography variant="body2" style={{ color: '#666' }}>
No accounts connected. Click "+ Add account".
</Typography>
</div>
) : (
<List dense style={{ padding: 0 }}>
{$accounts.map((entry) => {
const sel = isSelectedEntry(entry);
return (
<ListItem key={entry.key} button onClick={() => handleSelect(entry)}
style={{
borderRadius: 8, marginBottom: 4, cursor: 'pointer',
border: sel ? '1px solid #2D88FF' : '1px solid rgba(255,255,255,0.08)',
background: sel ? 'rgba(45,136,255,0.1)' : 'rgba(255,255,255,0.03)',
}}
>
<ListItemAvatar style={{ minWidth: 44 }}>
<AccountAvatar name={entry.name} isPage={entry.isPage} />
</ListItemAvatar>
<ListItemText
primary={
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: sel ? 700 : 400, color: sel ? '#fff' : '#ccc', fontSize: '0.9rem' }}>
{entry.name}
</span>
<TokenBadge expired={entry.expired} tokenType={entry.tokenType} expiresAt={entry.expiresAt} />
{sel && <Chip label="Selected" size="small" style={{ background: '#2D88FF', color: '#fff', fontSize: '0.68rem', height: 18 }} />}
</span>
}
secondary={
<span style={{ fontSize: '0.75rem', color: '#777' }}>
{entry.isPage ? `📄 Page · ID: ${entry.pageId}` : `👤 Profile · ID: ${entry.fbUserId}`}
{entry.expiresAt && !entry.isPage ? ` · Expires: ${fbOAuth.formatExpiry(entry.expiresAt)}` : ''}
{entry.scopes && entry.scopes.length > 0 ? ` · Scopes: ${entry.scopes.join(', ')}` : ''}
</span>
}
/>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', ml: 1 }}>
{!entry.isPage && (
<Tooltip title="Refresh token (60 days)">
<span>
<IconButton size="small" disabled={$authorizing}
onClick={(e) => { e.stopPropagation(); handleRefreshAccount(entry.fbUserId); }}
style={{ color: '#ff9800' }}
>
<RefreshIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
{!entry.isPage && (
<Tooltip title="Disconnect account">
<IconButton size="small"
onClick={(e) => { e.stopPropagation(); handleRemoveAccount(entry.fbUserId); }}
style={{ color: '#f44336' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</ListItem>
);
})}
</List>
)}
</Grid>
{/* Account type selector */}
<Grid item xs={12}>
<Select
label={<Trans>Account type</Trans>}
value={settings.account_type}
onChange={handleChange('account_type')}
>
<MenuItem value="page">
Facebook Page (requires Page ID + Page Access Token)
</MenuItem>
<MenuItem value="user">
Personal profile (requires User Access Token with publish_video)
</MenuItem>
</Select>
</Grid>
{/* Page ID — solo visible cuando account_type = 'page' */}
{settings.account_type === 'page' && (
<Grid item xs={12} md={6}>
<TextField
variant="outlined"
fullWidth
label={<Trans>Facebook Page ID</Trans>}
placeholder="123456789012345"
helperText={
<Trans>
Find it at: facebook.com/YOUR_PAGE About Page transparency Page ID
</Trans>
}
value={settings.page_id}
onChange={handleChange('page_id')}
/>
{$error && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#f44336', whiteSpace: 'pre-wrap', fontSize: '0.82rem', background: 'rgba(244,67,54,0.07)', padding: '8px 12px', borderRadius: 6, border: '1px solid rgba(244,67,54,0.3)' }}>
{$error}
</Typography>
</Grid>
)}
{$success && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#4caf50', fontSize: '0.82rem', background: 'rgba(76,175,80,0.07)', padding: '8px 12px', borderRadius: 6, border: '1px solid rgba(76,175,80,0.3)' }}>
{$success}
</Typography>
</Grid>
)}
{/* Access Token */}
<Grid item xs={12} md={settings.account_type === 'page' ? 6 : 12}>
<TextField
variant="outlined"
fullWidth
label={
settings.account_type === 'page'
? <Trans>Page Access Token</Trans>
: <Trans>User Access Token</Trans>
}
placeholder="EAAxxxxxxx..."
helperText={
settings.account_type === 'page'
? <Trans>Must have publish_video permission. Get it from Graph API Explorer selecting your Page.</Trans>
: <Trans>User token with publish_video permission. Get it from Graph API Explorer.</Trans>
}
value={settings.page_access_token}
onChange={handleChange('page_access_token')}
/>
<Grid item xs={12}><Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} /></Grid>
{/* ══ Configuración de stream ══ */}
<Grid item xs={12}>
<Typography variant="h4" style={{ marginBottom: 4 }}>Stream settings</Typography>
</Grid>
{/* Title */}
<Grid item xs={12}>
<TextField
variant="outlined"
fullWidth
label={<Trans>Stream title</Trans>}
placeholder={props.metadata && props.metadata.name ? props.metadata.name : ''}
<TextField variant="outlined" fullWidth
label="Live title"
placeholder={props.metadata?.name || 'My live stream'}
value={settings.title}
onChange={handleChange('title')}
/>
</Grid>
{/* Description */}
<Grid item xs={12}>
<TextField
variant="outlined"
fullWidth
multiline
rows={3}
label={<Trans>Stream description</Trans>}
placeholder={props.metadata && props.metadata.description ? props.metadata.description : ''}
<TextField variant="outlined" fullWidth multiline rows={2}
label="Description"
placeholder={props.metadata?.description || ''}
value={settings.description}
onChange={handleChange('description')}
/>
</Grid>
{/* Error message */}
{$apiError && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#f44336', whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
{$apiError}
</Typography>
</Grid>
)}
{/* Success message */}
{$apiSuccess && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#4caf50', fontSize: '0.8rem' }}>
{$apiSuccess}
</Typography>
</Grid>
)}
{/* Create button */}
<Grid item xs={12}>
<FormInlineButton
onClick={createFacebookLive}
disabled={$loading || !settings.page_access_token}
<Button variant="contained" fullWidth onClick={handleStartLive}
disabled={$loading || !$selected}
style={{ background: $loading ? '#333' : '#1877f2', color: '#fff', textTransform: 'none', fontSize: '1rem', padding: '10px 0', borderRadius: 8 }}
startIcon={$loading ? <CircularProgress size={18} color="inherit" /> : <FontAwesomeIcon icon={faFacebook} />}
>
{$loading ? <Trans>Creating...</Trans> : <Trans>Create Live &amp; get stream key</Trans>}
</FormInlineButton>
<Typography variant="caption" style={{ display: 'block', marginTop: 4, color: '#888' }}>
<Trans>
Required permissions: <strong>publish_video</strong>.
For pages, also: <strong>pages_manage_posts</strong>, <strong>pages_read_engagement</strong>.
</Trans>
{$loading
? 'Creating stream…'
: $selected
? `Start Live as "${$selected.name}"`
: 'Select an account first'}
</Button>
{$selected && $selected.expired && (
<Typography variant="caption" style={{ color: '#ff9800', display: 'block', marginTop: 4 }}>
Token is expired. Use 🔄 to refresh it.
</Typography>
)}
{$selected && $selected.tokenType === 'short' && (
<Typography variant="caption" style={{ color: '#ff9800', display: 'block', marginTop: 4 }}>
Short-lived token (2h). Configure App Secret for 60-day tokens.
</Typography>
)}
</Grid>
<Grid item xs={12}><Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} /></Grid>
{/* ══ Stream keys ══ */}
<Grid item xs={12}>
<Typography variant="h4"><Trans>Stream Keys</Trans></Typography>
<Typography variant="caption" style={{ color: '#777' }}>
Filled when Live is created. You can also paste them manually.
</Typography>
</Grid>
{settings.rtmp_primary && (
<>
<Grid item xs={12} md={9}>
<TextField variant="outlined" fullWidth label={<Trans>Stream key principal</Trans>}
value={settings.stream_key_primary} onChange={handleChange('stream_key_primary')} />
</Grid>
<Grid item xs={12} md={3}>
<FormInlineButton target="blank" href={stream_key_link} component="a"><Trans>GET</Trans></FormInlineButton>
</Grid>
</>
)}
{settings.rtmp_backup && (
<>
<Grid item xs={12} md={9}>
<TextField variant="outlined" fullWidth label={<Trans>Stream key backup</Trans>}
value={settings.stream_key_backup} onChange={handleChange('stream_key_backup')} />
</Grid>
<Grid item xs={12} md={3}>
<FormInlineButton target="blank" href={stream_key_link} component="a"><Trans>GET</Trans></FormInlineButton>
</Grid>
</>
)}
<Grid item xs={12}>
<Checkbox label="Enable primary stream" checked={settings.rtmp_primary} onChange={handleChange('rtmp_primary')} />
<Checkbox label="Enable backup stream" checked={settings.rtmp_backup} onChange={handleChange('rtmp_backup')} />
</Grid>
<Grid item xs={12}><Divider style={{ borderColor: 'rgba(255,255,255,0.08)', marginTop: 4 }} /></Grid>
{/* ══ Token manual (avanzado) ══ */}
<Grid item xs={12}>
<Typography variant="caption" style={{ color: '#555' }}>
Advanced: manual token (only if not using a connected account)
</Typography>
</Grid>
<Grid item xs={12} md={settings.account_type === 'page' ? 6 : 12}>
<Select label="Account type" value={settings.account_type} onChange={handleChange('account_type')}>
<MenuItem value="page">Facebook Page</MenuItem>
<MenuItem value="user">Personal profile</MenuItem>
</Select>
</Grid>
{settings.account_type === 'page' && (
<Grid item xs={12} md={6}>
<TextField variant="outlined" fullWidth label="Page ID"
placeholder="123456789012345" value={settings.page_id} onChange={handleChange('page_id')} />
</Grid>
)}
<Grid item xs={12}>
<TextField variant="outlined" fullWidth
label={settings.account_type === 'page' ? 'Page Access Token' : 'User Access Token'}
placeholder="EAAxxxxxxx…"
value={settings.page_access_token}
onChange={handleChange('page_access_token')}
/>
</Grid>
</Grid>
);
}
@ -371,3 +566,4 @@ Service.defaultProps = {
};
export { id, name, version, stream_key_link, description, image_copyright, author, category, requires, ServiceIcon as icon, Service as component };

View File

@ -7,24 +7,25 @@ import { v4 as uuidv4 } from 'uuid';
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 MuiTextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Checkbox from '../../../misc/Checkbox';
import FormInlineButton from '../../../misc/FormInlineButton';
import Select from '../../../misc/Select';
import ytOAuth from '../../../utils/ytOAuth';
const id = 'youtube';
const name = 'YouTube Live';
const version = '1.1';
const version = '1.4';
const stream_key_link = 'https://www.youtube.com/live_dashboard';
const description = (
<Trans>
Transmits your video as an RTMP stream with the required key generated in YouTube Studio. You can find more information on setting up a live stream at
YouTube's{' '}
Transmits your video as RTMP. Connect a YouTube channel account using the global OAuth2 credentials from Settings Integrations,
then create a live broadcast to get the stream key automatically.{' '}
<Link color="secondary" target="_blank" href="https://creatoracademy.youtube.com/">
Creator Academy
</Link>
.
</Trans>
);
const image_copyright = (
@ -38,14 +39,8 @@ const image_copyright = (
);
const author = {
creator: {
name: 'datarhei',
link: 'https://github.com/datarhei',
},
maintainer: {
name: 'datarhei',
link: 'https://github.com/datarhei',
},
creator: { name: 'datarhei', link: 'https://github.com/datarhei' },
maintainer: { name: 'datarhei', link: 'https://github.com/datarhei' },
};
const category = 'platform';
@ -58,145 +53,431 @@ const requires = {
},
};
const YT_API = 'https://www.googleapis.com/youtube/v3';
function ServiceIcon(props) {
return <FontAwesomeIcon icon={faYoutube} style={{ color: '#FF0000' }} {...props} />;
}
function init(settings) {
const initSettings = {
return {
mode: 'rtmps',
stream_key: '',
primary: true,
backup: false,
title: '',
description: '',
// Datos de la cuenta conectada a ESTA publication
account_key: '', // clave en ytOAuth store (channel_id)
account_label: '', // nombre amigable del canal
oauth_broadcast_id: '',
oauth_stream_id: '',
privacy_status: 'public',
scheduled_start: '',
...settings,
};
return initSettings;
}
function Service(props) {
const settings = init(props.settings);
// Pre-fill title/description from channel metadata if not already set
const [$loading, setLoading] = React.useState(false);
const [$apiError, setApiError] = React.useState('');
const [$apiSuccess, setApiSuccess] = React.useState('');
const [$connecting, setConnecting] = React.useState(false);
// Credenciales globales guardadas en Settings → Integrations
const globalCreds = ytOAuth.getCredentials();
const hasGlobalCreds = !!(globalCreds.client_id && globalCreds.client_secret);
// Pre-fill title/description desde metadata
if (!settings.title && props.metadata && props.metadata.name) {
settings.title = props.metadata.name;
}
if (!settings.description && props.metadata && props.metadata.description) {
settings.description = props.metadata.description;
}
if (!settings.scheduled_start) {
const d = new Date(Date.now() + 5 * 60 * 1000);
settings.scheduled_start = d.toISOString().slice(0, 16);
}
// Cuenta conectada a ESTA publication (guardada en ytOAuth store)
const connectedAccount = settings.account_key ? ytOAuth.getAccount(settings.account_key) : null;
const isConnected = !!(connectedAccount && connectedAccount.access_token);
const isTokenExpired = isConnected && connectedAccount.token_expiry
? Date.now() > connectedAccount.token_expiry - 60000
: false;
const pushSettings = (s) => props.onChange(createOutput(s), s);
const handleChange = (what) => (event) => {
const value = event.target.value;
const value = event && event.target ? event.target.value : event;
setApiError('');
if (['primary', 'backup'].includes(what)) {
settings[what] = !settings[what];
} else {
settings[what] = value;
}
const outputs = createOutput(settings);
props.onChange(outputs, settings);
pushSettings(settings);
};
const createOutput = (settings) => {
const createOutput = (s) => {
const outputs = [];
if (settings.stream_key.length === 0) {
return outputs;
}
if (settings.mode === 'rtmps') {
if (!s.stream_key) return outputs;
if (s.mode === 'rtmps') {
let options = ['-f', 'flv'];
if (props.skills.ffmpeg.version_major >= 6) {
if (props.skills && props.skills.ffmpeg && props.skills.ffmpeg.version_major >= 6) {
const codecs = [];
if (props.skills.codecs.video.includes('hevc')) {
codecs.push('hvc1');
}
if (props.skills.codecs.video.includes('av1')) {
codecs.push('av01');
}
if (codecs.length !== 0) {
options.push('-rtmp_enhanced_codecs', codecs.join(','));
}
if (props.skills.codecs && props.skills.codecs.video && props.skills.codecs.video.includes('hevc')) codecs.push('hvc1');
if (props.skills.codecs && props.skills.codecs.video && props.skills.codecs.video.includes('av1')) codecs.push('av01');
if (codecs.length) options.push('-rtmp_enhanced_codecs', codecs.join(','));
}
// https://developers.google.com/youtube/v3/live/guides/rtmps-ingestion
if (settings.primary === true) {
outputs.push({
address: 'rtmps://a.rtmp.youtube.com/live2/' + settings.stream_key,
options: options.slice(),
});
if (s.primary) outputs.push({ address: 'rtmps://a.rtmp.youtube.com/live2/' + s.stream_key, options: options.slice() });
if (s.backup) outputs.push({ address: 'rtmps://b.rtmp.youtube.com/live2?backup=1/' + s.stream_key, options: options.slice() });
} else if (s.mode === 'hls') {
const hlsName = uuidv4();
const options = ['-f','hls','-start_number','0','-hls_time','2','-hls_delete_threshold','3',
'-hls_list_size','5','-hls_flags','append_list','-hls_segment_type','mpegts',
'-http_persistent','1','-y','-method','PUT'];
if (s.primary) {
const base = `https://a.upload.youtube.com/http_upload_hls?cid=${s.stream_key}&copy=0&file=`;
outputs.push({ address: base + hlsName + '.m3u8', options: [...options, '-hls_segment_filename', base + hlsName + '_%d.ts'] });
}
if (settings.backup === true) {
outputs.push({
address: 'rtmps://b.rtmp.youtube.com/live2?backup=1/' + settings.stream_key,
options: options.slice(),
});
}
} else if (settings.mode === 'hls') {
// https://developers.google.com/youtube/v3/live/guides/hls-ingestion
const name = uuidv4();
const options = [
'-f',
'hls',
'-start_number',
'0',
'-hls_time',
'2',
'-hls_delete_threshold',
'3',
'-hls_list_size',
'5',
'-hls_flags',
'append_list',
'-hls_segment_type',
'mpegts',
'-http_persistent',
'1',
'-y',
'-method',
'PUT',
];
if (settings.primary === true) {
const base = `https://a.upload.youtube.com/http_upload_hls?cid=${settings.stream_key}&copy=0&file=`;
outputs.push({
address: base + name + '.m3u8',
options: [...options, '-hls_segment_filename', base + name + '_%d.ts'],
});
}
if (settings.backup === true) {
const base = `https://b.upload.youtube.com/http_upload_hls?cid=${settings.stream_key}&copy=1&file=`;
outputs.push({
address: base + name + '.m3u8',
options: [...options, '-hls_segment_filename', base + name + '_%d.ts'],
});
if (s.backup) {
const base = `https://b.upload.youtube.com/http_upload_hls?cid=${s.stream_key}&copy=1&file=`;
outputs.push({ address: base + hlsName + '.m3u8', options: [...options, '-hls_segment_filename', base + hlsName + '_%d.ts'] });
}
}
return outputs;
};
const allowRTMPS = props.skills.protocols.includes('rtmps') && props.skills.formats.includes('flv');
const allowHLS = props.skills.protocols.includes('https') && props.skills.formats.includes('hls');
// ─── Conectar cuenta de YouTube para ESTA publication ─────────────────
const handleConnectAccount = () => {
setApiError('');
setApiSuccess('');
if (!hasGlobalCreds) {
setApiError('No global OAuth2 credentials found. Go to Settings → Integrations to enter your Client ID and Client Secret first.');
return;
}
const redirectUri = window.location.origin + '/oauth2callback';
const oauthState = btoa(JSON.stringify({ service: 'youtube', ts: Date.now() }));
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({
client_id: globalCreds.client_id,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/youtube.force-ssl',
access_type: 'offline',
prompt: 'consent',
state: oauthState,
}).toString();
// Abrir popup centrado
const w = 600, h = 700;
const left = Math.round(window.screenX + (window.outerWidth - w) / 2);
const top = Math.round(window.screenY + (window.outerHeight - h) / 2);
const authWindow = window.open(
authUrl,
'youtube_oauth_pub',
`width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
);
if (!authWindow) {
setApiError('Popup was blocked. Please allow popups for this site and try again.');
return;
}
setConnecting(true);
// Timeout de seguridad — 10 minutos
const timeoutId = setTimeout(() => {
window.removeEventListener('message', listener);
setConnecting(false);
setApiError('Authorization timed out. Please try again.');
}, 10 * 60 * 1000);
const listener = async (event) => {
// Aceptar mensajes del mismo origen o del popup
if (!event.data || event.data.service !== 'youtube') return;
// Ignorar el ACK que nosotros enviamos al popup
if (event.data.ack === true) return;
// Quitar el listener y timeout
window.removeEventListener('message', listener);
clearTimeout(timeoutId);
// Enviar ACK al popup para que se cierre
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);
}
} catch (e) { /* popup puede haber cerrado ya */ }
if (event.data.error) {
setApiError('Authorization denied: ' + event.data.error);
setConnecting(false);
return;
}
if (event.data.code) {
try {
// Intercambiar code por tokens
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: event.data.code,
client_id: globalCreds.client_id,
client_secret: globalCreds.client_secret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}).toString(),
});
const tokenData = await resp.json();
if (tokenData.error) throw new Error(tokenData.error_description || tokenData.error);
// Obtener info del canal
const channelInfo = await ytOAuth.fetchChannelInfo(tokenData.access_token);
const accountKey = channelInfo.channel_id || ('yt_' + Date.now());
const channelName = channelInfo.channel_title || accountKey;
// Guardar cuenta en ytOAuth store
ytOAuth.saveAccount(accountKey, {
label: channelName,
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token || '',
token_expiry: Date.now() + (tokenData.expires_in || 3600) * 1000,
channel_title: channelInfo.channel_title || '',
channel_id: channelInfo.channel_id || accountKey,
});
// Actualizar settings de esta Publication
settings.account_key = accountKey;
settings.account_label = channelName;
pushSettings(settings);
setApiSuccess('✅ Channel "' + channelName + '" connected successfully! Now click "Create Live Broadcast & get stream key".');
} catch (err) {
setApiError('❌ ' + err.message);
} finally {
setConnecting(false);
}
}
};
window.addEventListener('message', listener);
// Detectar si el usuario cierra el popup manualmente
const pollClosed = setInterval(() => {
if (authWindow.closed) {
clearInterval(pollClosed);
// Si todavía estamos esperando (el listener no disparó), limpiar
window.removeEventListener('message', listener);
clearTimeout(timeoutId);
setConnecting(false);
}
}, 500);
};
// ─── Pegar token manualmente ──────────────────────────────────────────
const handleManualToken = async () => {
setApiError('');
setApiSuccess('');
const token = window.prompt(
'Paste your YouTube OAuth2 Access Token.\n\nGet it from:\nhttps://developers.google.com/oauthplayground\n\nScope: https://www.googleapis.com/auth/youtube.force-ssl'
);
if (!token || !token.trim()) return;
setConnecting(true);
try {
const channelInfo = await ytOAuth.fetchChannelInfo(token.trim());
const accountKey = channelInfo.channel_id || ('yt_manual_' + Date.now());
ytOAuth.saveAccount(accountKey, {
label: channelInfo.channel_title || 'Manual token',
access_token: token.trim(),
refresh_token: '',
token_expiry: Date.now() + 3600 * 1000,
channel_title: channelInfo.channel_title || '',
channel_id: channelInfo.channel_id || accountKey,
});
settings.account_key = accountKey;
settings.account_label = channelInfo.channel_title || accountKey;
pushSettings(settings);
setApiSuccess('✅ Channel "' + (channelInfo.channel_title || accountKey) + '" connected (manual token — expires in 1h).');
} catch (err) {
setApiError('Error: ' + err.message);
} finally {
setConnecting(false);
}
};
// ─── Desconectar cuenta ───────────────────────────────────────────────
const handleDisconnect = () => {
settings.account_key = '';
settings.account_label = '';
settings.stream_key = '';
settings.oauth_broadcast_id = '';
settings.oauth_stream_id = '';
pushSettings(settings);
setApiError('');
setApiSuccess('');
};
// ─── Crear LiveBroadcast + LiveStream + Bind ───────────────────────────
const handleCreateLive = async () => {
setApiError('');
setApiSuccess('');
if (!isConnected) {
setApiError('Connect a YouTube channel account first.');
return;
}
if (!settings.title) {
setApiError('Enter a stream title first.');
return;
}
setLoading(true);
try {
const token = await ytOAuth.getValidToken(settings.account_key);
const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
const startTime = settings.scheduled_start
? new Date(settings.scheduled_start).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
// 1) LiveBroadcast
const bResp = await fetch(`${YT_API}/liveBroadcasts?part=snippet,status,contentDetails`, {
method: 'POST', headers,
body: JSON.stringify({
snippet: { title: settings.title, description: settings.description || '', scheduledStartTime: startTime },
status: { privacyStatus: settings.privacy_status || 'public', selfDeclaredMadeForKids: false },
contentDetails: { enableAutoStart: true, enableAutoStop: true, enableDvr: true, recordFromStart: true },
}),
});
const bData = await bResp.json();
if (!bResp.ok) throw new Error('Broadcast: ' + (bData.error ? bData.error.message : `HTTP ${bResp.status}`));
const broadcastId = bData.id;
// 2) LiveStream
const sResp = await fetch(`${YT_API}/liveStreams?part=snippet,cdn,status`, {
method: 'POST', headers,
body: JSON.stringify({
snippet: { title: settings.title },
cdn: { frameRate: 'variable', ingestionType: 'rtmp', resolution: 'variable' },
contentDetails: { isReusable: false },
}),
});
const sData = await sResp.json();
if (!sResp.ok) throw new Error('Stream: ' + (sData.error ? sData.error.message : `HTTP ${sResp.status}`));
const streamId = sData.id;
const streamKey = sData.cdn?.ingestionInfo?.streamName || '';
// 3) Bind
const bindResp = await fetch(
`${YT_API}/liveBroadcasts/bind?id=${broadcastId}&part=id,contentDetails&streamId=${streamId}`,
{ method: 'POST', headers }
);
if (!bindResp.ok) {
const bd = await bindResp.json();
throw new Error('Bind: ' + (bd.error ? bd.error.message : `HTTP ${bindResp.status}`));
}
settings.oauth_broadcast_id = broadcastId;
settings.oauth_stream_id = streamId;
settings.stream_key = streamKey;
pushSettings(settings);
setApiSuccess(
`✅ Broadcast created on "${connectedAccount.channel_title || connectedAccount.label}"!\n` +
`• Stream key filled automatically\n• Privacy: ${settings.privacy_status}\n• Starts: ${startTime}`
);
} catch (err) {
setApiError('❌ ' + err.message);
} finally {
setLoading(false);
}
};
const allowRTMPS = props.skills && props.skills.protocols && props.skills.protocols.includes('rtmps') && props.skills.formats && props.skills.formats.includes('flv');
const allowHLS = props.skills && props.skills.protocols && props.skills.protocols.includes('https') && props.skills.formats && props.skills.formats.includes('hls');
return (
<Grid container spacing={2}>
{/* ── Banner de cuenta conectada ──────────────────────────── */}
{isConnected ? (
<Grid item xs={12}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
background: 'rgba(66,183,42,0.08)',
border: '1px solid rgba(66,183,42,0.35)',
borderRadius: 6,
padding: '8px 14px',
}}>
<span style={{ fontSize: '1.2rem' }}>🟢</span>
<div style={{ lineHeight: 1.4 }}>
<Typography variant="body2" style={{ fontWeight: 600, fontSize: '0.88rem' }}>
YouTube channel connected:{' '}
<span style={{ color: '#4caf50' }}>
{connectedAccount.channel_title || connectedAccount.label || connectedAccount.channel_id}
</span>
</Typography>
{connectedAccount.channel_id && (
<Typography variant="caption" style={{ color: '#888', display: 'block' }}>
Channel ID: {connectedAccount.channel_id}
{isTokenExpired && (
<span style={{ color: '#ff9800', marginLeft: 8 }}> Token expired reconnect</span>
)}
</Typography>
)}
</div>
</div>
</Grid>
) : (
<Grid item xs={12}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
background: 'rgba(255,152,0,0.08)',
border: '1px solid rgba(255,152,0,0.35)',
borderRadius: 6,
padding: '8px 14px',
}}>
<span style={{ fontSize: '1.2rem' }}></span>
<Typography variant="body2" style={{ fontSize: '0.85rem', color: '#ff9800' }}>
{hasGlobalCreds
? 'No YouTube channel connected. Use the section below to authorize with Google.'
: <><span>No OAuth2 credentials found. </span><Link color="secondary" href="/#/settings/integrations" target="_blank">Go to Settings Integrations</Link><span> to add your Client ID and Client Secret.</span></>
}
</Typography>
</div>
</Grid>
)}
{/* ── Modo y stream key ──────────────────────────────────── */}
<Grid item xs={12}>
<Select label={<Trans>Delivering mode</Trans>} value={settings.mode} onChange={handleChange('mode')}>
{allowRTMPS === true && <MenuItem value="rtmps">RTMP</MenuItem>}
{allowHLS === true && <MenuItem value="hls">HLS</MenuItem>}
<Select label="Delivering mode" value={settings.mode} onChange={handleChange('mode')}>
{allowRTMPS && <MenuItem value="rtmps">RTMP</MenuItem>}
{allowHLS && <MenuItem value="hls">HLS</MenuItem>}
</Select>
</Grid>
<Grid item xs={12} md={9}>
<TextField variant="outlined" fullWidth label={<Trans>Stream key</Trans>} value={settings.stream_key} onChange={handleChange('stream_key')} />
<MuiTextField
variant="outlined"
fullWidth
label="Stream key"
helperText="Filled automatically after creating a live broadcast, or enter manually"
value={settings.stream_key}
onChange={handleChange('stream_key')}
/>
</Grid>
<Grid item xs={12} md={3}>
<FormInlineButton target="blank" href={stream_key_link} component="a">
@ -207,8 +488,10 @@ function Service(props) {
<Checkbox label={<Trans>Primary stream</Trans>} checked={settings.primary} onChange={handleChange('primary')} />
<Checkbox label={<Trans>Backup stream</Trans>} checked={settings.backup} onChange={handleChange('backup')} />
</Grid>
{/* ── Título y descripción ────────────────────────────────── */}
<Grid item xs={12}>
<TextField
<MuiTextField
variant="outlined"
fullWidth
label={<Trans>Stream title</Trans>}
@ -218,7 +501,7 @@ function Service(props) {
/>
</Grid>
<Grid item xs={12}>
<TextField
<MuiTextField
variant="outlined"
fullWidth
multiline
@ -229,6 +512,133 @@ function Service(props) {
onChange={handleChange('description')}
/>
</Grid>
{/* ── Cuenta de canal YouTube ─────────────────────────────── */}
<Grid item xs={12}>
<Typography variant="h4" style={{ marginTop: 4 }}>
YouTube channel account
</Typography>
{!hasGlobalCreds && (
<Typography variant="caption" style={{ color: '#ff9800', display: 'block', marginTop: 4 }}>
No OAuth2 credentials found. Go to{' '}
<Link color="secondary" href="/#/settings/integrations" target="_blank">
Settings Integrations
</Link>{' '}
to enter your Client ID and Client Secret first.
</Typography>
)}
</Grid>
{/* Estado de la cuenta conectada */}
{isConnected ? (
<React.Fragment>
<Grid item xs={12}>
{isTokenExpired ? (
<Typography variant="body2" style={{ color: '#ff9800', fontSize: '0.82rem' }}>
Token expired for <strong>{connectedAccount.channel_title || connectedAccount.label}</strong>. Reconnect the account.
</Typography>
) : (
<Typography variant="body2" style={{ color: '#4caf50', fontSize: '0.82rem' }}>
🟢 Connected: <strong>{connectedAccount.channel_title || connectedAccount.label}</strong>
{connectedAccount.channel_id && (
<span style={{ marginLeft: 8, color: '#888', fontSize: '0.75rem' }}>{connectedAccount.channel_id}</span>
)}
</Typography>
)}
</Grid>
<Grid item xs={12}>
<FormInlineButton onClick={handleDisconnect}>
Disconnect channel
</FormInlineButton>
</Grid>
</React.Fragment>
) : (
<React.Fragment>
<Grid item xs={12} md={6}>
<FormInlineButton
fullWidth
onClick={handleConnectAccount}
disabled={$connecting || !hasGlobalCreds}
>
{$connecting ? '⏳' : '🔑'} Authorize with Google
</FormInlineButton>
<Typography variant="caption" style={{ display: 'block', marginTop: 4, color: '#888', textAlign: 'center' }}>
Uses credentials from Settings Integrations
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormInlineButton
fullWidth
onClick={handleManualToken}
disabled={$connecting}
>
📋 Paste token manually
</FormInlineButton>
<Typography variant="caption" style={{ display: 'block', marginTop: 4, color: '#888', textAlign: 'center' }}>
OAuth Playground token expires in 1h
</Typography>
</Grid>
</React.Fragment>
)}
{/* ── Opciones del broadcast (solo si está conectado) ─────── */}
{isConnected && (
<React.Fragment>
<Grid item xs={12} md={6}>
<Select
label="Privacy status"
value={settings.privacy_status}
onChange={handleChange('privacy_status')}
>
<MenuItem value="public">Public</MenuItem>
<MenuItem value="unlisted">Unlisted</MenuItem>
<MenuItem value="private">Private</MenuItem>
</Select>
</Grid>
<Grid item xs={12} md={6}>
<MuiTextField
variant="outlined"
fullWidth
label="Scheduled start (local time)"
type="datetime-local"
value={settings.scheduled_start}
onChange={handleChange('scheduled_start')}
InputLabelProps={{ shrink: true }}
helperText="Leave default to start ~5 min from now"
/>
</Grid>
<Grid item xs={12}>
<FormInlineButton
fullWidth
onClick={handleCreateLive}
disabled={$loading || !settings.title}
>
{$loading ? 'Creating broadcast...' : '🎬 Create Live Broadcast & get stream key'}
</FormInlineButton>
{settings.oauth_broadcast_id && (
<Typography variant="caption" style={{ color: '#888', display: 'block', marginTop: 4 }}>
Broadcast ID: {settings.oauth_broadcast_id}
</Typography>
)}
</Grid>
</React.Fragment>
)}
{/* Error / Success */}
{$apiError && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#f44336', whiteSpace: 'pre-wrap', fontSize: '0.82rem' }}>
{$apiError}
</Typography>
</Grid>
)}
{$apiSuccess && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#4caf50', whiteSpace: 'pre-wrap', fontSize: '0.82rem' }}>
{$apiSuccess}
</Typography>
</Grid>
)}
</Grid>
);
}

View File

@ -39,6 +39,8 @@ import TabsVerticalGrid from '../misc/TabsVerticalGrid';
import Textarea from '../misc/Textarea';
import TextField from '../misc/TextField';
import useInterval from '../hooks/useInterval';
import ytOAuth from '../utils/ytOAuth';
import fbOAuth from '../utils/fbOAuth';
const useStyles = makeStyles((theme) => ({
inlineEnv: {
@ -726,6 +728,14 @@ export default function Settings(props) {
logging: { errors: false, messages: [] },
service: { errors: false, messages: [] },
});
// ── Integrations: credenciales OAuth2 globales (solo Client ID + Secret) ─
const [$ytCreds, setYtCreds] = React.useState(() => ytOAuth.getCredentials());
const [$ytCredsModified, setYtCredsModified] = React.useState(false);
// ── Integrations: Facebook App ID ─────────────────────────────────────────
const [$fbAppId, setFbAppId] = React.useState(() => fbOAuth.getAppId());
const [$fbAppIdModified, setFbAppIdModified] = React.useState(false);
const [$logdata, setLogdata] = React.useState('');
const logTimer = React.useRef();
const [$reloadKey, setReloadKey] = React.useState('');
@ -1176,6 +1186,21 @@ export default function Settings(props) {
return true;
};
// ── Handler para cambiar credenciales globales de YouTube ───────────────
const handleYtCredsChange = (field) => (e) => {
const val = e.target.value;
const updated = { ...$ytCreds, [field]: val };
setYtCreds(updated);
setYtCredsModified(true);
// NO persistir hasta que el usuario presione Save
};
// ── Handler para Facebook App ID ─────────────────────────────────────────
const handleFbAppIdChange = (e) => {
setFbAppId(e.target.value);
setFbAppIdModified(true);
};
const handleAbort = () => {
navigate(-1);
};
@ -1239,6 +1264,7 @@ export default function Settings(props) {
<ErrorTab className="tab" label={<Trans>RTMP</Trans>} value="rtmp" errors={$tabs.rtmp.errors} />
<ErrorTab className="tab" label={<Trans>SRT</Trans>} value="srt" errors={$tabs.srt.errors} />
{$expert === true && <ErrorTab className="tab" label={<Trans>Logging</Trans>} value="logging" errors={$tabs.logging.errors} />}
<Tab className="tab" label={<Trans>Integrations</Trans>} value="integrations" />
</Tabs>
<TabPanel value={$tab} index="general" className="panel">
<Grid container spacing={2}>
@ -2211,6 +2237,191 @@ export default function Settings(props) {
</Grid>
</Grid>
</TabPanel>
{/* ══════════════ INTEGRATIONS ══════════════════════════ */}
<TabPanel value={$tab} index="integrations" className="panel">
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h2">
<Trans>Integrations</Trans>
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body1">
<Trans>
Store your global OAuth2 credentials here. They will be used in all YouTube publications so you only need to enter them once.
</Trans>
</Typography>
</Grid>
{/* ── YouTube OAuth2 ────────────────────────────── */}
<Grid item xs={12}>
<Typography variant="h3" style={{ marginTop: 8 }}>
<Trans>YouTube Live OAuth2 Credentials</Trans>
</Typography>
<Typography variant="caption" style={{ color: '#aaa', display: 'block', marginBottom: 8 }}>
<Trans>
Create an OAuth2 Client ID in{' '}
</Trans>
<Link color="secondary" target="_blank" href="https://console.cloud.google.com/apis/credentials">
Google Cloud Console APIs &amp; Services Credentials
</Link>
<Trans>
{'. '}Then add{' '}
</Trans>
<strong style={{ color: '#fff' }}>{window.location.origin}/oauth2callback</strong>
<Trans>{' '}as an Authorized redirect URI.</Trans>
</Typography>
</Grid>
{/* Client ID */}
<Grid item xs={12} md={6}>
<TextField
variant="outlined"
fullWidth
label={<Trans>OAuth2 Client ID</Trans>}
placeholder="xxxxxxxxxxxx-xxxxxxxx.apps.googleusercontent.com"
helperText={<Trans>From Google Cloud Console OAuth 2.0 Client IDs</Trans>}
value={$ytCreds.client_id}
onChange={handleYtCredsChange('client_id')}
/>
</Grid>
{/* Client Secret */}
<Grid item xs={12} md={6}>
<Password
label={<Trans>OAuth2 Client Secret</Trans>}
placeholder="GOCSPX-xxxxxxxxxxxxxxxxx"
helperText={<Trans>From the same OAuth2 Client ID credential</Trans>}
value={$ytCreds.client_secret}
onChange={handleYtCredsChange('client_secret')}
/>
</Grid>
{/* Confirmación visual + botón Save propio */}
<Grid item xs={12} md={6}>
<Button
variant="outlined"
color="primary"
disabled={!$ytCredsModified}
onClick={() => {
ytOAuth.setCredentials($ytCreds.client_id, $ytCreds.client_secret);
setYtCredsModified(false);
}}
>
<Trans>Save credentials</Trans>
</Button>
</Grid>
<Grid item xs={12} md={6}>
{$ytCredsModified ? (
<Typography variant="body2" style={{ color: '#ff9800', fontSize: '0.82rem', lineHeight: '36px' }}>
<Trans>Unsaved changes</Trans>
</Typography>
) : $ytCreds.client_id ? (
<Typography variant="body2" style={{ color: '#4caf50', fontSize: '0.82rem', lineHeight: '36px' }}>
<Trans>Credentials saved</Trans>
</Typography>
) : null}
</Grid>
<Grid item xs={12}>
<Divider />
</Grid>
{/* Instrucciones */}
<Grid item xs={12}>
<Typography variant="h4">
<Trans>How to set up</Trans>
</Typography>
<Typography variant="body2" style={{ color: '#aaa', marginTop: 4 }}>
1. <Trans>Go to</Trans>{' '}
<Link color="secondary" target="_blank" href="https://console.cloud.google.com/apis/credentials">
console.cloud.google.com
</Link>
{' → '}
<Trans>Create project Enable YouTube Data API v3 Create OAuth 2.0 Client ID (type: Web application).</Trans>
</Typography>
<Typography variant="body2" style={{ color: '#aaa', marginTop: 4 }}>
2. <Trans>Add</Trans>{' '}
<strong style={{ color: '#fff' }}>{window.location.origin}/oauth2callback</strong>{' '}
<Trans>in "Authorized redirect URIs".</Trans>
</Typography>
<Typography variant="body2" style={{ color: '#aaa', marginTop: 4 }}>
3. <Trans>Paste the Client ID and Client Secret in the fields above.</Trans>
</Typography>
<Typography variant="body2" style={{ color: '#aaa', marginTop: 4 }}>
4. <Trans>In Publications YouTube Live click "Authorize with Google" to connect each channel account individually.</Trans>
</Typography>
</Grid>
{/* ── Divider ── */}
<Grid item xs={12}>
<Divider style={{ marginTop: 16, marginBottom: 8 }} />
</Grid>
{/* ── Facebook App ID ─────────────────────────────────── */}
<Grid item xs={12}>
<Typography variant="h3" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#2D88FF' }}>f</span>
<Trans>Facebook Live App ID</Trans>
</Typography>
<Typography variant="body2" style={{ color: '#aaa', marginTop: 4 }}>
<Trans>Required to use "Authorize with Facebook" in Publications Facebook Live. Create a Facebook App at </Trans>{' '}
<Link color="secondary" target="_blank" href="https://developers.facebook.com/apps/">
developers.facebook.com/apps
</Link>
<Trans>{' '}and add </Trans>
<strong style={{ color: '#fff' }}>{window.location.origin}/oauth/facebook/callback.html</strong>
<Trans>{' '}as a Valid OAuth Redirect URI in Facebook Login settings.</Trans>
</Typography>
</Grid>
<Grid item xs={12} md={8}>
<TextField
variant="outlined"
fullWidth
label={<Trans>Facebook App ID</Trans>}
placeholder="1234567890123456"
helperText={<Trans>From developers.facebook.com Your App Settings Basic App ID</Trans>}
value={$fbAppId}
onChange={handleFbAppIdChange}
/>
</Grid>
<Grid item xs={12} md={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button
variant="outlined"
color="primary"
disabled={!$fbAppIdModified}
onClick={() => {
fbOAuth.setAppId($fbAppId);
setFbAppIdModified(false);
}}
>
<Trans>Save App ID</Trans>
</Button>
{$fbAppIdModified ? (
<Typography variant="body2" style={{ color: '#ff9800', fontSize: '0.82rem' }}>
<Trans>Unsaved</Trans>
</Typography>
) : $fbAppId ? (
<Typography variant="body2" style={{ color: '#4caf50', fontSize: '0.82rem' }}>
<Trans>Saved</Trans>
</Typography>
) : null}
</Grid>
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#aaa', marginTop: 4, fontSize: '0.8rem' }}>
<strong style={{ color: '#e0e0e0' }}><Trans>Permissions needed:</Trans></strong>{' '}
<code>publish_video</code>, <code>pages_manage_posts</code>, <code>pages_read_engagement</code>, <code>pages_show_list</code>
</Typography>
<Typography variant="body2" style={{ color: '#888', marginTop: 4, fontSize: '0.78rem' }}>
<Trans>Note: Facebook's implicit flow gives a short-lived token (~2h). For production, use a server-side flow to get a long-lived token (60 days).</Trans>
</Typography>
</Grid>
</Grid>
</TabPanel>
</TabsVerticalGrid>
</Grid>
<PaperFooter

48
start-docker.bat Normal file
View File

@ -0,0 +1,48 @@
@echo off
setlocal
set IMAGE_NAME=restreamer-ui-v2
set TAG=latest
set CONTAINER_NAME=restreamer-ui-test
echo.
echo Verificando imagen %IMAGE_NAME%:%TAG% ...
docker images %IMAGE_NAME%:%TAG% --format "{{.Repository}}:{{.Tag}}" | findstr /i "%IMAGE_NAME%" >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo IMAGEN NO ENCONTRADA. Ejecuta build-docker.bat primero.
exit /b 1
)
echo Deteniendo contenedor anterior si existe...
docker stop %CONTAINER_NAME% >nul 2>&1
docker rm %CONTAINER_NAME% >nul 2>&1
echo.
echo Arrancando contenedor en http://localhost:3000/ui/ ...
echo.
docker run -d ^
--name %CONTAINER_NAME% ^
--restart unless-stopped ^
-p 3000:3000 ^
-e "CORE_ADDRESS=https://restreamer.nextream.sytes.net" ^
-e "YTDLP_HOST=192.168.1.20:8282" ^
-e "YTDLP_URL=" ^
-e "FB_SERVER_URL=" ^
-e "FB_ENCRYPTION_SECRET=restreamer-ui-fb-secret-key-32x!" ^
-v "restreamer-ui-fb-data:/data/fb" ^
%IMAGE_NAME%:%TAG%
if %ERRORLEVEL% NEQ 0 (
echo ERROR arrancando contenedor
exit /b %ERRORLEVEL%
)
echo.
echo === Contenedor corriendo ===
echo UI: http://localhost:3000/ui/
echo.
echo Mostrando logs (Ctrl+C para salir):
echo.
docker logs -f %CONTAINER_NAME%