Add configuration files and enhance OAuth integration for YouTube and Facebook
This commit is contained in:
parent
3dba88cedd
commit
f866802864
3
.env.local
Normal file
3
.env.local
Normal 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
10
.idea/.gitignore
generated
vendored
Normal 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
3
.npmrc
Normal 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
|
||||
|
||||
30
Caddyfile
30
Caddyfile
@ -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
|
||||
|
||||
@ -27,6 +27,10 @@ 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"
|
||||
|
||||
# Clave de cifrado para tokens almacenados (cámbiala en producción)
|
||||
FB_ENCRYPTION_SECRET: "restreamer-ui-fb-secret-key-32x!"
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ window.__RESTREAMER_CONFIG__ = {
|
||||
CORE_ADDRESS: "${CORE_ADDRESS:-}",
|
||||
YTDLP_URL: "${YTDLP_URL:-}",
|
||||
FB_SERVER_URL: "${FB_SERVER_URL:-}",
|
||||
FB_OAUTH_CALLBACK_URL: "${FB_OAUTH_CALLBACK_URL:-}",
|
||||
};
|
||||
EOF
|
||||
|
||||
|
||||
10
package.json
10
package.json
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
860
server/index.js
860
server/index.js
File diff suppressed because it is too large
Load Diff
@ -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}`);
|
||||
|
||||
@ -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,9 +282,10 @@ 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) {
|
||||
@ -283,6 +293,8 @@ const connectAccount = async (appId, hasSecret) => {
|
||||
const data = await _serverPost('/fb/exchange', {
|
||||
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 };
|
||||
@ -311,6 +327,8 @@ const connectAccount = async (appId, hasSecret) => {
|
||||
expires_at: result.expires_in ? Date.now() + result.expires_in * 1000 : 0,
|
||||
scope_granted: [],
|
||||
pages,
|
||||
restreamer_channel_id: context.restreamer_channel_id || '',
|
||||
restreamer_publication_id: context.restreamer_publication_id || '',
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
// cache in localStorage (no server persistence)
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -1,110 +1,248 @@
|
||||
/**
|
||||
* 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, ...}] */
|
||||
const listAccounts = () => {
|
||||
const data = _read();
|
||||
return Object.entries(data.accounts || {}).map(([key, val]) => ({ key, ...val }));
|
||||
/** Obtener credenciales desde localStorage (sync) */
|
||||
const getCredentials = () => {
|
||||
const cache = _readCache();
|
||||
return { client_id: cache.client_id || '', client_secret: cache.client_secret || '' };
|
||||
};
|
||||
|
||||
/** Refrescar access_token usando el refresh_token almacenado */
|
||||
/**
|
||||
* 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 = () => {
|
||||
return Object.entries(_readCache().accounts || {}).map(([key, val]) => ({ key, ...val }));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
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 in Settings → Integrations');
|
||||
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',
|
||||
@ -116,14 +254,16 @@ const refreshAccount = async (accountKey) => {
|
||||
grant_type: 'refresh_token',
|
||||
}).toString(),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) throw new Error(data.error_description || data.error);
|
||||
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,
|
||||
// Persistir nuevo access_token
|
||||
await saveAccount(accountKey, {
|
||||
access_token: tokenData.access_token,
|
||||
token_expiry: Date.now() + (tokenData.expires_in || 3600) * 1000,
|
||||
});
|
||||
return data.access_token;
|
||||
return tokenData.access_token;
|
||||
}
|
||||
};
|
||||
|
||||
/** Obtener token válido (refresca automáticamente si está expirado) */
|
||||
@ -135,7 +275,7 @@ const getValidToken = async (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(
|
||||
@ -149,21 +289,30 @@ const fetchChannelInfo = async (accessToken) => {
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@ -805,8 +805,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,7 +818,6 @@ 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 description = data.description || data.video_description || '';
|
||||
@ -826,7 +829,12 @@ function Pull(props) {
|
||||
setExtractorError('No stream_url found in service response.');
|
||||
}
|
||||
} catch (e) {
|
||||
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 +869,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>
|
||||
|
||||
@ -97,8 +97,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) {
|
||||
@ -114,7 +118,12 @@ function Source(props) {
|
||||
setExtractorError('No stream_url found in service response.');
|
||||
}
|
||||
} catch (e) {
|
||||
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 +165,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>
|
||||
|
||||
@ -448,6 +448,8 @@ export default function Edit(props) {
|
||||
metadata={$metadata}
|
||||
streams={$settings.streams}
|
||||
onChange={handleServiceChange}
|
||||
channelId={_channelid}
|
||||
publicationId={id}
|
||||
/>
|
||||
</Grid>
|
||||
</TabContent>
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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
|
||||
@ -225,14 +237,32 @@ function Service(props) {
|
||||
|
||||
if (event.data.code) {
|
||||
try {
|
||||
// Intercambiar code por tokens
|
||||
// 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/json' },
|
||||
body: JSON.stringify({
|
||||
code: event.data.code,
|
||||
redirect_uri: redirectUri,
|
||||
restreamer_channel_id: props.channelId || '',
|
||||
restreamer_publication_id: props.publicationId || '',
|
||||
}),
|
||||
});
|
||||
const exchangeData = await exchangeResp.json();
|
||||
|
||||
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: globalCreds.client_id,
|
||||
client_secret: globalCreds.client_secret,
|
||||
client_id: localCreds.client_id,
|
||||
client_secret: localCreds.client_secret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}).toString(),
|
||||
@ -240,27 +270,42 @@ function Service(props) {
|
||||
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, {
|
||||
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 || '',
|
||||
});
|
||||
|
||||
// Actualizar settings de esta Publication
|
||||
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;
|
||||
|
||||
setApiSuccess('✅ Channel "' + channelName + '" connected successfully! Now click "Create Live Broadcast & get stream key".');
|
||||
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 +342,15 @@ function Service(props) {
|
||||
const channelInfo = await ytOAuth.fetchChannelInfo(token.trim());
|
||||
const accountKey = channelInfo.channel_id || ('yt_manual_' + Date.now());
|
||||
|
||||
ytOAuth.saveAccount(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;
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user