Spaces:
Running
Running
| 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 | |
| const DL_TIMEOUT_MS = 3 * 60 * 1000; // 3min: timeout do download (yt-dlp) | |
| const TR_TIMEOUT_MS = 10 * 60 * 1000; // 10min: timeout da transcriΓ§Γ£o (Whisper) | |
| // TranscriΓ§Γ£o Γ© pesada de memΓ³ria (Whisper). Em instΓ’ncias pequenas (ex.: Render | |
| // free, 512MB) pode estourar a RAM. Permite desligar o recurso e limitar a 1 por vez. | |
| const ENABLE_TRANSCRIPTION = process.env.ENABLE_TRANSCRIPTION !== 'false'; | |
| const MAX_CONCURRENT = Number(process.env.MAX_CONCURRENT || 1); | |
| let activeJobs = 0; | |
| [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; | |
| } | |
| /** Traduz erros comuns do yt-dlp para mensagens claras */ | |
| function cleanYtDlpError(stderr = '') { | |
| const s = stderr.toLowerCase(); | |
| if (s.includes('login required') || s.includes('log in') || s.includes('rate-limit') || s.includes('cookies')) | |
| return 'Esse vΓdeo exige login (comum em contas privadas ou Instagram). Tente um link pΓΊblico.'; | |
| if (s.includes('private')) return 'Esse vΓdeo Γ© privado e nΓ£o pode ser baixado.'; | |
| if (s.includes('unavailable') || s.includes('not available') || s.includes('removed')) | |
| return 'VΓdeo indisponΓvel ou removido.'; | |
| if (s.includes('unsupported url') || s.includes('no video') || s.includes('unable to extract')) | |
| return 'NΓ£o foi possΓvel ler esse link. Verifique se Γ© um link de vΓdeo vΓ‘lido.'; | |
| if (s.includes('404')) return 'VΓdeo nΓ£o encontrado (404).'; | |
| return 'NΓ£o foi possΓvel baixar o vΓdeo. O link pode estar protegido ou indisponΓvel.'; | |
| } | |
| /** Roda o yt-dlp via spawn com timeout e kill garantido. `capture` retorna o stdout. */ | |
| function runYtDlp(args, { timeoutMs = DL_TIMEOUT_MS, capture = false } = {}) { | |
| return new Promise((resolve, reject) => { | |
| const child = spawn(YTDLP_PATH, args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] }); | |
| let out = '', err = '', settled = false, timedOut = false; | |
| const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, timeoutMs); | |
| if (capture && child.stdout) child.stdout.on('data', d => { out += d.toString(); }); | |
| child.stderr.on('data', d => { err += d.toString(); }); | |
| child.on('close', code => { | |
| if (settled) return; settled = true; clearTimeout(timer); | |
| if (timedOut) return reject(new Error('A operaΓ§Γ£o demorou demais e foi cancelada. Tente um vΓdeo mais curto.')); | |
| if (code !== 0) return reject(new Error(cleanYtDlpError(err))); | |
| resolve(out); | |
| }); | |
| child.on('error', e => { if (!settled) { settled = true; clearTimeout(timer); reject(e); } }); | |
| }); | |
| } | |
| // Flags que fazem o yt-dlp falhar rΓ‘pido em vez de ficar tentando pra sempre | |
| const YTDLP_BASE = ['--no-playlist', '--no-warnings', '--socket-timeout', '20', '--retries', '2', '--extractor-retries', '1']; | |
| async function getVideoInfo(url) { | |
| const raw = await runYtDlp([url, '--dump-json', ...YTDLP_BASE], { capture: true, timeoutMs: 45000 }); | |
| 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`); | |
| await runYtDlp([url, '--format', 'bestaudio/best', '--output', tmpOut, ...YTDLP_BASE]); | |
| 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 Γ‘udio desse vΓdeo.'); | |
| 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, timedOut = false; | |
| const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, TR_TIMEOUT_MS); | |
| 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; clearTimeout(timer); | |
| if (timedOut) return reject(new Error('A transcriΓ§Γ£o demorou demais. Tente um vΓdeo mais curto ou o modelo "Tiny".')); | |
| if (code !== 0) return reject(new Error('Falha ao transcrever o Γ‘udio.')); | |
| try { resolve(JSON.parse(stdout.trim())); } | |
| catch { reject(new Error('Resposta invΓ‘lida do transcritor.')); } | |
| }); | |
| child.on('error', err => { if (!settled) { settled = true; clearTimeout(timer); 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 { activeJobs--; 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 { activeJobs--; if (mp3 && fs.existsSync(mp3)) fs.unlink(mp3, () => {}); } | |
| } | |
| /** Verifica se dΓ‘ pra aceitar uma nova transcriΓ§Γ£o agora */ | |
| function transcriptionGate(res) { | |
| if (!ENABLE_TRANSCRIPTION) { | |
| res.status(503).json({ error: 'A transcriΓ§Γ£o de vΓdeo estΓ‘ desativada neste servidor (memΓ³ria limitada). As outras ferramentas funcionam normalmente β para transcrever, rode o app localmente.' }); | |
| return false; | |
| } | |
| if (activeJobs >= MAX_CONCURRENT) { | |
| res.status(429).json({ error: 'O servidor jΓ‘ estΓ‘ transcrevendo outro vΓdeo. Aguarde alguns instantes e tente novamente.' }); | |
| return false; | |
| } | |
| return true; | |
| } | |
| // ββ 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.get('/config', (req, res) => res.json({ transcription: ENABLE_TRANSCRIPTION })); | |
| 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-tiny', language = 'auto', task = 'transcribe' } = req.body; | |
| if (!url) return res.status(400).json({ error: 'URL nΓ£o informada' }); | |
| if (!transcriptionGate(res)) return; | |
| activeJobs++; | |
| 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.' }); | |
| if (!transcriptionGate(res)) { fs.unlink(req.file.path, () => {}); return; } | |
| activeJobs++; | |
| const { model = 'Xenova/whisper-tiny', 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`); | |
| await runYtDlp([url, | |
| '--format', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', | |
| '--merge-output-format', 'mp4', | |
| '--ffmpeg-location', ffmpegStatic, | |
| '--output', out, ...YTDLP_BASE]); | |
| 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: e.message }); } | |
| }); | |
| 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`); | |
| await runYtDlp([url, '--format', 'bestaudio/best', '--output', tmpOut, ...YTDLP_BASE]); | |
| 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: e.message }); } | |
| }); | |
| // 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)); | |