452 lines
19 KiB
JavaScript
452 lines
19 KiB
JavaScript
const express = require('express');
|
|
const { spawn } = require('child_process');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const multer = require('multer');
|
|
const app = express();
|
|
const PORT = process.env.PORT || 8282;
|
|
|
|
// CONFIGURACIÓN DE RUTAS
|
|
const NODE_PATH = '/usr/bin/node';
|
|
const COOKIES_FILE = path.resolve(__dirname, 'cookies.txt');
|
|
const COOKIES_PATH = COOKIES_FILE;
|
|
const { warmAndExportCookies, exportCookiesFromBotProfile } = require('./cookiewarmer');
|
|
|
|
// Función para sanear cookies (Evita errores de formato de yt-dlp)
|
|
function sanitizeCookies() {
|
|
if (fs.existsSync(COOKIES_FILE)) {
|
|
let content = fs.readFileSync(COOKIES_FILE, 'utf8');
|
|
if (content.includes('# Netscape HTTP Cookie File')) {
|
|
content = content.substring(content.indexOf('# Netscape HTTP Cookie File'));
|
|
fs.writeFileSync(COOKIES_FILE, content, 'utf8');
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- CONFIGURACIÓN DE MULTER (SUBIDA DE ARCHIVOS) ---
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
const uploadDir = path.resolve(__dirname, 'uploads');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
cb(null, uploadDir);
|
|
},
|
|
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
fileFilter: (req, file, cb) => {
|
|
if (path.extname(file.originalname).toLowerCase() !== '.txt') {
|
|
return cb(new Error('Solo se permiten archivos .txt'), false);
|
|
}
|
|
cb(null, true);
|
|
}
|
|
});
|
|
|
|
app.get('/stream/:id', (req, res) => {
|
|
sanitizeCookies();
|
|
const videoId = req.params.id;
|
|
const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
|
|
// Argumentos para obtener el JSON de metadatos
|
|
const args = [
|
|
'--cookies', COOKIES_FILE,
|
|
'--js-runtime', `node:${NODE_PATH}`,
|
|
'--dump-json',
|
|
'--format', 'bestvideo+bestaudio/best',
|
|
'--extractor-args', 'youtube:player_client=tv,web',
|
|
youtubeUrl
|
|
];
|
|
|
|
const child = spawn('yt-dlp', args);
|
|
let output = '';
|
|
|
|
child.stdout.on('data', (data) => output += data);
|
|
child.on('close', (code) => {
|
|
if (code !== 0) return res.status(500).json({ error: "Error al extraer stream" });
|
|
|
|
try {
|
|
const info = JSON.parse(output);
|
|
const response = {
|
|
video_id: videoId,
|
|
title: info.title,
|
|
description: info.description,
|
|
is_live: info.is_live || false,
|
|
stream_url: info.url,
|
|
hls_variant_url: info.manifest_url || "",
|
|
url_type: info.is_live ? "hls/m3u8" : "direct/mp4",
|
|
youtube_url: youtubeUrl,
|
|
thumbnails: [
|
|
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
|
|
],
|
|
ffmpeg_example: `ffmpeg -re -i "${info.url}" -c copy -f flv rtmp://destino/stream_key`
|
|
};
|
|
return res.json(response);
|
|
} catch (e) {
|
|
res.status(500).json({ error: "Error parseando JSON de yt-dlp" });
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Robust /nostream endpoint: busca en vivo y no-vivo usando la lógica completa (incluye js-runtime fallback) ---
|
|
app.get('/nostream/:id', async (req, res) => {
|
|
sanitizeCookies();
|
|
const videoId = req.params.id;
|
|
const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
|
|
const args = [
|
|
'--cookies', COOKIES_FILE,
|
|
'--no-warnings',
|
|
'--dump-json',
|
|
'--format', 'bestvideo+bestaudio/best',
|
|
'--extractor-args', 'youtube:player_client=tv,web',
|
|
youtubeUrl
|
|
];
|
|
|
|
const runYtDlp = (spawnArgs, timeoutMs = 30000) => new Promise((resolve) => {
|
|
const p = spawn('yt-dlp', spawnArgs);
|
|
let out = '';
|
|
let err = '';
|
|
let killed = false;
|
|
const to = setTimeout(() => { killed = true; try { p.kill('SIGKILL'); } catch(e){} }, timeoutMs);
|
|
p.stdout.on('data', d => out += d);
|
|
p.stderr.on('data', d => err += d);
|
|
p.on('close', (code) => { clearTimeout(to); resolve({ code, out, err, killed }); });
|
|
});
|
|
|
|
const probeFormatUrl = (formatId, timeoutMs = 20000) => new Promise((resolve) => {
|
|
const runOnce = (args, tms) => new Promise((resolveRun) => {
|
|
const p = spawn('yt-dlp', args);
|
|
let out = '';
|
|
let err = '';
|
|
const to = setTimeout(() => { try{ p.kill('SIGKILL'); }catch(e){}; resolveRun({ ok: false, out, err }); }, tms);
|
|
p.stdout.on('data', d => out += d);
|
|
p.stderr.on('data', d => err += d);
|
|
p.on('close', (code) => { clearTimeout(to); if (code !== 0) return resolveRun({ ok: false, out, err }); const line = out.split(/\r?\n/).find(Boolean); resolveRun({ ok: true, url: line || null, out, err }); });
|
|
});
|
|
|
|
const baseArgs = ['--cookies', COOKIES_FILE, '--no-warnings', '--get-url', '--format', formatId, youtubeUrl];
|
|
runOnce(baseArgs, timeoutMs).then(r => {
|
|
if (r.ok && r.url) return resolve(r.url);
|
|
// try forcing JS runtime as a fallback
|
|
const jsArgs = ['--cookies', COOKIES_FILE, '--no-warnings', '--js-runtime', `node:${NODE_PATH}`, '--get-url', '--format', formatId, youtubeUrl];
|
|
runOnce(jsArgs, timeoutMs).then(r2 => {
|
|
if (r2.ok && r2.url) return resolve(r2.url);
|
|
return resolve(null);
|
|
});
|
|
});
|
|
});
|
|
|
|
try {
|
|
let result = await runYtDlp(args, 30000);
|
|
if (result.code !== 0) {
|
|
// retry once without js-runtime hints
|
|
const altArgs = args.filter(a => !a.startsWith('--js-runtime') && !a.startsWith('node:'));
|
|
result = await runYtDlp(altArgs, 30000);
|
|
|
|
// If requested format is not available, retry with a safer 'best' format
|
|
const errText = (result.err || result.out || '').toString();
|
|
if (/Requested format is not available/i.test(errText) || /Some formats may be missing/i.test(errText)) {
|
|
const fmtArgs = altArgs.slice();
|
|
const idx = fmtArgs.findIndex((v, i) => v === '--format');
|
|
if (idx !== -1) {
|
|
fmtArgs[idx+1] = 'best';
|
|
} else {
|
|
// append format best
|
|
fmtArgs.splice(fmtArgs.length-1, 0, '--format', 'best');
|
|
}
|
|
const second = await runYtDlp(fmtArgs, 30000);
|
|
if (second.code === 0) result = second; // accept this successful result
|
|
}
|
|
|
|
// If still failing, try to fetch full info (formats list) and pick a merged format
|
|
if (result.code !== 0) {
|
|
const infoArgs = ['--cookies', COOKIES_FILE, '--no-warnings', '--dump-json', '--extractor-args', 'youtube:player_client=tv,web', youtubeUrl];
|
|
const infoResult = await runYtDlp(infoArgs, 30000);
|
|
if (infoResult.code === 0) {
|
|
try {
|
|
const infoObj = JSON.parse(infoResult.out);
|
|
if (Array.isArray(infoObj.formats)) {
|
|
const merged = infoObj.formats.filter(f => (f.vcodec && f.vcodec !== 'none') && (f.acodec && f.acodec !== 'none'));
|
|
if (merged.length) {
|
|
merged.sort((a,b) => (b.tbr||b.bitrate||b.filesize||b.height||0) - (a.tbr||a.bitrate||a.filesize||a.height||0));
|
|
const chosen = merged[0];
|
|
// prefer format_id, otherwise format
|
|
const fmtId = chosen.format_id || chosen.format;
|
|
if (fmtId) {
|
|
const probed = await probeFormatUrl(fmtId, 20000);
|
|
if (probed) {
|
|
// respond immediately using infoObj and probed url
|
|
const urlType = probed.includes('.m3u8') ? 'hls/m3u8' : 'direct';
|
|
return res.json({
|
|
video_id: videoId,
|
|
title: infoObj.title,
|
|
description: infoObj.description,
|
|
is_live: infoObj.is_live || false,
|
|
stream_url: probed,
|
|
hls_variant_url: infoObj.manifest_url || '',
|
|
url_type: urlType,
|
|
youtube_url: youtubeUrl,
|
|
thumbnails: [
|
|
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
|
|
],
|
|
ffmpeg_example: `ffmpeg -re -i "${probed}" -c copy -f flv rtmp://destino/stream_key`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore parse errors here, we'll fall through to error below
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final fallback: try forcing a JS runtime (node) to solve JS challenges
|
|
if (result.code !== 0) {
|
|
try {
|
|
const jsArgs = args.slice();
|
|
if (!jsArgs.includes('--js-runtime')) {
|
|
jsArgs.splice(jsArgs.length-1, 0, '--js-runtime', `node:${NODE_PATH}`);
|
|
}
|
|
const jsResult = await runYtDlp(jsArgs, 40000);
|
|
if (jsResult.code === 0) result = jsResult;
|
|
} catch (e) {
|
|
// ignore and fall through to error
|
|
}
|
|
}
|
|
}
|
|
if (result.code !== 0) return res.status(500).json({ error: 'Error al extraer stream', details: result.err || result.out });
|
|
|
|
const info = JSON.parse(result.out);
|
|
|
|
function scoreFormat(f) { return (f.tbr || f.bitrate || f.filesize || f.height || 0); }
|
|
|
|
// Prefer HLS (m3u8) with v+a
|
|
let hlsVariant = info.manifest_url || '';
|
|
if (!hlsVariant && Array.isArray(info.formats)) {
|
|
const hlsCandidates = info.formats.filter(f => {
|
|
const hasV = f.vcodec && f.vcodec !== 'none';
|
|
const hasA = f.acodec && f.acodec !== 'none';
|
|
const isHls = (f.ext && f.ext.toLowerCase() === 'm3u8') || (f.protocol && String(f.protocol).toLowerCase().includes('m3u8')) || (f.format_note && String(f.format_note).toLowerCase().includes('hls'));
|
|
return hasV && hasA && isHls && f.url;
|
|
});
|
|
if (hlsCandidates.length) { hlsCandidates.sort((a,b) => scoreFormat(b)-scoreFormat(a)); hlsVariant = hlsCandidates[0].url; }
|
|
}
|
|
|
|
// Best merged format
|
|
let bestMergedFormat = null;
|
|
if (Array.isArray(info.formats)) {
|
|
const merged = info.formats.filter(f => (f.vcodec && f.vcodec !== 'none') && (f.acodec && f.acodec !== 'none'));
|
|
if (merged.length) { merged.sort((a,b) => scoreFormat(b)-scoreFormat(a)); bestMergedFormat = merged[0]; }
|
|
}
|
|
|
|
let streamUrl = '';
|
|
if (hlsVariant) streamUrl = hlsVariant;
|
|
else if (bestMergedFormat && bestMergedFormat.url) streamUrl = bestMergedFormat.url;
|
|
else if (bestMergedFormat && bestMergedFormat.format_id) {
|
|
const probed = await probeFormatUrl(bestMergedFormat.format_id, 20000);
|
|
if (probed) streamUrl = probed;
|
|
}
|
|
if (!streamUrl) streamUrl = info.url || '';
|
|
|
|
const urlType = streamUrl && streamUrl.includes('.m3u8') ? 'hls/m3u8' : 'direct';
|
|
|
|
res.json({
|
|
video_id: videoId,
|
|
title: info.title,
|
|
description: info.description,
|
|
is_live: info.is_live || false,
|
|
stream_url: streamUrl,
|
|
hls_variant_url: hlsVariant || '',
|
|
url_type: urlType,
|
|
youtube_url: youtubeUrl,
|
|
thumbnails: [
|
|
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
|
|
],
|
|
ffmpeg_example: `ffmpeg -re -i "${streamUrl}" -c copy -f flv rtmp://destino/stream_key`
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Error procesando petición', details: err.message });
|
|
}
|
|
});
|
|
|
|
// Diagnostic endpoint: returns full yt-dlp JSON (formats list) for a video id
|
|
app.get('/formats/:id', async (req, res) => {
|
|
const videoId = req.params.id;
|
|
const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
|
|
const run = (args, timeoutMs = 20000) => new Promise((resolve) => {
|
|
const p = spawn('yt-dlp', args);
|
|
let out = '';
|
|
let err = '';
|
|
const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch(e){}; resolve({ code: 124, out, err }); }, timeoutMs);
|
|
p.stdout.on('data', d => out += d);
|
|
p.stderr.on('data', d => err += d);
|
|
p.on('close', (code) => { clearTimeout(to); resolve({ code, out, err }); });
|
|
});
|
|
|
|
// Try several invocations to get diagnostic info
|
|
const attempts = [
|
|
['--cookies', COOKIES_FILE, '--no-warnings', '--dump-json', '--extractor-args', 'youtube:player_client=tv,web', youtubeUrl],
|
|
['--no-warnings', '--dump-json', youtubeUrl],
|
|
['--no-warnings', '--list-formats', youtubeUrl]
|
|
];
|
|
|
|
for (const args of attempts) {
|
|
const r = await run(args, 20000);
|
|
if (r.code === 0) {
|
|
// success
|
|
if (args.includes('--dump-json')) {
|
|
try {
|
|
const info = JSON.parse(r.out);
|
|
return res.json(info);
|
|
} catch (e) {
|
|
return res.status(500).json({ error: 'parse error', details: e.message, raw: r.out.slice(0,2000) });
|
|
}
|
|
}
|
|
// list-formats or other raw output
|
|
return res.json({ raw: r.out });
|
|
}
|
|
// If this attempt failed, capture last stderr for diagnosis and continue
|
|
var lastErr = r.err || r.out || '';
|
|
}
|
|
|
|
return res.status(500).json({ error: 'yt-dlp failed to list formats', details: lastErr });
|
|
});
|
|
|
|
app.get('/transcript/:id', (req, res) => {
|
|
sanitizeCookies();
|
|
const videoId = req.params.id;
|
|
const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
|
|
// Argumentos para extraer subtítulos sin descargar video
|
|
const args = [
|
|
'--cookies', COOKIES_FILE,
|
|
'--js-runtime', `node:${NODE_PATH}`,
|
|
'--write-auto-subs',
|
|
'--skip-download',
|
|
'--print', '%(subtitles)j', // Imprime el JSON de subtítulos
|
|
youtubeUrl
|
|
];
|
|
|
|
const child = spawn('yt-dlp', args);
|
|
let output = '';
|
|
|
|
child.stdout.on('data', (data) => output += data);
|
|
child.on('close', (code) => {
|
|
res.json({
|
|
video_id: videoId,
|
|
thumbnails: [
|
|
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
`https://img.youtube.com/vi/${videoId}/sddefault.jpg`
|
|
],
|
|
info: "Para transcripción completa procesada, use un parser de VTT sobre la URL de subtítulos de YouTube."
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// --- INTERFAZ UX (MODO OSCURO / TAILWIND) ---
|
|
// Serve a built frontend from `dist` when available. During development run the Vite dev server.
|
|
const distPath = path.join(__dirname, 'dist');
|
|
if (fs.existsSync(distPath)) {
|
|
app.use(express.static(distPath));
|
|
app.get('/admin', (req, res) => res.sendFile(path.join(distPath, 'index.html')));
|
|
} else {
|
|
app.get('/admin', (req, res) => {
|
|
res.send(`
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Admin - Build missing</title>
|
|
</head>
|
|
<body style="background:#0f172a;color:#f8fafc;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;">
|
|
<div style="text-align:center;max-width:640px;padding:24px;">
|
|
<h1>Frontend not built</h1>
|
|
<p>Run <code>npm run build</code> to build the admin frontend, or run <code>npm run dev</code> during development and open the Vite dev server.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
}
|
|
|
|
// --- ENDPOINT: SUBIDA Y VALIDACIÓN ---
|
|
app.post('/upload-cookies', upload.single('cookies'), (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ status: "error", message: "No se seleccionó archivo." });
|
|
}
|
|
|
|
const tempPath = req.file.path;
|
|
const content = fs.readFileSync(tempPath, 'utf8');
|
|
|
|
// Validación estricta del formato Netscape
|
|
if (!content.includes('# Netscape HTTP Cookie File')) {
|
|
fs.unlinkSync(tempPath); // Borramos el inválido
|
|
return res.status(400).json({
|
|
status: "error",
|
|
message: "Formato inválido. Debe ser un archivo de cookies Netscape."
|
|
});
|
|
}
|
|
|
|
// Move the validated upload into place atomically
|
|
try {
|
|
fs.copyFileSync(tempPath, COOKIES_PATH);
|
|
fs.unlinkSync(tempPath);
|
|
} catch (mvErr) {
|
|
// If copy or unlink fails, attempt a rename as fallback
|
|
try {
|
|
fs.renameSync(tempPath, COOKIES_PATH);
|
|
} catch (renameErr) {
|
|
return res.status(500).json({ status: "error", message: 'Error al guardar cookies: ' + (mvErr.message || renameErr.message) });
|
|
}
|
|
}
|
|
|
|
res.json({ status: "success", message: "cookies.txt actualizado." });
|
|
} catch (error) {
|
|
res.status(500).json({ status: "error", message: error.message });
|
|
}
|
|
});
|
|
/**
|
|
* Endpoint para forzar la actualización de cookies.txt
|
|
* Uso: http://localhost:8282/refresh-cookies
|
|
*/
|
|
app.get('/refresh-cookies', async (req, res) => {
|
|
try {
|
|
await exportCookiesFromBotProfile();
|
|
res.json({
|
|
status: "success",
|
|
message: "Cookies sincronizadas y descifradas correctamente desde el perfil del bot."
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ status: "error", details: error.message });
|
|
}
|
|
});
|
|
|
|
// --- INICIO DEL SERVIDOR ---
|
|
// Servir un favicon simple (1x1 PNG) para evitar 404 en el navegador
|
|
app.get('/favicon.ico', (req, res) => {
|
|
const img = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=', 'base64');
|
|
res.set('Content-Type', 'image/png');
|
|
res.send(img);
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`
|
|
🚀 API Enlace Directo News activa
|
|
------------------------------------------
|
|
Panel Admin: http://localhost:${PORT}/admin
|
|
------------------------------------------
|
|
`);
|
|
});
|