ffmpeg-api / index.js
opex792's picture
Update index.js
b5bcffb verified
import express from 'express';
import multer from 'multer';
import { spawn } from 'child_process';
import { writeFile, unlink, readFile } from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fetch from 'node-fetch';
import { startCleanupJob } from './cleanup.js';
const TEMP_DIR = '/tmp';
const app = express();
const PORT = process.env.PORT || 7860;
export const tasks = {};
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
const downloadFile = async (url) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
};
// --- "УМНЫЙ" ЭНДПОИНТ ДЛЯ ПОТОКОВОЙ ОБРАБОТКИ ---
app.post('/api/run/stream', upload.single('file'), async (req, res) => {
try {
const { command, args: argsJson, file_url } = req.body;
const file = req.file;
if (!command) return res.status(400).json({ error: 'Parameter "command" is required.' });
let args;
try {
args = argsJson ? JSON.parse(argsJson) : [];
} catch(e) {
return res.status(400).json({ error: 'Parameter "args" must be a valid JSON array.' });
}
let inputBuffer;
if (file) {
inputBuffer = file.buffer;
} else if (file_url) {
inputBuffer = await downloadFile(file_url);
} else {
return res.status(400).json({ error: 'A file must be provided via "file" or "file_url".' });
}
// --- СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ FFMPEG ---
if (command === 'ffmpeg') {
const inputFilePath = path.join(TEMP_DIR, `${uuidv4()}-input`);
const outputFilePath = path.join(TEMP_DIR, `${uuidv4()}-output`);
try {
await writeFile(inputFilePath, inputBuffer);
const processedArgs = args.map(arg =>
arg.replace('{INPUT_FILE}', inputFilePath)
.replace('{OUTPUT_FILE}', outputFilePath)
);
const process = spawn(command, processedArgs);
let stderrChunks = [];
process.stderr.on('data', (data) => stderrChunks.push(data));
process.on('close', async (code) => {
if (code === 0) {
try {
const outputBuffer = await readFile(outputFilePath);
res.setHeader('Content-Type', 'application/octet-stream');
res.send(outputBuffer);
} catch (readError) {
res.status(500).json({ error: "Command succeeded, but failed to read output file.", message: readError.message });
}
} else {
const stderr = Buffer.concat(stderrChunks).toString('utf8');
res.status(500).json({ error: 'Command execution failed.', code: code, stderr: stderr });
}
// Очистка
await unlink(inputFilePath).catch(()=>{});
await unlink(outputFilePath).catch(()=>{});
});
} catch (execError) {
res.status(500).json({ error: "Failed during ffmpeg file operations.", message: execError.message });
await unlink(inputFilePath).catch(()=>{});
await unlink(outputFilePath).catch(()=>{});
}
// --- ОБЫЧНАЯ ЛОГИКА ДЛЯ ДРУГИХ КОМАНД ---
} else {
const process = spawn(command, args);
let stdoutChunks = [];
let stderrChunks = [];
process.stdout.on('data', (data) => stdoutChunks.push(data));
process.stderr.on('data', (data) => stderrChunks.push(data));
process.on('close', (code) => {
if (code === 0) {
res.setHeader('Content-Type', 'application/octet-stream');
res.send(Buffer.concat(stdoutChunks));
} else {
const stderr = Buffer.concat(stderrChunks).toString('utf8');
res.status(500).json({ error: 'Command execution failed.', code: code, stderr: stderr });
}
});
process.stdin.write(inputBuffer);
process.stdin.end();
}
} catch (error) {
res.status(500).json({ error: 'Server error during stream processing.', message: error.message });
}
});
// --- СИСТЕМА АСИНХРОННЫХ ЗАДАЧ (ОСТАЕТСЯ КАК АЛЬТЕРНАТИВА) ---
// ... (остальной код для /api/task/* и /api/download/* без изменений) ...
const executeTask = async (taskId) => {
const task = tasks[taskId];
const { command, args, inputBuffer, outputFilename } = task.payload;
const tempFiles = [];
try {
tasks[taskId].status = 'processing';
tasks[taskId].startTime = Date.now();
const originalName = task.payload.originalName?.replace(/[^a-zA-Z0-9._-]/g, '') || 'input';
const inputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${originalName}`);
const outputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${outputFilename || 'output'}`);
tempFiles.push(inputFilePath, outputFilePath);
task.outputFilePath = outputFilePath;
const processedArgs = args.map(arg => arg.replace('{INPUT_FILE}', inputFilePath).replace('{OUTPUT_FILE}', outputFilePath));
await writeFile(inputFilePath, inputBuffer);
const process = spawn(command, processedArgs);
let stderrOutput = '';
let totalDuration = 0;
process.stderr.on('data', (data) => {
const stderrLine = data.toString();
stderrOutput += stderrLine;
if (!totalDuration) {
const durationMatch = stderrLine.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.\d{2}/);
if (durationMatch) {
totalDuration = parseInt(durationMatch[1]) * 3600 + parseInt(durationMatch[2]) * 60 + parseInt(durationMatch[3]);
task.estimatedTotalTime = totalDuration;
}
}
const timeMatch = stderrLine.match(/time=(\d{2}):(\d{2}):(\d{2})\.\d{2}/);
if (timeMatch && totalDuration) {
const currentTime = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
task.progress = Math.min(100, Math.round((currentTime / totalDuration) * 100));
}
});
await new Promise((resolve, reject) => {
process.on('close', (code) => {
task.endTime = Date.now();
task.stderr = stderrOutput;
if (code === 0) {
task.status = 'completed';
task.result = { download_url: `/api/download/${path.basename(outputFilePath)}` };
resolve();
} else {
const error = new Error(`Process exited with code ${code}`);
error.code = code;
reject(error);
}
});
process.on('error', reject);
});
} catch (error) {
tasks[taskId].status = 'failed';
tasks[taskId].endTime = Date.now();
tasks[taskId].error = { message: error.message, code: error.code };
for (const filePath of tempFiles) { unlink(filePath).catch(() => {}); }
}
};
app.get('/', (req, res) => { res.send('Task-based remote execution server is ready.'); });
app.post('/api/task/create', upload.single('file'), async (req, res) => {
try {
const { command, args: argsJson, file_url, output_filename } = req.body;
const file = req.file;
if (!command) return res.status(400).json({ error: 'Parameter "command" is required.' });
let args;
try { args = argsJson ? JSON.parse(argsJson) : []; } catch(e) { return res.status(400).json({ error: 'Parameter "args" must be a valid JSON array.' }); }
let inputBuffer;
if (file) { inputBuffer = file.buffer; } else if (file_url) { inputBuffer = await downloadFile(file_url); } else { return res.status(400).json({ error: 'A file must be provided via "file" or "file_url".' }); }
const taskId = uuidv4();
tasks[taskId] = { id: taskId, status: 'queued', progress: 0, submittedAt: Date.now(), payload: { command, args, outputFilename: output_filename, originalName: file?.originalname, inputBuffer, } };
executeTask(taskId);
res.status(202).json({ message: "Task accepted.", taskId: taskId, status_url: `/api/task/status/${taskId}` });
} catch (error) {
res.status(500).json({ error: 'Failed to create task.', message: error.message });
}
});
app.get('/api/task/status/:taskId', (req, res) => {
const { taskId } = req.params;
const task = tasks[taskId];
if (!task) { return res.status(404).json({ error: 'Task not found.' }); }
const response = { id: task.id, status: task.status };
if (task.startTime) {
const endTime = task.endTime || Date.now();
response.elapsedTimeSeconds = Math.round((endTime - task.startTime) / 1000);
}
if (task.status === 'processing') {
response.progress = task.progress;
if (task.estimatedTotalTime && response.elapsedTimeSeconds) {
const remaining = task.estimatedTotalTime - response.elapsedTimeSeconds;
response.estimatedTimeLeftSeconds = Math.max(0, remaining);
}
}
if (task.status === 'completed') { response.result = task.result; }
if (task.status === 'failed') { response.error = task.error; }
res.status(200).json(response);
});
app.get('/api/download/:fileId', (req, res) => {
const { fileId } = req.params;
if (fileId.includes('..')) { return res.status(400).send('Invalid file ID.'); }
const filePath = path.join(TEMP_DIR, fileId);
const stream = createReadStream(filePath);
stream.on('error', (err) => {
if (err.code === 'ENOENT') { res.status(404).send('File not found or has been cleaned up.'); } else { res.status(500).send('Server error.'); }
});
res.setHeader('Content-Type', 'application/octet-stream');
stream.pipe(res);
});
app.listen(PORT, () => { startCleanupJob(); });