opex792 commited on
Commit
3aa3b77
·
verified ·
1 Parent(s): eb7c18d

Upload 5 files

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -1
  2. README.md +29 -46
  3. index.js +96 -139
Dockerfile CHANGED
@@ -1,7 +1,7 @@
1
  FROM node:18-slim
2
 
3
  RUN apt-get update && \
4
- apt-get install -y ffmpeg && \
5
  rm -rf /var/lib/apt/lists/*
6
 
7
  WORKDIR /usr/src/app
 
1
  FROM node:18-slim
2
 
3
  RUN apt-get update && \
4
+ apt-get install -y ffmpeg imagemagick && \
5
  rm -rf /var/lib/apt/lists/*
6
 
7
  WORKDIR /usr/src/app
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- title: Async Command Runner
3
- emoji: 🏃
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
@@ -9,62 +9,45 @@ pinned: false
9
 
10
  # API для выполнения команд
11
 
12
- Это API позволяет удаленно выполнять консольные команды. Поддерживается два режима:
13
-
14
- 1. **Потоковый (Stream)**: Быстрая обработка "на лету" для простых команд.
15
- 2. **Асинхронные задачи (Tasks)**: Для длительных операций, с возможностью отслеживания прогресса.
16
 
17
  ---
18
 
19
- ## Режим 1: Потоковая обработка
20
 
21
- Идеально для быстрых команд. Файл передается в `stdin` команды, а результат из `stdout` возвращается клиенту. Файлы не сохраняются на диске.
22
 
23
  **Эндпоинт:** `POST /api/run/stream`
24
 
25
- **Пример `curl`:**
 
 
 
 
26
  ```bash
 
27
  curl -X POST 'https://<your-space-url>/api/run/stream' \
28
- -F 'file=@/path/to/your/image.jpg' \
29
- -F 'command=magick' \
30
- -F 'args=["convert", "-", "-grayscale", "average", "jpg:-"]' \
31
- --output grayscale_image.jpg
32
-
33
- Режим 2: Асинхронные задачи
34
- Используйте, когда команде нужно работать с файлами на диске или когда результат нужно сохранить.
35
- Шаг 1: Создание задачи
36
- Эндпоинт: POST /api/task/create
37
- Пример curl:
38
- curl -X POST 'https://<your-space-url>/api/task/create' \
39
  -F 'command=ffmpeg' \
40
  -F 'args=["-i", "{INPUT_FILE}", "-vcodec", "libx264", "-acodec", "aac", "{OUTPUT_FILE}"]' \
41
  -F 'file=@"/path/to/your/video.mov"' \
42
- -F 'output_filename=converted.mp4'
43
 
44
- Ответ сервера (статус 202 Accepted):
45
- {
46
- "message": "Task accepted.",
47
- "taskId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
48
- "status_url": "/api/task/status/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
49
- }
50
-
51
- Шаг 2: Проверка статуса задачи
52
- Эндпоинт: GET /api/task/status/:taskId
53
- Отправляйте GET-запросы на status_url, чтобы отслеживать выполнение.
54
- Пример ответа (завершено):
55
- {
56
- "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
57
- "status": "completed",
58
- "elapsedTimeSeconds": 60,
59
- "result": {
60
- "download_url": "/api/download/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy-converted.mp4"
61
- }
62
- }
63
-
64
- Шаг 3: Скачивание результата
65
- Когда задача получает статус completed, используйте download_url для скачивания файла.
66
- Эндпоинт: GET /api/download/:fileId
67
- curl 'https://<your-space-url>/api/download/yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy-converted.mp4' \
68
- --output "final_video.mp4"
69
 
70
 
 
1
  ---
2
+ title: Hybrid Command Runner
3
+ emoji: 🚀
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
 
9
 
10
  # API для выполнения команд
11
 
12
+ Это API позволяет удаленно выполнять консольные команды. Поддерживается два режима, но основной — это **умный стриминг**.
 
 
 
13
 
14
  ---
15
 
16
+ ## Режим 1: "Умный" стриминг (рекомендуемый)
17
 
18
+ Этот эндпоинт ваш основной инструмент. Он автоматически определяет лучший способ выполнения команды.
19
 
20
  **Эндпоинт:** `POST /api/run/stream`
21
 
22
+ ### Использование с FFmpeg (Надежный режим)
23
+
24
+ Если вы вызываете `ffmpeg`, API автоматически сохранит ваш файл на сервере, выполнит команду и вернет вам готовый результат. **Запрос будет длиться ровно столько, сколько идет конвертация.**
25
+
26
+ **Пример `curl` для конвертации видео:**
27
  ```bash
28
+ # Эта команда теперь будет работать через /api/run/stream
29
  curl -X POST 'https://<your-space-url>/api/run/stream' \
 
 
 
 
 
 
 
 
 
 
 
30
  -F 'command=ffmpeg' \
31
  -F 'args=["-i", "{INPUT_FILE}", "-vcodec", "libx264", "-acodec", "aac", "{OUTPUT_FILE}"]' \
32
  -F 'file=@"/path/to/your/video.mov"' \
33
+ --output "converted_video.mp4"
34
 
35
+ Использование с другими командами (Режим реального стриминга)
36
+ Для команд вроде magick (ImageMagick) API работает в режиме реального времени, обрабатывая данные "на лету".
37
+ Пример curl для обработки изображения:
38
+ curl -X POST 'https://<your-space-url>/api/run/stream' \
39
+ -F 'file=@/path/to/your/image.jpg' \
40
+ -F 'command=magick' \
41
+ -F 'args=["-", "-resize", "50%", "jpg:-"]' \
42
+ --output "resized_image.jpg"
43
+
44
+ Режим 2: Асинхронные задачи (для UI или очень долгих операций)
45
+ Этот режим полезен, если вы создаете UI и не хотите, чтобы запрос "висел", или если конвертация занимает очень много времени (например, часы).
46
+ Шаг 1: Создание задачи (POST /api/task/create)
47
+ Мгновенно получаете ID зад��чи.
48
+ Шаг 2: Проверка статуса (GET /api/task/status/:taskId)
49
+ Периодически проверяете, как дела.
50
+ Шаг 3: Скачивание (GET /api/download/:fileId)
51
+ Когда задача готова, скачиваете результат.
 
 
 
 
 
 
 
 
52
 
53
 
index.js CHANGED
@@ -2,7 +2,7 @@ import express from 'express';
2
  import multer from 'multer';
3
  import { spawn } from 'child_process';
4
  import { writeFile, unlink } from 'fs/promises';
5
- import { createReadStream } from 'fs';
6
  import path from 'path';
7
  import { v4 as uuidv4 } from 'uuid';
8
  import fetch from 'node-fetch';
@@ -29,8 +29,7 @@ const downloadFile = async (url) => {
29
  return Buffer.from(arrayBuffer);
30
  };
31
 
32
-
33
- // --- ЭНДПОИНТ ДЛЯ ПОТОКОВОЙ ОБРАБОТКИ (ИСПРАВЛЕН) ---
34
  app.post('/api/run/stream', upload.single('file'), async (req, res) => {
35
  try {
36
  const { command, args: argsJson, file_url } = req.body;
@@ -54,49 +53,91 @@ app.post('/api/run/stream', upload.single('file'), async (req, res) => {
54
  return res.status(400).json({ error: 'A file must be provided via "file" or "file_url".' });
55
  }
56
 
57
- const process = spawn(command, args);
58
-
59
- let stdoutChunks = [];
60
- let stderrChunks = []; // <<< ДОБАВЛЕНО: Сбор ошибок
61
-
62
- process.stdout.on('data', (data) => stdoutChunks.push(data));
63
- process.stderr.on('data', (data) => stderrChunks.push(data)); // <<< ДОБАВЛЕНО: Слушаем stderr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
- process.on('error', (err) => { // <<< ДОБАВЛЕНО: Обработка ошибок запуска
66
- res.status(500).json({
67
- error: 'Failed to start command.',
68
- message: err.message
69
  });
70
- });
71
-
72
- process.on('close', (code) => {
73
- if (code === 0) {
74
- res.setHeader('Content-Type', 'application/octet-stream');
75
- res.send(Buffer.concat(stdoutChunks));
76
- } else {
77
- // <<< ИЗМЕНЕНО: Отправляем подробную ошибку
78
- const stderr = Buffer.concat(stderrChunks).toString('utf8');
79
- res.status(500).json({
80
- error: 'Command execution failed.',
81
- code: code,
82
- stderr: stderr
83
- });
84
- }
85
- });
86
-
87
- process.stdin.write(inputBuffer);
88
- process.stdin.end();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  } catch (error) {
91
- res.status(500).json({
92
- error: 'Server error during stream processing.',
93
- message: error.message
94
- });
95
  }
96
  });
97
 
98
 
99
- // --- СИСТЕМА АСИНХРОННЫХ ЗАДАЧ ---
100
 
101
  const executeTask = async (taskId) => {
102
  const task = tasks[taskId];
@@ -106,29 +147,19 @@ const executeTask = async (taskId) => {
106
  try {
107
  tasks[taskId].status = 'processing';
108
  tasks[taskId].startTime = Date.now();
109
-
110
  const originalName = task.payload.originalName?.replace(/[^a-zA-Z0-9._-]/g, '') || 'input';
111
  const inputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${originalName}`);
112
  const outputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${outputFilename || 'output'}`);
113
-
114
  tempFiles.push(inputFilePath, outputFilePath);
115
  task.outputFilePath = outputFilePath;
116
-
117
- const processedArgs = args.map(arg =>
118
- arg.replace('{INPUT_FILE}', inputFilePath)
119
- .replace('{OUTPUT_FILE}', outputFilePath)
120
- );
121
-
122
  await writeFile(inputFilePath, inputBuffer);
123
-
124
  const process = spawn(command, processedArgs);
125
  let stderrOutput = '';
126
  let totalDuration = 0;
127
-
128
  process.stderr.on('data', (data) => {
129
  const stderrLine = data.toString();
130
  stderrOutput += stderrLine;
131
-
132
  if (!totalDuration) {
133
  const durationMatch = stderrLine.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.\d{2}/);
134
  if (durationMatch) {
@@ -136,23 +167,19 @@ const executeTask = async (taskId) => {
136
  task.estimatedTotalTime = totalDuration;
137
  }
138
  }
139
-
140
  const timeMatch = stderrLine.match(/time=(\d{2}):(\d{2}):(\d{2})\.\d{2}/);
141
  if (timeMatch && totalDuration) {
142
  const currentTime = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
143
  task.progress = Math.min(100, Math.round((currentTime / totalDuration) * 100));
144
  }
145
  });
146
-
147
  await new Promise((resolve, reject) => {
148
  process.on('close', (code) => {
149
  task.endTime = Date.now();
150
  task.stderr = stderrOutput;
151
  if (code === 0) {
152
  task.status = 'completed';
153
- task.result = {
154
- download_url: `/api/download/${path.basename(outputFilePath)}`
155
- };
156
  resolve();
157
  } else {
158
  const error = new Error(`Process exited with code ${code}`);
@@ -162,96 +189,43 @@ const executeTask = async (taskId) => {
162
  });
163
  process.on('error', reject);
164
  });
165
-
166
  } catch (error) {
167
  tasks[taskId].status = 'failed';
168
  tasks[taskId].endTime = Date.now();
169
- tasks[taskId].error = {
170
- message: error.message,
171
- code: error.code
172
- };
173
- for (const filePath of tempFiles) {
174
- unlink(filePath).catch(() => {});
175
- }
176
  }
177
  };
178
 
179
- app.get('/', (req, res) => {
180
- res.send('Task-based remote execution server is ready.');
181
- });
182
 
183
  app.post('/api/task/create', upload.single('file'), async (req, res) => {
184
  try {
185
  const { command, args: argsJson, file_url, output_filename } = req.body;
186
  const file = req.file;
187
-
188
  if (!command) return res.status(400).json({ error: 'Parameter "command" is required.' });
189
-
190
  let args;
191
- try {
192
- args = argsJson ? JSON.parse(argsJson) : [];
193
- } catch(e) {
194
- return res.status(400).json({ error: 'Parameter "args" must be a valid JSON array.' });
195
- }
196
-
197
  let inputBuffer;
198
- if (file) {
199
- inputBuffer = file.buffer;
200
- } else if (file_url) {
201
- inputBuffer = await downloadFile(file_url);
202
- } else {
203
- return res.status(400).json({ error: 'A file must be provided via "file" or "file_url".' });
204
- }
205
-
206
  const taskId = uuidv4();
207
- tasks[taskId] = {
208
- id: taskId,
209
- status: 'queued',
210
- progress: 0,
211
- submittedAt: Date.now(),
212
- payload: {
213
- command,
214
- args,
215
- outputFilename: output_filename,
216
- originalName: file?.originalname,
217
- inputBuffer,
218
- }
219
- };
220
-
221
  executeTask(taskId);
222
-
223
- res.status(202).json({
224
- message: "Task accepted.",
225
- taskId: taskId,
226
- status_url: `/api/task/status/${taskId}`
227
- });
228
-
229
  } catch (error) {
230
- res.status(500).json({
231
- error: 'Failed to create task.',
232
- message: error.message,
233
- });
234
  }
235
  });
236
 
237
  app.get('/api/task/status/:taskId', (req, res) => {
238
  const { taskId } = req.params;
239
  const task = tasks[taskId];
240
-
241
- if (!task) {
242
- return res.status(404).json({ error: 'Task not found.' });
243
- }
244
-
245
- const response = {
246
- id: task.id,
247
- status: task.status,
248
- };
249
-
250
  if (task.startTime) {
251
  const endTime = task.endTime || Date.now();
252
  response.elapsedTimeSeconds = Math.round((endTime - task.startTime) / 1000);
253
  }
254
-
255
  if (task.status === 'processing') {
256
  response.progress = task.progress;
257
  if (task.estimatedTotalTime && response.elapsedTimeSeconds) {
@@ -259,40 +233,23 @@ app.get('/api/task/status/:taskId', (req, res) => {
259
  response.estimatedTimeLeftSeconds = Math.max(0, remaining);
260
  }
261
  }
262
-
263
- if (task.status === 'completed') {
264
- response.result = task.result;
265
- }
266
-
267
- if (task.status === 'failed') {
268
- response.error = task.error;
269
- }
270
-
271
  res.status(200).json(response);
272
  });
273
 
274
  app.get('/api/download/:fileId', (req, res) => {
275
  const { fileId } = req.params;
276
- if (fileId.includes('..')) {
277
- return res.status(400).send('Invalid file ID.');
278
- }
279
-
280
  const filePath = path.join(TEMP_DIR, fileId);
281
-
282
  const stream = createReadStream(filePath);
283
  stream.on('error', (err) => {
284
- if (err.code === 'ENOENT') {
285
- res.status(404).send('File not found or has been cleaned up.');
286
- } else {
287
- res.status(500).send('Server error.');
288
- }
289
  });
290
  res.setHeader('Content-Type', 'application/octet-stream');
291
  stream.pipe(res);
292
  });
293
 
294
- app.listen(PORT, () => {
295
- startCleanupJob();
296
- });
297
 
298
 
 
2
  import multer from 'multer';
3
  import { spawn } from 'child_process';
4
  import { writeFile, unlink } from 'fs/promises';
5
+ import { createReadStream, watch } from 'fs';
6
  import path from 'path';
7
  import { v4 as uuidv4 } from 'uuid';
8
  import fetch from 'node-fetch';
 
29
  return Buffer.from(arrayBuffer);
30
  };
31
 
32
+ // --- "УМНЫЙ" ЭНДПОИНТ С НАСТОЯЩИМ LIVE-СТРИМИНГОМ ДЛЯ FFMPEG ---
 
33
  app.post('/api/run/stream', upload.single('file'), async (req, res) => {
34
  try {
35
  const { command, args: argsJson, file_url } = req.body;
 
53
  return res.status(400).json({ error: 'A file must be provided via "file" or "file_url".' });
54
  }
55
 
56
+ // --- СПЕЦИАЛЬНАЯ ЛОГИКА ДЛЯ FFMPEG (LIVE STREAMING) ---
57
+ if (command === 'ffmpeg') {
58
+ const inputFilePath = path.join(TEMP_DIR, `${uuidv4()}-input`);
59
+ const outputFilePath = path.join(TEMP_DIR, `${uuidv4()}-output`);
60
+
61
+ await writeFile(inputFilePath, inputBuffer);
62
+
63
+ const processedArgs = args.map(arg =>
64
+ arg.replace('{INPUT_FILE}', inputFilePath)
65
+ .replace('{OUTPUT_FILE}', outputFilePath)
66
+ );
67
+
68
+ const ffmpegProcess = spawn(command, processedArgs);
69
+
70
+ // Функция для очистки
71
+ const cleanup = () => {
72
+ ffmpegProcess.kill('SIGKILL'); // Убеждаемся, что процесс завершен
73
+ unlink(inputFilePath).catch(() => {});
74
+ unlink(outputFilePath).catch(() => {});
75
+ };
76
+
77
+ // Как только ffmpeg создаст выходной файл, начинаем его стримить
78
+ const watcher = watch(TEMP_DIR, (eventType, filename) => {
79
+ if (filename === path.basename(outputFilePath)) {
80
+ res.setHeader('Content-Type', 'application/octet-stream');
81
+ const outputStream = createReadStream(outputFilePath);
82
+ outputStream.pipe(res);
83
+ // Прекращаем следить за файлом после того, как он найден
84
+ watcher.close();
85
+ }
86
+ });
87
 
88
+ // Если пользователь закроет соединение, убиваем ffmpeg и чистим файлы
89
+ res.on('close', () => {
90
+ cleanup();
 
91
  });
92
+
93
+ ffmpegProcess.on('close', (code) => {
94
+ watcher.close(); // На всякий случай
95
+ // Если ffmpeg завершился с ошибкой до того, как мы начали стримить
96
+ if (!res.headersSent) {
97
+ res.status(500).json({ error: 'ffmpeg failed before output was generated.', code: code });
98
+ }
99
+ // Файлы будут удалены после завершения стрима (если он начался) или сразу
100
+ setTimeout(cleanup, 100); // Небольшая задержка для завершения потока
101
+ });
102
+
103
+ ffmpegProcess.on('error', (err) => {
104
+ watcher.close();
105
+ if (!res.headersSent) {
106
+ res.status(500).json({ error: 'Failed to start ffmpeg.', message: err.message });
107
+ }
108
+ cleanup();
109
+ });
110
+
111
+ // --- ОБЫЧНАЯ ЛОГИКА ДЛЯ ДРУГИХ КОМАНД ---
112
+ } else {
113
+ const process = spawn(command, args);
114
+ let stdoutChunks = [];
115
+ let stderrChunks = [];
116
+
117
+ process.stdout.on('data', (data) => stdoutChunks.push(data));
118
+ process.stderr.on('data', (data) => stderrChunks.push(data));
119
+
120
+ process.on('close', (code) => {
121
+ if (code === 0) {
122
+ res.setHeader('Content-Type', 'application/octet-stream');
123
+ res.send(Buffer.concat(stdoutChunks));
124
+ } else {
125
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
126
+ res.status(500).json({ error: 'Command execution failed.', code: code, stderr: stderr });
127
+ }
128
+ });
129
+
130
+ process.stdin.write(inputBuffer);
131
+ process.stdin.end();
132
+ }
133
 
134
  } catch (error) {
135
+ res.status(500).json({ error: 'Server error during stream processing.', message: error.message });
 
 
 
136
  }
137
  });
138
 
139
 
140
+ // ... (остальной код для /api/task/* и /api/download/* без изменений) ...
141
 
142
  const executeTask = async (taskId) => {
143
  const task = tasks[taskId];
 
147
  try {
148
  tasks[taskId].status = 'processing';
149
  tasks[taskId].startTime = Date.now();
 
150
  const originalName = task.payload.originalName?.replace(/[^a-zA-Z0-9._-]/g, '') || 'input';
151
  const inputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${originalName}`);
152
  const outputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${outputFilename || 'output'}`);
 
153
  tempFiles.push(inputFilePath, outputFilePath);
154
  task.outputFilePath = outputFilePath;
155
+ const processedArgs = args.map(arg => arg.replace('{INPUT_FILE}', inputFilePath).replace('{OUTPUT_FILE}', outputFilePath));
 
 
 
 
 
156
  await writeFile(inputFilePath, inputBuffer);
 
157
  const process = spawn(command, processedArgs);
158
  let stderrOutput = '';
159
  let totalDuration = 0;
 
160
  process.stderr.on('data', (data) => {
161
  const stderrLine = data.toString();
162
  stderrOutput += stderrLine;
 
163
  if (!totalDuration) {
164
  const durationMatch = stderrLine.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.\d{2}/);
165
  if (durationMatch) {
 
167
  task.estimatedTotalTime = totalDuration;
168
  }
169
  }
 
170
  const timeMatch = stderrLine.match(/time=(\d{2}):(\d{2}):(\d{2})\.\d{2}/);
171
  if (timeMatch && totalDuration) {
172
  const currentTime = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
173
  task.progress = Math.min(100, Math.round((currentTime / totalDuration) * 100));
174
  }
175
  });
 
176
  await new Promise((resolve, reject) => {
177
  process.on('close', (code) => {
178
  task.endTime = Date.now();
179
  task.stderr = stderrOutput;
180
  if (code === 0) {
181
  task.status = 'completed';
182
+ task.result = { download_url: `/api/download/${path.basename(outputFilePath)}` };
 
 
183
  resolve();
184
  } else {
185
  const error = new Error(`Process exited with code ${code}`);
 
189
  });
190
  process.on('error', reject);
191
  });
 
192
  } catch (error) {
193
  tasks[taskId].status = 'failed';
194
  tasks[taskId].endTime = Date.now();
195
+ tasks[taskId].error = { message: error.message, code: error.code };
196
+ for (const filePath of tempFiles) { unlink(filePath).catch(() => {}); }
 
 
 
 
 
197
  }
198
  };
199
 
200
+ app.get('/', (req, res) => { res.send('Task-based remote execution server is ready.'); });
 
 
201
 
202
  app.post('/api/task/create', upload.single('file'), async (req, res) => {
203
  try {
204
  const { command, args: argsJson, file_url, output_filename } = req.body;
205
  const file = req.file;
 
206
  if (!command) return res.status(400).json({ error: 'Parameter "command" is required.' });
 
207
  let args;
208
+ try { args = argsJson ? JSON.parse(argsJson) : []; } catch(e) { return res.status(400).json({ error: 'Parameter "args" must be a valid JSON array.' }); }
 
 
 
 
 
209
  let inputBuffer;
210
+ 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".' }); }
 
 
 
 
 
 
 
211
  const taskId = uuidv4();
212
+ tasks[taskId] = { id: taskId, status: 'queued', progress: 0, submittedAt: Date.now(), payload: { command, args, outputFilename: output_filename, originalName: file?.originalname, inputBuffer, } };
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  executeTask(taskId);
214
+ res.status(202).json({ message: "Task accepted.", taskId: taskId, status_url: `/api/task/status/${taskId}` });
 
 
 
 
 
 
215
  } catch (error) {
216
+ res.status(500).json({ error: 'Failed to create task.', message: error.message });
 
 
 
217
  }
218
  });
219
 
220
  app.get('/api/task/status/:taskId', (req, res) => {
221
  const { taskId } = req.params;
222
  const task = tasks[taskId];
223
+ if (!task) { return res.status(404).json({ error: 'Task not found.' }); }
224
+ const response = { id: task.id, status: task.status };
 
 
 
 
 
 
 
 
225
  if (task.startTime) {
226
  const endTime = task.endTime || Date.now();
227
  response.elapsedTimeSeconds = Math.round((endTime - task.startTime) / 1000);
228
  }
 
229
  if (task.status === 'processing') {
230
  response.progress = task.progress;
231
  if (task.estimatedTotalTime && response.elapsedTimeSeconds) {
 
233
  response.estimatedTimeLeftSeconds = Math.max(0, remaining);
234
  }
235
  }
236
+ if (task.status === 'completed') { response.result = task.result; }
237
+ if (task.status === 'failed') { response.error = task.error; }
 
 
 
 
 
 
 
238
  res.status(200).json(response);
239
  });
240
 
241
  app.get('/api/download/:fileId', (req, res) => {
242
  const { fileId } = req.params;
243
+ if (fileId.includes('..')) { return res.status(400).send('Invalid file ID.'); }
 
 
 
244
  const filePath = path.join(TEMP_DIR, fileId);
 
245
  const stream = createReadStream(filePath);
246
  stream.on('error', (err) => {
247
+ if (err.code === 'ENOENT') { res.status(404).send('File not found or has been cleaned up.'); } else { res.status(500).send('Server error.'); }
 
 
 
 
248
  });
249
  res.setHeader('Content-Type', 'application/octet-stream');
250
  stream.pipe(res);
251
  });
252
 
253
+ app.listen(PORT, () => { startCleanupJob(); });
 
 
254
 
255