Transcriptor / server.js
CreatorKit
fix: estabilidade em instancias com pouca memoria (ex.: Render free)
f00aab1
Raw
History Blame Contribute Delete
16.3 kB
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));