const express = require('express'); const path = require('path'); const fs = require('fs'); const { execFile, spawn } = require('child_process'); const { randomUUID } = require('crypto'); const ffmpegStatic = require('ffmpeg-static'); const YTDlpWrap = require('yt-dlp-wrap').default; const multer = require('multer'); const helmet = require('helmet'); const compression = require('compression'); const { analyze, generateTools } = require('./analyze'); // ── Config ──────────────────────────────────────────────────── const PORT = process.env.PORT || 5050; const IS_WIN = process.platform === 'win32'; const DOWNLOADS_DIR = path.join(__dirname, 'downloads'); const UPLOADS_DIR = path.join(__dirname, 'uploads'); const YTDLP_PATH = process.env.YTDLP_PATH || path.join(__dirname, 'bin', IS_WIN ? 'yt-dlp.exe' : 'yt-dlp'); const JOB_TTL_MS = 60 * 60 * 1000; // 1h: jobs e arquivos temporários expiram [DOWNLOADS_DIR, UPLOADS_DIR, path.join(__dirname, 'bin')].forEach(d => fs.mkdirSync(d, { recursive: true })); // ── App ─────────────────────────────────────────────────────── const app = express(); app.set('trust proxy', 1); app.use(helmet({ contentSecurityPolicy: false, // página usa estilos/scripts inline + fontes/thumbs externas crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false, })); app.use(compression()); app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, 'public'))); const upload = multer({ dest: UPLOADS_DIR, limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2 GB }); // ── In-memory job store (single instance) ───────────────────── const jobs = {}; function newJob() { const id = randomUUID(); jobs[id] = { status: 'queued', text: '', error: '', language: '', segments: [], createdAt: Date.now() }; return id; } function finishJob(id, result) { if (!jobs[id]) return; Object.assign(jobs[id], { status: 'done', text: result.text, language: result.language || '', segments: result.segments || [], }); } function failJob(id, msg) { if (jobs[id]) Object.assign(jobs[id], { status: 'error', error: msg }); console.error(`[job ${id}] erro:`, msg); } // Limpeza periódica de jobs antigos + arquivos órfãos setInterval(() => { const now = Date.now(); for (const [id, job] of Object.entries(jobs)) { if (now - (job.createdAt || 0) > JOB_TTL_MS) delete jobs[id]; } for (const dir of [DOWNLOADS_DIR, UPLOADS_DIR]) { fs.readdir(dir, (err, files) => { if (err) return; files.forEach(f => { const fp = path.join(dir, f); fs.stat(fp, (e, st) => { if (!e && now - st.mtimeMs > JOB_TTL_MS) fs.unlink(fp, () => {}); }); }); }); } }, 15 * 60 * 1000).unref(); // ── yt-dlp / ffmpeg helpers ─────────────────────────────────── let ytdlpReady = null; async function ensureYtDlp() { if (fs.existsSync(YTDLP_PATH)) return; if (!ytdlpReady) { ytdlpReady = (async () => { console.log('Baixando yt-dlp...'); await YTDlpWrap.downloadFromGithub(YTDLP_PATH); if (!IS_WIN) { try { fs.chmodSync(YTDLP_PATH, 0o755); } catch {} } console.log('yt-dlp pronto.'); })(); } return ytdlpReady; } async function getVideoInfo(url) { const ytdlp = new YTDlpWrap(YTDLP_PATH); const raw = await ytdlp.execPromise([url, '--dump-json', '--no-playlist', '--quiet']); const info = JSON.parse(raw); return { title: info.title || '', uploader: info.uploader || info.channel || '', thumbnail: info.thumbnail || '', duration: info.duration || 0, platform: info.extractor_key || '', }; } /** Converte qualquer áudio/vídeo para mp3 mono 16kHz (p/ Whisper) */ function convertToMp3(inputFile, outMp3, deleteInput = false, hq = false) { return new Promise((resolve, reject) => { const args = hq ? ['-i', inputFile, '-vn', '-ar', '44100', '-ac', '2', '-b:a', '192k', '-y', outMp3] : ['-i', inputFile, '-vn', '-ar', '16000', '-ac', '1', '-b:a', '64k', '-y', outMp3]; execFile(ffmpegStatic, args, { stdio: 'pipe' }, (err) => { if (deleteInput) fs.unlink(inputFile, () => {}); if (err) reject(new Error('Erro ao converter áudio: ' + err.message)); else resolve(); }); }); } async function downloadAudioFromUrl(jobId, url, setStatus = () => {}) { const outMp3 = path.join(DOWNLOADS_DIR, `${jobId}.mp3`); const tmpOut = path.join(DOWNLOADS_DIR, `${jobId}.%(ext)s`); const ytdlp = new YTDlpWrap(YTDLP_PATH); await ytdlp.execPromise([url, '--format', 'bestaudio/best', '--output', tmpOut, '--no-playlist', '--quiet']); const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(jobId) && !f.endsWith('.mp3')); if (!files.length) throw new Error('Não foi possível baixar o vídeo. Verifique o link.'); setStatus('extracting'); await convertToMp3(path.join(DOWNLOADS_DIR, files[0]), outMp3, true); return outMp3; } /** Roda o Whisper num processo filho */ function transcribeAudio(mp3Path, modelName, language, task = 'transcribe') { return new Promise((resolve, reject) => { const worker = path.join(__dirname, 'whisper_worker.js'); const child = spawn(process.execPath, [worker, mp3Path, modelName, language, task], { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = '', settled = false; child.stdout.on('data', d => { stdout += d.toString(); }); child.stderr.on('data', d => { const l = d.toString().trim(); if (l) { stderr += l + '\n'; console.log('[worker]', l); } }); child.on('close', code => { if (settled) return; settled = true; if (code !== 0) return reject(new Error(stderr.split('\n').filter(Boolean).pop() || `Worker saiu com código ${code}`)); try { resolve(JSON.parse(stdout.trim())); } catch { reject(new Error('Resposta inválida do transcritor.')); } }); child.on('error', err => { if (!settled) { settled = true; reject(err); } }); }); } // ── Pipelines ───────────────────────────────────────────────── async function runUrlJob(jobId, url, model, language, task) { let mp3 = null; try { jobs[jobId].status = 'downloading_ytdlp'; await ensureYtDlp(); jobs[jobId].status = 'downloading'; mp3 = await downloadAudioFromUrl(jobId, url, s => { jobs[jobId].status = s; }); jobs[jobId].status = 'transcribing'; finishJob(jobId, await transcribeAudio(mp3, model, language, task)); } catch (e) { failJob(jobId, e.message); } finally { if (mp3 && fs.existsSync(mp3)) fs.unlink(mp3, () => {}); } } async function runFileJob(jobId, filePath, model, language, task) { let mp3 = null; try { jobs[jobId].status = 'extracting'; mp3 = path.join(DOWNLOADS_DIR, `${jobId}.mp3`); await convertToMp3(filePath, mp3, true); jobs[jobId].status = 'transcribing'; finishJob(jobId, await transcribeAudio(mp3, model, language, task)); } catch (e) { failJob(jobId, e.message); if (filePath && fs.existsSync(filePath)) fs.unlink(filePath, () => {}); } finally { if (mp3 && fs.existsSync(mp3)) fs.unlink(mp3, () => {}); } } // ── Routes ──────────────────────────────────────────────────── app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'templates', 'index.html'))); app.get('/health', (req, res) => res.json({ ok: true, uptime: process.uptime() })); app.post('/info', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'URL não informada' }); try { await ensureYtDlp(); res.json(await getVideoInfo(url)); } catch (e) { res.status(400).json({ error: 'Não foi possível obter informações do vídeo.' }); } }); app.post('/transcribe', (req, res) => { const { url, model = 'Xenova/whisper-small', language = 'auto', task = 'transcribe' } = req.body; if (!url) return res.status(400).json({ error: 'URL não informada' }); const jobId = newJob(); runUrlJob(jobId, url, model, language, task); res.json({ job_id: jobId }); }); app.post('/transcribe-file', upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado.' }); const { model = 'Xenova/whisper-small', language = 'auto', task = 'transcribe' } = req.body; const jobId = newJob(); runFileJob(jobId, req.file.path, model, language, task); res.json({ job_id: jobId }); }); app.post('/analyze', (req, res) => { const { text, segments, title } = req.body; if (!text) return res.status(400).json({ error: 'Texto não informado' }); try { res.json(analyze(text, segments || [], title || '')); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post('/generate', (req, res) => { const { type, ...params } = req.body; if (!type) return res.status(400).json({ error: 'Tipo de ferramenta não informado' }); try { res.json(generateTools(type, params)); } catch (e) { res.status(400).json({ error: e.message }); } }); app.get('/status/:jobId', (req, res) => { const job = jobs[req.params.jobId]; if (!job) return res.status(404).json({ error: 'Job não encontrado' }); res.json(job); }); /** Stream helper: envia arquivo e remove ao terminar */ function streamAndCleanup(res, filePath, downloadName, contentType) { res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`); res.setHeader('Content-Type', contentType); const stream = fs.createReadStream(filePath); stream.pipe(res); const cleanup = () => fs.unlink(filePath, () => {}); stream.on('close', cleanup); res.on('close', cleanup); } app.post('/download-video', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'URL não informada' }); try { await ensureYtDlp(); const id = randomUUID(); const out = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`); const ytdlp = new YTDlpWrap(YTDLP_PATH); await ytdlp.execPromise([url, '--format', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', '--merge-output-format', 'mp4', '--ffmpeg-location', ffmpegStatic, '--output', out, '--no-playlist', '--quiet']); const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(id)); if (!files.length) return res.status(500).json({ error: 'Falha ao baixar vídeo.' }); streamAndCleanup(res, path.join(DOWNLOADS_DIR, files[0]), 'video.mp4', 'video/mp4'); } catch (e) { res.status(500).json({ error: 'Falha ao baixar vídeo. Pode estar protegido ou indisponível.' }); } }); app.post('/download-audio', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'URL não informada' }); try { await ensureYtDlp(); const id = randomUUID(); const mp3Out = path.join(DOWNLOADS_DIR, `${id}.mp3`); const tmpOut = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`); const ytdlp = new YTDlpWrap(YTDLP_PATH); await ytdlp.execPromise([url, '--format', 'bestaudio/best', '--output', tmpOut, '--no-playlist', '--quiet']); const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(id) && !f.endsWith('.mp3')); if (!files.length) return res.status(500).json({ error: 'Falha ao baixar áudio.' }); await convertToMp3(path.join(DOWNLOADS_DIR, files[0]), mp3Out, true, true); streamAndCleanup(res, mp3Out, 'audio.mp3', 'audio/mpeg'); } catch (e) { res.status(500).json({ error: 'Falha ao baixar áudio.' }); } }); // Handler global de erros (ex.: arquivo grande demais no multer) app.use((err, req, res, next) => { console.error('Erro não tratado:', err.message); if (res.headersSent) return next(err); res.status(500).json({ error: 'Erro interno. Tente novamente.' }); }); // ── Start + graceful shutdown ───────────────────────────────── const server = app.listen(PORT, () => { console.log(`\n✅ CreatorKit rodando em http://localhost:${PORT} (${process.env.NODE_ENV || 'development'})\n`); }); function shutdown(sig) { console.log(`\n${sig} recebido, encerrando...`); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 10000).unref(); } ['SIGTERM', 'SIGINT'].forEach(s => process.on(s, () => shutdown(s))); process.on('unhandledRejection', r => console.error('Rejeição não tratada:', r));