Transcriptor / server.js
CreatorKit
Initial commit: CreatorKit — plataforma para criadores
8661b79
Raw
History Blame
12.8 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
[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));