const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const rateLimit = require('express-rate-limit'); const log = require('../lib/logger'); const router = express.Router(); const UPLOADS_DIR = path.join(__dirname, '..', 'uploads'); const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads'); const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB for users const MAX_FILE_SIZE_ADMIN = 1024 * 1024 * 1024; // 1GB for admin function videoFileFilter(req, file, cb) { const valid = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/mpeg', 'video/3gpp', 'video/ogg']; if (valid.includes(file.mimetype) || file.originalname.match(/\.(mp4|webm|mov|avi|mkv|mpeg|mpg|3gp|ogg|flv|wmv)$/i)) { cb(null, true); } else { cb(new Error('Неподдерживаемый формат видео')); } } const uploadUser = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: videoFileFilter }); const uploadAdmin = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE_ADMIN }, fileFilter: videoFileFilter }); // Pick multer based on user role function uploadMiddleware(req, res, next) { const isAdmin = req.session && req.session.user && req.session.user.role === 'admin'; const handler = isAdmin ? uploadAdmin.single('video') : uploadUser.single('video'); handler(req, res, next); } const limiter = rateLimit({ windowMs: 60000, max: 10, message: { error: 'Слишком много запросов' } }); // Active jobs tracking const jobs = new Map(); function runFFmpeg(args, jobId) { return new Promise((resolve, reject) => { const proc = spawn('ffmpeg', args, { timeout: 600000 }); // 10 min timeout let stderrBuf = ''; proc.stderr.on('data', (d) => { const chunk = d.toString(); stderrBuf += chunk; // Parse progress — look for time= or out_time= in chunk const timeMatch = chunk.match(/(?:out_time|time)=\s*(\d{2}):(\d{2}):(\d{2})[\.\d]*/); if (timeMatch && jobs.has(jobId)) { const secs = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]); const job = jobs.get(jobId); if (job.duration > 0) { const pct = Math.min(99, Math.round((secs / job.duration) * 100)); if (pct > job.progress) job.progress = pct; // only increase } } }); proc.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`ffmpeg exited with code ${code}: ${stderrBuf.slice(-500)}`)); }); proc.on('error', reject); }); } // Get video duration function getVideoDuration(filePath) { return new Promise((resolve) => { const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]); let out = ''; proc.stdout.on('data', (d) => { out += d.toString(); }); proc.on('close', () => { try { const info = JSON.parse(out); const duration = parseFloat(info.format?.duration || '0'); const streams = info.streams || []; const video = streams.find(s => s.codec_type === 'video'); const audio = streams.find(s => s.codec_type === 'audio'); resolve({ duration, width: video ? parseInt(video.width) : 0, height: video ? parseInt(video.height) : 0, codec: video ? video.codec_name : '', audioCodec: audio ? audio.codec_name : '', bitrate: info.format?.bit_rate ? Math.round(parseInt(info.format.bit_rate) / 1024) : 0, }); } catch { resolve({ duration: 0, width: 0, height: 0, codec: '', audioCodec: '', bitrate: 0 }); } }); }); } // Page router.get('/', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'video.html')); }); // Upload and get info router.post('/upload', limiter, uploadMiddleware, async (req, res) => { if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); try { const info = await getVideoDuration(req.file.path); const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; jobs.set(jobId, { inputPath: req.file.path, originalName: req.file.originalname, size: req.file.size, duration: info.duration, progress: 0, status: 'ready', outputPath: null, info, }); // Cleanup job after 30 min setTimeout(() => { const job = jobs.get(jobId); if (job) { try { fs.existsSync(job.inputPath) && fs.unlinkSync(job.inputPath); } catch {} try { job.outputPath && fs.existsSync(job.outputPath) && fs.unlinkSync(job.outputPath); } catch {} jobs.delete(jobId); } }, 30 * 60 * 1000); log.info(`Video upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(1)}MB, ${info.duration.toFixed(1)}s)`); res.json({ jobId, info, originalName: req.file.originalname, size: req.file.size }); } catch (err) { try { fs.unlinkSync(req.file.path); } catch {} res.status(500).json({ error: err.message }); } }); // Convert router.post('/convert', express.json(), async (req, res) => { const { jobId, mode, format, quality, startTime, endTime } = req.body; const job = jobs.get(jobId); if (!job) return res.status(404).json({ error: 'Задача не найдена' }); if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' }); job.status = 'processing'; job.progress = 0; const ext = { mp4: '.mp4', webm: '.webm', avi: '.avi', mkv: '.mkv', mp3: '.mp3', aac: '.aac', gif: '.gif' }; const outExt = ext[format] || '.mp4'; const outFile = path.join(DOWNLOADS_DIR, `${jobId}${outExt}`); job.outputPath = outFile; try { let args = ['-y', '-progress', 'pipe:2', '-i', job.inputPath]; // Time range (for GIF or trim) if (startTime !== undefined && startTime > 0) args.push('-ss', String(startTime)); if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime)); switch (mode) { case 'convert': { // Format conversion (software encoders — mpeg4 for mp4/avi/mkv) if (format === 'mp4') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-movflags', '+faststart'); else if (format === 'webm') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-f', 'avi'); // webm fallback to avi else if (format === 'avi') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac'); else if (format === 'mkv') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac'); break; } case 'compress': { // Quality via mpeg4 q:v (1=best, 31=worst) const qv = quality === 'high' ? '3' : quality === 'low' ? '15' : '8'; args.push('-c:v', 'mpeg4', '-q:v', qv, '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart'); break; } case 'audio': { args.push('-vn'); if (format === 'mp3') args.push('-c:a', 'aac', '-b:a', '192k'); else args.push('-c:a', 'aac', '-b:a', '192k'); break; } case 'gif': { // GIF from video const w = Math.min(job.info.width || 480, 480); args.push('-vf', `scale=${w}:-1:flags=lanczos,fps=12`, '-loop', '0'); break; } default: job.status = 'error'; return res.status(400).json({ error: 'Неизвестный режим' }); } args.push(outFile); // Start ffmpeg in background — respond immediately res.json({ status: 'processing' }); runFFmpeg(args, jobId).then(() => { job.status = 'done'; job.progress = 100; const outStat = fs.statSync(outFile); job.outputSize = outStat.size; job.savings = job.size > 0 ? Math.round((1 - outStat.size / job.size) * 100) : 0; job.downloadUrl = `/video/download/${jobId}${outExt}`; log.info(`Video ${mode}: ${job.originalName} → ${format} (${(outStat.size / 1024 / 1024).toFixed(1)}MB, ${job.savings}% saved)`); }).catch(err => { job.status = 'error'; job.error = err.message.slice(0, 200); log.error('Video convert error', { error: err.message }); }); } catch (err) { job.status = 'error'; log.error('Video convert error', { error: err.message }); res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) }); } }); // Progress (returns downloadUrl when done) router.get('/progress/:jobId', (req, res) => { const job = jobs.get(req.params.jobId); if (!job) return res.status(404).json({ error: 'Not found' }); const result = { status: job.status, progress: job.progress }; if (job.status === 'done') { result.downloadUrl = job.downloadUrl; result.outputSize = job.outputSize; result.savings = job.savings; } if (job.status === 'error') { result.error = job.error || 'Ошибка обработки'; } res.json(result); }); // Download result router.get('/download/:filename', (req, res) => { const filename = path.basename(req.params.filename); const filePath = path.join(DOWNLOADS_DIR, filename); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' }); res.download(filePath); }); // Multer error handler router.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { const isAdmin = req.session && req.session.user && req.session.user.role === 'admin'; const limit = isAdmin ? '1 GB' : '200 MB'; return res.status(413).json({ error: 'Файл слишком большой. Максимум ' + limit + '.' }); } return res.status(400).json({ error: err.message }); } if (err) return res.status(400).json({ error: err.message }); next(); }); module.exports = router;