Add Docker support and enhance YouTube/Facebook stream key management
This commit is contained in:
parent
2455251423
commit
3dba88cedd
@ -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
2
.gitignore
vendored
@ -2,7 +2,7 @@
|
||||
|
||||
# dependencies
|
||||
/NONPUBLIC
|
||||
/node_modules
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/
|
||||
|
||||
35
Caddyfile
35
Caddyfile
@ -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
|
||||
}
|
||||
|
||||
48
Dockerfile
48
Dockerfile
@ -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
70
build-docker.bat
Normal 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
80
build-docker.ps1
Normal 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
39
docker-compose.yml
Normal 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
43
docker-entrypoint.sh
Normal 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
|
||||
|
||||
@ -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: '',
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
179
public/oauth/facebook/callback.html
Normal file
179
public/oauth/facebook/callback.html
Normal 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
202
public/oauth2callback.html
Normal 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
55
run-docker.ps1
Normal 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
510
server/index.js
Normal 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
18
server/package.json
Normal 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
@ -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
213
src/utils/autoStreamKey.js
Normal 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
413
src/utils/fbOAuth.js
Normal 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 short→long exchange, saves to config.json)
|
||||
* 2b. If token → POST /fb/upgrade (server upgrades short→long, 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,
|
||||
};
|
||||
@ -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
169
src/utils/ytOAuth.js
Normal 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,
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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) {},
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & 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 };
|
||||
|
||||
|
||||
@ -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}©=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}©=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}©=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}©=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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 & 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
48
start-docker.bat
Normal 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%
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user