Compare commits

...

3 Commits

21 changed files with 1389 additions and 1438 deletions

3
.env.local Normal file
View File

@ -0,0 +1,3 @@
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

10
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
# Aumentar el límite de memoria de Node.js para evitar "heap out of memory"
node-options=--max-old-space-size=4096

2
.yarnrc Normal file
View File

@ -0,0 +1,2 @@
--max-old-space-size 4096

View File

@ -9,9 +9,17 @@ handle /fb-server/* {
}
# ── yt-dlp stream extractor (servicio externo configurable via env) ───────────
handle /yt-stream/* {
uri strip_prefix /yt-stream
reverse_proxy {env.YTDLP_HOST}
# /yt-stream/{VIDEO_ID} http://YTDLP_HOST/stream/{VIDEO_ID}
# yt-dlp puede tardar 20-30s timeouts extendidos a 120s
handle_path /yt-stream/* {
rewrite * /stream{path}
reverse_proxy {env.YTDLP_HOST} {
transport http {
dial_timeout 10s
response_header_timeout 120s
read_timeout 120s
}
}
}
# OAuth2 callback page must be served as a static HTML (not the SPA index)
@ -22,13 +30,27 @@ handle /oauth2callback {
}
}
# Facebook OAuth2 callback popup
# Facebook OAuth2 callback popup soporta tanto .html como .htm
# .html servir directamente
handle /oauth/facebook/callback.html {
file_server {
root /ui/build
}
}
# .htm reescribir internamente a .html (misma página, misma URL visible para Facebook)
handle /oauth/facebook/callback.htm {
rewrite * /oauth/facebook/callback.html
file_server {
root /ui/build
}
}
# Sin extensión redirigir a .html
handle /oauth/facebook/callback {
redir /oauth/facebook/callback.html{query} 302
}
# SPA serve static files, fallback to index.html for client-side routing
handle {
root * /ui/build

View File

@ -27,6 +27,14 @@ services:
# Dejar vacío → Caddy proxy /fb-server → localhost:3002 (sin CORS)
FB_SERVER_URL: ""
# URL EXACTA registrada en Facebook como "Valid OAuth Redirect URI"
# Debe coincidir con lo que tienes en developers.facebook.com
FB_OAUTH_CALLBACK_URL: "https://djmaster.nextream.sytes.net/oauth/facebook/callback.htm"
# URL EXACTA registrada en Google Console como "Authorized redirect URI"
# Debe coincidir con lo que tienes en console.cloud.google.com
YT_OAUTH_CALLBACK_URL: "https://djmaster.nextream.sytes.net/oauth2callback"
# Clave de cifrado para tokens almacenados (cámbiala en producción)
FB_ENCRYPTION_SECRET: "restreamer-ui-fb-secret-key-32x!"

View File

@ -9,9 +9,11 @@ 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:-}",
CORE_ADDRESS: "${CORE_ADDRESS:-}",
YTDLP_URL: "${YTDLP_URL:-}",
FB_SERVER_URL: "${FB_SERVER_URL:-}",
FB_OAUTH_CALLBACK_URL: "${FB_OAUTH_CALLBACK_URL:-}",
YT_OAUTH_CALLBACK_URL: "${YT_OAUTH_CALLBACK_URL:-}",
};
EOF

View File

@ -50,12 +50,12 @@
"videojs-overlay": "^3.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts --optimize-for-size build",
"start": "NODE_OPTIONS=--max-old-space-size=4096 react-scripts start",
"build": "NODE_OPTIONS=--max-old-space-size=4096 react-scripts --optimize-for-size build",
"start-build": "serve -s build",
"test": "react-scripts test",
"test-ci": "react-scripts test --watchAll=false --testTimeout 50000",
"test-coverage": "react-scripts test --watchAll=false --testTimeout 50000 --coverage",
"test": "NODE_OPTIONS=--max-old-space-size=4096 react-scripts test",
"test-ci": "NODE_OPTIONS=--max-old-space-size=4096 react-scripts test --watchAll=false --testTimeout 50000",
"test-coverage": "NODE_OPTIONS=--max-old-space-size=4096 react-scripts test --watchAll=false --testTimeout 50000 --coverage",
"eject": "react-scripts eject",
"i18n-extract": "lingui extract",
"i18n-extract:clean": "lingui extract --clean",

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@ -126,11 +126,15 @@
show('🔄', 'Intercambiando código…',
'Obteniendo token de larga duración (60 días)…', 'success');
// redirect_uri debe coincidir EXACTAMENTE con la URL registrada en Facebook
// Usamos window.location.href sin query string para obtener la URL exacta
var thisUrl = window.location.origin + window.location.pathname;
// 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' },
redirect_uri: thisUrl },
TARGET_ORIGIN
);
show('✅', '¡Código enviado!', 'Procesando token de larga duración…', 'success');
@ -138,7 +142,7 @@
} 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) + '…');
showDebug('code recibido pero window.opener es null.\ncode: ' + code.slice(0, 20) + '…\nredirect_uri: ' + thisUrl);
}
return;
}

View File

@ -135,14 +135,13 @@
function sendCode() {
if (ackReceived || attempts >= maxAttempts) return;
attempts++;
// redirect_uri = esta misma página (origin + pathname), exactamente como está registrada en Google
var thisUrl = window.location.origin + window.location.pathname;
var msg = { service: 'youtube', code: code, state: state, redirect_uri: thisUrl };
try {
window.opener.postMessage(
{ service: 'youtube', code: code, state: state },
window.location.origin // mismo origen
);
window.opener.postMessage(msg, window.location.origin);
} catch (e) {
// Si falla por política de origen, intentar con '*'
try { window.opener.postMessage({ service: 'youtube', code: code, state: state }, '*'); } catch (e2) {}
try { window.opener.postMessage(msg, '*'); } catch (e2) {}
}
setTimeout(sendCode, 600);
}

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,7 @@ module.exports = function (app) {
app.use('/diskfs', coreProxy);
// yt-dlp stream extractor: /yt-stream/{VIDEO_ID} → /stream/{VIDEO_ID}
// yt-dlp puede tardar hasta 30-60s en extraer la URL — timeout extendido
app.use(
'/yt-stream',
createProxyMiddleware({
@ -64,6 +65,8 @@ module.exports = function (app) {
changeOrigin: true,
secure: false,
ws: false,
proxyTimeout: 120000, // 120s — yt-dlp puede tardar bastante
timeout: 120000,
pathRewrite: { '^/yt-stream': '/stream' },
onError: (err, req, res) => {
console.error(`[setupProxy] yt-dlp proxy error: ${err.code}${err.message}`);

View File

@ -85,14 +85,13 @@ async function _serverDelete(path) {
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 };
c.__fb_config = { app_id: data.app_id || '', has_secret: !!data.has_secret };
_saveCache(c);
return c.__config;
return c.__fb_config;
} catch (_) {
const c = _loadCache();
return c.__config || { app_id: '', has_secret: false };
return c.__fb_config || c.__config || { app_id: '', has_secret: false };
}
};
@ -101,7 +100,7 @@ const getConfig = async () => {
*/
const getAppId = () => {
const c = _loadCache();
return c.__config?.app_id || '';
return c.__fb_config?.app_id || c.__config?.app_id || '';
};
/**
@ -114,7 +113,11 @@ const saveConfig = async ({ app_id, 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 };
c.__fb_config = {
...(c.__fb_config || c.__config || {}),
app_id: app_id ?? c.__fb_config?.app_id ?? '',
has_secret: app_secret ? true : !!(c.__fb_config?.has_secret),
};
_saveCache(c);
}
return data;
@ -208,7 +211,13 @@ const authorizeWithPopup = (appId, hasSecret = false) => {
}
return new Promise((resolve, reject) => {
const redirectUri = window.location.origin + '/oauth/facebook/callback.html';
// Usar FB_OAUTH_CALLBACK_URL si está definida (permite usar .htm o cualquier dominio)
// De lo contrario construir desde window.location.origin
const _rtCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
const redirectUri = _rtCfg.FB_OAUTH_CALLBACK_URL
? _rtCfg.FB_OAUTH_CALLBACK_URL
: 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)
@ -273,16 +282,19 @@ const authorizeWithPopup = (appId, hasSecret = false) => {
*
* @param {string} appId
* @param {boolean} hasSecret
* @param {{ restreamer_channel_id?: string, restreamer_publication_id?: string }} [context]
* @returns {Promise<{ account: object, tokenType: 'long'|'short', expiresAt: number }>}
*/
const connectAccount = async (appId, hasSecret) => {
const connectAccount = async (appId, hasSecret, context = {}) => {
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,
code: result.code,
redirect_uri: result.redirect_uri,
restreamer_channel_id: context.restreamer_channel_id || '',
restreamer_publication_id: context.restreamer_publication_id || '',
});
if (data.error) throw new Error(data.error);
// refresh cache
@ -293,7 +305,11 @@ const connectAccount = async (appId, hasSecret) => {
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 });
const data = await _serverPost('/fb/upgrade', {
access_token: result.access_token,
restreamer_channel_id: context.restreamer_channel_id || '',
restreamer_publication_id: context.restreamer_publication_id || '',
});
if (data.ok) {
await listAccounts();
return { account: data.account, tokenType: 'long', expiresAt: data.account.expires_at };
@ -304,14 +320,16 @@ const connectAccount = async (appId, hasSecret) => {
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: [],
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(),
restreamer_channel_id: context.restreamer_channel_id || '',
restreamer_publication_id: context.restreamer_publication_id || '',
updated_at: Date.now(),
};
// cache in localStorage (no server persistence)
const c = _loadCache();
@ -319,7 +337,6 @@ const connectAccount = async (appId, hasSecret) => {
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);
@ -382,10 +399,10 @@ const getTokenForEntry = async (fbUserId, pageId) => {
}
};
// ── Legacy compatibility (localStorage-only, for components not yet migrated)
// ── Legacy compatibility ─────────────────────────────────────────────────────
const setAppId = (app_id) => {
const c = _loadCache();
c.__config = { ...(c.__config || {}), app_id };
c.__fb_config = { ...(c.__fb_config || c.__config || {}), app_id };
_saveCache(c);
};

View File

@ -1,141 +1,300 @@
/**
* ytOAuth.js Almacenamiento global de credenciales OAuth2 de YouTube en localStorage.
* ytOAuth.js YouTube OAuth2 client utility para Restreamer UI.
*
* 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.
* Persistencia:
* - Credenciales (client_id, client_secret) y cuentas (tokens) se guardan
* en el servidor /data/fb/config.json (volumen Docker persistente).
* - localStorage se usa solo como caché para render inmediato (sin round-trip).
*
* 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
* }
* }
* }
* Estructura en config.json (gestionado por server/index.js):
* __yt_config: { client_id, client_secret (cifrado AES-256-GCM) }
* yt__<channel_id>: {
* account_key, label, channel_title, channel_id,
* access_token (cifrado), refresh_token (cifrado),
* token_expiry (Unix ms), updated_at
* }
*/
const STORAGE_KEY = 'yt_oauth';
const PREFIX = '@@restreamer-ui@@';
const LS_KEY = '@@restreamer-ui@@yt_oauth_v2';
const _read = () => {
// Mismo base URL que fbOAuth — Caddy proxea /fb-server → Node :3002
const _rtCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
const SERVER_BASE = _rtCfg.FB_SERVER_URL
? _rtCfg.FB_SERVER_URL.replace(/\/$/, '')
: '/fb-server';
// ── localStorage cache ────────────────────────────────────────────────────────
function _readCache() {
try {
const raw = window.localStorage.getItem(PREFIX + STORAGE_KEY);
const raw = localStorage.getItem(LS_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);
}
};
function _writeCache(data) {
try { localStorage.setItem(LS_KEY, JSON.stringify(data)); }
catch (_) {}
}
/** Obtener toda la config */
const getAll = () => _read();
// ── Server helpers ────────────────────────────────────────────────────────────
async function _get(path) {
const res = await fetch(SERVER_BASE + path);
return res.json();
}
/** Guardar client_id y client_secret globales */
async function _put(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 _post(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 _del(path) {
const res = await fetch(SERVER_BASE + path, { method: 'DELETE' });
return res.json();
}
// ── Credentials ───────────────────────────────────────────────────────────────
/** Guardar en localStorage (sync, para compatibilidad legacy) */
const setCredentials = (client_id, client_secret) => {
const data = _read();
data.client_id = client_id || '';
data.client_secret = client_secret || '';
_write(data);
const cache = _readCache();
cache.client_id = client_id || '';
cache.client_secret = client_secret || '';
_writeCache(cache);
};
/** 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);
/**
* Guardar credenciales servidor (config.json) + localStorage.
* @returns {Promise<{ok: boolean}>}
*/
const saveCredentials = async (client_id, client_secret) => {
setCredentials(client_id, client_secret); // caché inmediata
try {
return await _put('/yt/config', { client_id: client_id || '', client_secret: client_secret || '' });
} catch (err) {
console.warn('[ytOAuth] saveCredentials server error:', err.message);
return { ok: false, error: err.message };
}
};
/** Listar cuentas como array [{key, label, email, channel_title, ...}] */
/** Obtener credenciales desde localStorage (sync) */
const getCredentials = () => {
const cache = _readCache();
return { client_id: cache.client_id || '', client_secret: cache.client_secret || '' };
};
/**
* Sincronizar credenciales desde el servidor localStorage.
* Llamar al montar Settings para cargar lo que está en config.json.
*/
const loadCredentialsFromServer = async () => {
try {
// /yt/config/full incluye el client_secret descifrado
const data = await _get('/yt/config/full');
if (data.client_id) {
setCredentials(data.client_id, data.client_secret || '');
}
return { client_id: data.client_id || '', client_secret: data.client_secret || '' };
} catch (_) {
return getCredentials();
}
};
// ── Accounts ──────────────────────────────────────────────────────────────────
/**
* Guardar cuenta en servidor (persiste en config.json cifrado) + caché.
*/
const saveAccount = async (accountKey, accountData) => {
// 1. Actualizar caché inmediatamente
const cache = _readCache();
if (!cache.accounts) cache.accounts = {};
cache.accounts[accountKey] = { ...(cache.accounts[accountKey] || {}), ...accountData, updated_at: Date.now() };
_writeCache(cache);
// 2. Persistir en servidor
try {
return await _post('/yt/accounts', {
account_key: accountKey,
label: accountData.label || accountData.channel_title || accountKey,
channel_title: accountData.channel_title || '',
channel_id: accountData.channel_id || '',
access_token: accountData.access_token || '',
refresh_token: accountData.refresh_token || '',
token_expiry: accountData.token_expiry || 0,
restreamer_channel_id: accountData.restreamer_channel_id || '',
restreamer_publication_id: accountData.restreamer_publication_id || '',
});
} catch (err) {
console.warn('[ytOAuth] saveAccount server error:', err.message);
return { ok: false, error: err.message };
}
};
/**
* Guardar cuenta sync en localStorage (legacy para flujos que no son async).
*/
const saveAccountSync = (accountKey, accountData) => {
const cache = _readCache();
if (!cache.accounts) cache.accounts = {};
cache.accounts[accountKey] = { ...(cache.accounts[accountKey] || {}), ...accountData, updated_at: Date.now() };
_writeCache(cache);
};
/**
* Cargar lista de cuentas desde el servidor actualiza caché.
*/
const loadAccountsFromServer = async () => {
try {
const accounts = await _get('/yt/accounts');
if (Array.isArray(accounts)) {
const cache = _readCache();
// Convertir array a mapa { [account_key]: data }
cache.accounts = {};
accounts.forEach((a) => { cache.accounts[a.account_key] = a; });
_writeCache(cache);
return cache.accounts;
}
} catch (_) {}
return _readCache().accounts || {};
};
/**
* Obtener token completo (con access_token + refresh_token) desde el servidor.
*/
const getAccountToken = async (accountKey) => {
try {
return await _get(`/yt/accounts/${encodeURIComponent(accountKey)}/token`);
} catch (_) {
return getAccount(accountKey);
}
};
/** Obtener todas las cuentas desde localStorage (sync) */
const getAccounts = () => {
return _readCache().accounts || {};
};
/** Obtener una cuenta por key desde localStorage (sync) */
const getAccount = (accountKey) => {
return (_readCache().accounts || {})[accountKey] || null;
};
/** Obtener todos los datos */
const getAll = () => _readCache();
/** Listar cuentas como array */
const listAccounts = () => {
const data = _read();
return Object.entries(data.accounts || {}).map(([key, val]) => ({ key, ...val }));
return Object.entries(_readCache().accounts || {}).map(([key, val]) => ({ key, ...val }));
};
/** Refrescar access_token usando el refresh_token almacenado */
/**
* Eliminar cuenta del servidor + caché.
*/
const removeAccount = async (accountKey) => {
// 1. Eliminar del caché
const cache = _readCache();
if (cache.accounts && cache.accounts[accountKey]) {
delete cache.accounts[accountKey];
_writeCache(cache);
}
// 2. Eliminar del servidor
try {
return await _del(`/yt/accounts/${encodeURIComponent(accountKey)}`);
} catch (err) {
console.warn('[ytOAuth] removeAccount server error:', err.message);
return { ok: false };
}
};
// ── Token management ──────────────────────────────────────────────────────────
/**
* Refrescar access_token usando el refresh_token del servidor.
* El servidor hace la llamada a Google y actualiza config.json.
*/
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');
try {
const data = await _post(`/yt/accounts/${encodeURIComponent(accountKey)}/refresh`, {});
if (data.error) throw new Error(data.error);
// Actualizar caché
const cache = _readCache();
if (cache.accounts && cache.accounts[accountKey]) {
cache.accounts[accountKey].access_token = data.access_token;
cache.accounts[accountKey].token_expiry = data.token_expiry;
_writeCache(cache);
}
return data.access_token;
} catch (err) {
// Fallback: intentar con credenciales locales
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');
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);
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 tokenData = await resp.json();
if (tokenData.error) throw new Error(tokenData.error_description || tokenData.error);
saveAccount(accountKey, {
access_token: data.access_token,
token_expiry: Date.now() + (data.expires_in || 3600) * 1000,
});
return data.access_token;
// Persistir nuevo access_token
await saveAccount(accountKey, {
access_token: tokenData.access_token,
token_expiry: Date.now() + (tokenData.expires_in || 3600) * 1000,
});
return tokenData.access_token;
}
};
/** Obtener token válido (refresca automáticamente si está expirado) */
/** Obtener token válido (refresca automáticamente si está expirado).
* Si el access_token no está en caché local (porque viene del servidor cifrado),
* lo obtiene del endpoint /yt/accounts/:key/token.
*/
const getValidToken = async (accountKey) => {
const account = getAccount(accountKey);
if (!account || !account.access_token) throw new Error('Account not connected: ' + accountKey);
let account = getAccount(accountKey);
if (!account) throw new Error('Account not connected: ' + accountKey);
// Si no hay access_token en caché local, obtenerlo del servidor
if (!account.access_token) {
const serverAccount = await getAccountToken(accountKey);
if (serverAccount && serverAccount.access_token) {
// Guardar en caché para futuras llamadas
const cache = _readCache();
if (!cache.accounts) cache.accounts = {};
cache.accounts[accountKey] = { ...(cache.accounts[accountKey] || {}), ...serverAccount };
_writeCache(cache);
account = cache.accounts[accountKey];
} else {
throw new Error('Could not retrieve token for account: ' + 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 */
/** Fetch info del canal de YouTube */
const fetchChannelInfo = async (accessToken) => {
try {
const resp = await fetch(
@ -146,24 +305,33 @@ const fetchChannelInfo = async (accessToken) => {
if (data.items && data.items.length > 0) {
return {
channel_title: data.items[0].snippet.title,
channel_id: data.items[0].id,
channel_id: data.items[0].id,
};
}
} catch {}
} catch (_) {}
return {};
};
export default {
getAll,
// Credentials
setCredentials,
saveCredentials,
getCredentials,
loadCredentialsFromServer,
// Accounts
getAll,
saveAccount,
saveAccountSync,
loadAccountsFromServer,
getAccountToken,
getAccounts,
getAccount,
removeAccount,
listAccounts,
// Token management
refreshAccount,
getValidToken,
// Google API
fetchChannelInfo,
};

View File

@ -768,20 +768,34 @@ const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
const extractYouTubeVideoId = (url) => {
if (!url) return '';
// Handles: youtu.be/ID, ?v=ID, /embed/ID, /shorts/ID
const trimmed = url.trim();
// Formatos soportados:
// https://www.youtube.com/watch?v=ID
// https://www.youtube.com/watch?v=ID&t=123
// https://www.youtube.com/live/ID
// https://www.youtube.com/live/ID?si=TOKEN
// https://youtu.be/ID
// https://youtu.be/ID?si=TOKEN
// https://www.youtube.com/embed/ID
// https://www.youtube.com/shorts/ID
// https://www.youtube.com/v/ID
// https://m.youtube.com/... (versión móvil)
// https://www.youtube-nocookie.com/embed/ID
// ID (11 chars directamente)
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})/,
// ?v=ID o &v=ID
/[?&]v=([a-zA-Z0-9_-]{11})(?:[&?/]|$)/,
// youtu.be/ID — seguido de ?, &, / o fin
/youtu\.be\/([a-zA-Z0-9_-]{11})(?:[?&/]|$)/,
// /live/ID, /embed/ID, /shorts/ID, /v/ID — seguido de ?, &, / o fin
/\/(?:live|embed|shorts|v)\/([a-zA-Z0-9_-]{11})(?:[?&/]|$)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
const match = trimmed.match(pattern);
if (match) return match[1];
}
// If it's already just an ID (11 chars alphanumeric)
if (/^[a-zA-Z0-9_-]{11}$/.test(url.trim())) return url.trim();
// Si ya es solo un ID de 11 caracteres alfanuméricos
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
return '';
};
@ -805,8 +819,12 @@ function Pull(props) {
return;
}
setExtractorLoading(true);
// AbortController con 90s — yt-dlp puede tardar 20-30s en extraer la URL
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 90000);
try {
const response = await fetch(STREAM_SERVICE_BASE + videoId);
const response = await fetch(STREAM_SERVICE_BASE + videoId, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' - ' + response.statusText);
}
@ -814,9 +832,8 @@ function Pull(props) {
if (data && data.stream_url) {
props.onChange('', 'address')({ target: { value: data.stream_url } });
setExtractorError('');
// Propagar title y description si el servicio los devuelve
if (typeof props.onYoutubeMetadata === 'function') {
const title = data.title || data.video_title || '';
const title = data.title || data.video_title || '';
const description = data.description || data.video_description || '';
if (title || description) {
props.onYoutubeMetadata(title, description);
@ -826,7 +843,12 @@ function Pull(props) {
setExtractorError('No stream_url found in service response.');
}
} catch (e) {
setExtractorError('Error: ' + e.message);
clearTimeout(timeoutId);
if (e.name === 'AbortError') {
setExtractorError('Timeout: the extractor took too long. Try again.');
} else {
setExtractorError('Error: ' + e.message);
}
} finally {
setExtractorLoading(false);
}
@ -861,6 +883,11 @@ function Pull(props) {
{extractorError}
</Typography>
)}
{extractorLoading && (
<Typography variant="caption" style={{ color: '#2196f3', display: 'block' }}>
Extracting stream URL this may take up to 30s
</Typography>
)}
</Grid>
{detectedYouTubeId && (
<Grid item>

View File

@ -36,18 +36,17 @@ const STREAM_SERVICE_BASE = _runtimeCfg.YTDLP_URL
const extractYouTubeVideoId = (url) => {
if (!url) return '';
const trimmed = url.trim();
const patterns = [
/[?&]v=([a-zA-Z0-9_-]{11})/,
/youtu\.be\/([a-zA-Z0-9_-]{11})/,
/\/embed\/([a-zA-Z0-9_-]{11})/,
/\/shorts\/([a-zA-Z0-9_-]{11})/,
/\/v\/([a-zA-Z0-9_-]{11})/,
/[?&]v=([a-zA-Z0-9_-]{11})(?:[&?/]|$)/,
/youtu\.be\/([a-zA-Z0-9_-]{11})(?:[?&/]|$)/,
/\/(?:live|embed|shorts|v)\/([a-zA-Z0-9_-]{11})(?:[?&/]|$)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
const match = trimmed.match(pattern);
if (match) return match[1];
}
if (/^[a-zA-Z0-9_-]{11}$/.test(url.trim())) return url.trim();
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
return '';
};
@ -97,8 +96,12 @@ function Source(props) {
return;
}
setExtractorLoading(true);
// AbortController con 90s — yt-dlp puede tardar 20-30s en extraer la URL
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 90000);
try {
const response = await fetch(STREAM_SERVICE_BASE + videoId);
const response = await fetch(STREAM_SERVICE_BASE + videoId, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (data && data.stream_url) {
@ -106,7 +109,7 @@ function Source(props) {
handleChange(newSettings);
setExtractorError('');
if (typeof props.onYoutubeMetadata === 'function') {
const title = data.title || data.video_title || '';
const title = data.title || data.video_title || '';
const description = data.description || data.video_description || '';
if (title || description) props.onYoutubeMetadata(title, description);
}
@ -114,7 +117,12 @@ function Source(props) {
setExtractorError('No stream_url found in service response.');
}
} catch (e) {
setExtractorError('Error: ' + e.message);
clearTimeout(timeoutId);
if (e.name === 'AbortError') {
setExtractorError('Timeout: the extractor took too long. Try again.');
} else {
setExtractorError('Error: ' + e.message);
}
} finally {
setExtractorLoading(false);
}
@ -156,6 +164,11 @@ function Source(props) {
{extractorError}
</Typography>
)}
{extractorLoading && (
<Typography variant="caption" style={{ color: '#2196f3', display: 'block' }}>
Extracting stream URL this may take up to 30s
</Typography>
)}
</Grid>
{detectedYouTubeId && (
<Grid item>

View File

@ -448,6 +448,8 @@ export default function Edit(props) {
metadata={$metadata}
streams={$settings.streams}
onChange={handleServiceChange}
channelId={_channelid}
publicationId={id}
/>
</Grid>
</TabContent>

View File

@ -200,7 +200,11 @@ function Service(props) {
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);
const context = {
restreamer_channel_id: props.channelId || '',
restreamer_publication_id: props.publicationId || '',
};
const { account, tokenType, expiresAt } = await fbOAuth.connectAccount($appId, $hasSecret, context);
await loadAccounts();
const typeLabel = tokenType === 'long'
? `✅ Token de larga duración (~${Math.round((expiresAt - Date.now()) / 86400000)} días)`
@ -222,7 +226,11 @@ function Service(props) {
} catch (err) {
if (!$appId) { setError('❌ ' + err.message); setAuthorizing(false); return; }
try {
const { account, tokenType } = await fbOAuth.connectAccount($appId, $hasSecret);
const context = {
restreamer_channel_id: props.channelId || '',
restreamer_publication_id: props.publicationId || '',
};
const { account, tokenType } = await fbOAuth.connectAccount($appId, $hasSecret, context);
await loadAccounts();
setSuccess(`✅ Token renovado para "${account.name}". (${tokenType === 'long' ? '60 días' : '2h'})`);
} catch (err2) { setError('❌ ' + err2.message); }

View File

@ -85,9 +85,21 @@ function Service(props) {
const [$apiError, setApiError] = React.useState('');
const [$apiSuccess, setApiSuccess] = React.useState('');
const [$connecting, setConnecting] = React.useState(false);
const [$globalCreds, setGlobalCreds] = React.useState(() => ytOAuth.getCredentials());
// Sincronizar credenciales y cuentas desde el servidor al montar
React.useEffect(() => {
(async () => {
try {
const creds = await ytOAuth.loadCredentialsFromServer();
setGlobalCreds(creds);
await ytOAuth.loadAccountsFromServer();
} catch (_) {}
})();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Credenciales globales guardadas en Settings → Integrations
const globalCreds = ytOAuth.getCredentials();
const globalCreds = $globalCreds;
const hasGlobalCreds = !!(globalCreds.client_id && globalCreds.client_secret);
// Pre-fill title/description desde metadata
@ -104,8 +116,9 @@ function Service(props) {
// 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
// isConnected: tiene token local O tiene token en servidor (has_access_token)
const isConnected = !!(connectedAccount && (connectedAccount.access_token || connectedAccount.has_access_token));
const isTokenExpired = isConnected && connectedAccount && connectedAccount.token_expiry
? Date.now() > connectedAccount.token_expiry - 60000
: false;
@ -162,7 +175,12 @@ function Service(props) {
return;
}
const redirectUri = window.location.origin + '/oauth2callback';
// Usar YT_OAUTH_CALLBACK_URL si está definida (producción con dominio real)
// De lo contrario construir desde window.location.origin (desarrollo)
const _rtCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {};
const redirectUri = _rtCfg.YT_OAUTH_CALLBACK_URL
? _rtCfg.YT_OAUTH_CALLBACK_URL
: 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,
@ -225,42 +243,79 @@ function Service(props) {
if (event.data.code) {
try {
// Intercambiar code por tokens
const resp = await fetch('https://oauth2.googleapis.com/token', {
// Usar el redirect_uri que envió el callback (URL exacta de la página de callback)
// Esto garantiza que coincida exactamente con el registrado en Google Console
const callbackRedirectUri = event.data.redirect_uri || redirectUri;
// Intercambiar code por tokens vía servidor (incluye IDs de Restreamer)
const exchangeResp = await fetch('/fb-server/yt/exchange', {
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(),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: event.data.code,
redirect_uri: callbackRedirectUri,
restreamer_channel_id: props.channelId || '',
restreamer_publication_id: props.publicationId || '',
}),
});
const tokenData = await resp.json();
if (tokenData.error) throw new Error(tokenData.error_description || tokenData.error);
const exchangeData = await exchangeResp.json();
// 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;
if (exchangeData.error) {
// Fallback client-side si el servidor no tiene credentials
const localCreds = globalCreds;
if (!localCreds.client_id || !localCreds.client_secret) {
throw new Error(exchangeData.error);
}
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: localCreds.client_id,
client_secret: localCreds.client_secret,
redirect_uri: callbackRedirectUri,
grant_type: 'authorization_code',
}).toString(),
});
const tokenData = await resp.json();
if (tokenData.error) throw new Error(tokenData.error_description || tokenData.error);
// 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,
});
const channelInfo = await ytOAuth.fetchChannelInfo(tokenData.access_token);
const accountKey = channelInfo.channel_id || ('yt_' + Date.now());
const channelName = channelInfo.channel_title || accountKey;
// Actualizar settings de esta Publication
settings.account_key = accountKey;
settings.account_label = channelName;
pushSettings(settings);
await 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,
restreamer_channel_id: props.channelId || '',
restreamer_publication_id: props.publicationId || '',
});
setApiSuccess('✅ Channel "' + channelName + '" connected successfully! Now click "Create Live Broadcast & get stream key".');
settings.account_key = accountKey;
settings.account_label = channelName;
pushSettings(settings);
setApiSuccess('✅ Channel "' + channelName + '" connected successfully!');
} else {
// Éxito por servidor — cuenta ya guardada en config.json con IDs de Restreamer
const acc = exchangeData.account;
const accountKey = acc.account_key || acc.channel_id;
const channelName = acc.label || acc.channel_title || accountKey;
ytOAuth.saveAccountSync(accountKey, {
...acc,
restreamer_channel_id: props.channelId || '',
restreamer_publication_id: props.publicationId || '',
});
settings.account_key = accountKey;
settings.account_label = channelName;
pushSettings(settings);
setApiSuccess('✅ Channel "' + channelName + '" connected successfully!');
}
} catch (err) {
setApiError('❌ ' + err.message);
} finally {
@ -297,13 +352,15 @@ function Service(props) {
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,
await 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,
restreamer_channel_id: props.channelId || '',
restreamer_publication_id: props.publicationId || '',
});
settings.account_key = accountKey;
@ -364,8 +421,8 @@ function Service(props) {
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`, {
// 2) LiveStream — contentDetails debe estar en ?part= para poder enviarlo en el body
const sResp = await fetch(`${YT_API}/liveStreams?part=snippet,cdn,status,contentDetails`, {
method: 'POST', headers,
body: JSON.stringify({
snippet: { title: settings.title },

View File

@ -756,6 +756,19 @@ export default function Settings(props) {
React.useEffect(() => {
(async () => {
await load();
// Sincronizar credenciales desde el servidor al montar
try {
const ytCreds = await ytOAuth.loadCredentialsFromServer();
if (ytCreds.client_id) {
setYtCreds(ytCreds);
}
} catch (_) {}
try {
const fbCfg = await fbOAuth.getConfig();
if (fbCfg.app_id) {
setFbAppId(fbCfg.app_id);
}
} catch (_) {}
})();
return () => {
@ -2304,8 +2317,8 @@ export default function Settings(props) {
variant="outlined"
color="primary"
disabled={!$ytCredsModified}
onClick={() => {
ytOAuth.setCredentials($ytCreds.client_id, $ytCreds.client_secret);
onClick={async () => {
await ytOAuth.saveCredentials($ytCreds.client_id, $ytCreds.client_secret);
setYtCredsModified(false);
}}
>
@ -2393,8 +2406,8 @@ export default function Settings(props) {
variant="outlined"
color="primary"
disabled={!$fbAppIdModified}
onClick={() => {
fbOAuth.setAppId($fbAppId);
onClick={async () => {
await fbOAuth.saveConfig({ app_id: $fbAppId });
setFbAppIdModified(false);
}}
>

1180
yarn.lock

File diff suppressed because it is too large Load Diff