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(` Admin - Build missing

Frontend not built

Run npm run build to build the admin frontend, or run npm run dev during development and open the Vite dev server.

`); }); } // --- 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 ------------------------------------------ `); });