@hff-ai/media-processor-client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +570 -0
- package/dist/MediaProcessor.d.ts +222 -0
- package/dist/MediaProcessor.d.ts.map +1 -0
- package/dist/MediaProcessor.js +731 -0
- package/dist/MediaProcessor.js.map +1 -0
- package/dist/__tests__/integration/mockMediaProcessor.d.ts +80 -0
- package/dist/__tests__/integration/mockMediaProcessor.d.ts.map +1 -0
- package/dist/__tests__/integration/mockMediaProcessor.js +248 -0
- package/dist/__tests__/integration/mockMediaProcessor.js.map +1 -0
- package/dist/__tests__/setup.d.ts +4 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/__tests__/setup.js +13 -0
- package/dist/__tests__/setup.js.map +1 -0
- package/dist/errors.d.ts +134 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +173 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +453 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +51 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MediaProcessor - Main client class for communicating with the Media Processor service
|
|
4
|
+
*/
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.MediaProcessor = void 0;
|
|
10
|
+
const events_1 = require("events");
|
|
11
|
+
const amqplib_1 = __importDefault(require("amqplib"));
|
|
12
|
+
const axios_1 = __importDefault(require("axios"));
|
|
13
|
+
const uuid_1 = require("uuid");
|
|
14
|
+
const types_1 = require("./types");
|
|
15
|
+
const errors_1 = require("./errors");
|
|
16
|
+
/**
|
|
17
|
+
* Default options for the processor
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_OPTIONS = {
|
|
20
|
+
maxConcurrentJobs: 5,
|
|
21
|
+
defaultUserId: 'anonymous',
|
|
22
|
+
autoReconnect: true,
|
|
23
|
+
reconnectDelay: 5000,
|
|
24
|
+
maxReconnectAttempts: 10,
|
|
25
|
+
requestTimeout: 30000,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* MIME type mappings for common extensions
|
|
29
|
+
*/
|
|
30
|
+
const MIME_TYPES = {
|
|
31
|
+
// Images
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.bmp': 'image/bmp',
|
|
38
|
+
'.tiff': 'image/tiff',
|
|
39
|
+
'.tif': 'image/tiff',
|
|
40
|
+
// Videos
|
|
41
|
+
'.mp4': 'video/mp4',
|
|
42
|
+
'.mov': 'video/quicktime',
|
|
43
|
+
'.avi': 'video/x-msvideo',
|
|
44
|
+
'.mkv': 'video/x-matroska',
|
|
45
|
+
'.webm': 'video/webm',
|
|
46
|
+
'.wmv': 'video/x-ms-wmv',
|
|
47
|
+
'.flv': 'video/x-flv',
|
|
48
|
+
// Archives
|
|
49
|
+
'.zip': 'application/zip',
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* MediaProcessor client class
|
|
53
|
+
*
|
|
54
|
+
* Provides a stream-like interface for communicating with the Media Processor service.
|
|
55
|
+
* Handles AMQP connections, message publishing, and event emission for processing updates.
|
|
56
|
+
*/
|
|
57
|
+
class MediaProcessor extends events_1.EventEmitter {
|
|
58
|
+
/**
|
|
59
|
+
* Create a new MediaProcessor instance
|
|
60
|
+
*
|
|
61
|
+
* @param config - Configuration for the media processor connection
|
|
62
|
+
*/
|
|
63
|
+
constructor(config) {
|
|
64
|
+
super();
|
|
65
|
+
this.connection = null;
|
|
66
|
+
this.channel = null;
|
|
67
|
+
this.consumerTag = null;
|
|
68
|
+
this._isConnected = false;
|
|
69
|
+
this.reconnectAttempts = 0;
|
|
70
|
+
this.reconnectTimer = null;
|
|
71
|
+
this.isShuttingDown = false;
|
|
72
|
+
/** Map of pending async requests, keyed by correlation ID */
|
|
73
|
+
this.pendingRequests = new Map();
|
|
74
|
+
// Validate configuration
|
|
75
|
+
this.validateConfig(config);
|
|
76
|
+
this.config = config;
|
|
77
|
+
this.options = { ...DEFAULT_OPTIONS, ...config.options };
|
|
78
|
+
// Set up queue names based on system
|
|
79
|
+
this.requestQueue = `${config.system}.media.processing.requests`;
|
|
80
|
+
this.updatesQueue = `${config.system}.media.processing.updates`;
|
|
81
|
+
// Create HTTP client
|
|
82
|
+
this.httpClient = axios_1.default.create({
|
|
83
|
+
baseURL: config.processorUrl,
|
|
84
|
+
timeout: this.options.requestTimeout,
|
|
85
|
+
headers: {
|
|
86
|
+
'Authorization': `Bearer ${config.token}`,
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Validate the configuration object
|
|
93
|
+
*/
|
|
94
|
+
validateConfig(config) {
|
|
95
|
+
if (!config.processorUrl) {
|
|
96
|
+
throw errors_1.ConfigurationError.missingField('processorUrl');
|
|
97
|
+
}
|
|
98
|
+
if (!config.processorAmqp) {
|
|
99
|
+
throw errors_1.ConfigurationError.missingField('processorAmqp');
|
|
100
|
+
}
|
|
101
|
+
if (!config.token) {
|
|
102
|
+
throw errors_1.ConfigurationError.missingField('token');
|
|
103
|
+
}
|
|
104
|
+
if (!config.system) {
|
|
105
|
+
throw errors_1.ConfigurationError.missingField('system');
|
|
106
|
+
}
|
|
107
|
+
if (!config.s3Details) {
|
|
108
|
+
throw errors_1.ConfigurationError.missingField('s3Details');
|
|
109
|
+
}
|
|
110
|
+
// Validate S3 config
|
|
111
|
+
const s3 = config.s3Details;
|
|
112
|
+
if (!s3.endpoint) {
|
|
113
|
+
throw errors_1.ConfigurationError.invalidS3Config('missing endpoint');
|
|
114
|
+
}
|
|
115
|
+
if (!s3.accessKey) {
|
|
116
|
+
throw errors_1.ConfigurationError.invalidS3Config('missing accessKey');
|
|
117
|
+
}
|
|
118
|
+
if (!s3.secretAccessKey) {
|
|
119
|
+
throw errors_1.ConfigurationError.invalidS3Config('missing secretAccessKey');
|
|
120
|
+
}
|
|
121
|
+
if (!s3.bucketName) {
|
|
122
|
+
throw errors_1.ConfigurationError.invalidS3Config('missing bucketName');
|
|
123
|
+
}
|
|
124
|
+
// Validate URLs
|
|
125
|
+
try {
|
|
126
|
+
new URL(config.processorUrl);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw errors_1.ConfigurationError.invalidUrl('processorUrl', config.processorUrl);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Connect to the media processor service
|
|
134
|
+
*
|
|
135
|
+
* This will:
|
|
136
|
+
* 1. Set up the system configuration via REST API
|
|
137
|
+
* 2. Establish AMQP connection
|
|
138
|
+
* 3. Start consuming update messages
|
|
139
|
+
*/
|
|
140
|
+
async connect() {
|
|
141
|
+
if (this._isConnected) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.isShuttingDown = false;
|
|
145
|
+
try {
|
|
146
|
+
// Step 1: Set up system via REST API
|
|
147
|
+
await this.setupSystem();
|
|
148
|
+
// Step 2: Connect to AMQP
|
|
149
|
+
await this.connectAmqp();
|
|
150
|
+
// Step 3: Set up consumer for updates
|
|
151
|
+
await this.setupConsumer();
|
|
152
|
+
this._isConnected = true;
|
|
153
|
+
this.reconnectAttempts = 0;
|
|
154
|
+
this.emit('connected');
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
this._isConnected = false;
|
|
158
|
+
if (error instanceof errors_1.ConnectionError || error instanceof errors_1.ConfigurationError) {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
if (axios_1.default.isAxiosError(error)) {
|
|
162
|
+
if (error.response?.status === 401 || error.response?.status === 403) {
|
|
163
|
+
throw errors_1.ConnectionError.authFailed();
|
|
164
|
+
}
|
|
165
|
+
throw errors_1.ConnectionError.restApiError(error.message, error.response?.status, error.response?.data);
|
|
166
|
+
}
|
|
167
|
+
throw errors_1.ConnectionError.connectionFailed('media processor', error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Set up the system configuration via REST API
|
|
172
|
+
*/
|
|
173
|
+
async setupSystem() {
|
|
174
|
+
const systemConfig = {
|
|
175
|
+
bucket: {
|
|
176
|
+
provider: this.config.s3Details.provider,
|
|
177
|
+
endpoint: this.config.s3Details.endpoint,
|
|
178
|
+
access_key: this.config.s3Details.accessKey,
|
|
179
|
+
secret_access_key: this.config.s3Details.secretAccessKey,
|
|
180
|
+
region: this.config.s3Details.region,
|
|
181
|
+
bucket_name: this.config.s3Details.bucketName,
|
|
182
|
+
},
|
|
183
|
+
max_concurrent_jobs: this.options.maxConcurrentJobs,
|
|
184
|
+
};
|
|
185
|
+
await this.httpClient.post(`/api/v1/systems/${this.config.system}`, systemConfig);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Connect to RabbitMQ
|
|
189
|
+
*/
|
|
190
|
+
async connectAmqp() {
|
|
191
|
+
try {
|
|
192
|
+
this.connection = await amqplib_1.default.connect(this.config.processorAmqp);
|
|
193
|
+
// Handle connection events
|
|
194
|
+
this.connection.on('error', (err) => {
|
|
195
|
+
this.handleConnectionError(err);
|
|
196
|
+
});
|
|
197
|
+
this.connection.on('close', () => {
|
|
198
|
+
if (!this.isShuttingDown) {
|
|
199
|
+
this.handleConnectionClose();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
this.channel = await this.connection.createChannel();
|
|
203
|
+
// Ensure queues exist
|
|
204
|
+
await this.channel.assertQueue(this.requestQueue, { durable: true });
|
|
205
|
+
await this.channel.assertQueue(this.updatesQueue, { durable: true });
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
throw errors_1.ConnectionError.amqpError('Failed to connect to RabbitMQ', error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Set up the consumer for update messages
|
|
213
|
+
*/
|
|
214
|
+
async setupConsumer() {
|
|
215
|
+
if (!this.channel) {
|
|
216
|
+
throw errors_1.ConnectionError.amqpError('Channel not available');
|
|
217
|
+
}
|
|
218
|
+
const { consumerTag } = await this.channel.consume(this.updatesQueue, (msg) => this.handleMessage(msg), { noAck: false });
|
|
219
|
+
this.consumerTag = consumerTag;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Handle incoming messages from the updates queue
|
|
223
|
+
*
|
|
224
|
+
* Messages are routed based on their correlationId:
|
|
225
|
+
*
|
|
226
|
+
* 1. Has correlationId AND matches pending request → handle via Promise
|
|
227
|
+
* 2. Has correlationId but NO match → orphaned async message → unhandled_message only
|
|
228
|
+
* 3. No correlationId → queue-based message → emit events
|
|
229
|
+
*
|
|
230
|
+
* This ensures:
|
|
231
|
+
* - Clean separation between async and queue API styles
|
|
232
|
+
* - Orphaned async messages (from dead processes) don't pollute event handlers
|
|
233
|
+
* - Queue messages are properly routed to event listeners
|
|
234
|
+
*/
|
|
235
|
+
handleMessage(msg) {
|
|
236
|
+
if (!msg || !this.channel) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const content = msg.content.toString();
|
|
241
|
+
const update = JSON.parse(content);
|
|
242
|
+
// Extract correlation ID from message details
|
|
243
|
+
const correlationId = this.extractCorrelationId(update);
|
|
244
|
+
// CASE 1 & 2: Message has a correlationId (was created by processS3*() async method)
|
|
245
|
+
if (correlationId) {
|
|
246
|
+
const pending = this.pendingRequests.get(correlationId);
|
|
247
|
+
if (pending) {
|
|
248
|
+
// CASE 1: Matching pending request - handle via Promise
|
|
249
|
+
if ((0, types_1.isProgressMessage)(update)) {
|
|
250
|
+
if (pending.onProgress) {
|
|
251
|
+
pending.onProgress(update);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else if ((0, types_1.isResultMessage)(update)) {
|
|
255
|
+
if (pending.timeoutId) {
|
|
256
|
+
clearTimeout(pending.timeoutId);
|
|
257
|
+
}
|
|
258
|
+
this.pendingRequests.delete(correlationId);
|
|
259
|
+
pending.resolve(update);
|
|
260
|
+
}
|
|
261
|
+
else if ((0, types_1.isErrorMessage)(update)) {
|
|
262
|
+
if (pending.timeoutId) {
|
|
263
|
+
clearTimeout(pending.timeoutId);
|
|
264
|
+
}
|
|
265
|
+
this.pendingRequests.delete(correlationId);
|
|
266
|
+
pending.reject(errors_1.ProcessingError.fromErrorMessage(update));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// CASE 2: Orphaned async message (process that awaited it is gone)
|
|
271
|
+
// Do NOT emit to event handlers - only notify via unhandled_message
|
|
272
|
+
if ((0, types_1.isResultMessage)(update) || (0, types_1.isErrorMessage)(update)) {
|
|
273
|
+
this.emit('unhandled_message', {
|
|
274
|
+
type: (0, types_1.isResultMessage)(update) ? 'completed' : 'error',
|
|
275
|
+
mediaType: update.mediaType,
|
|
276
|
+
mediaId: update.mediaId,
|
|
277
|
+
message: `Orphaned async response (correlationId: ${correlationId}) - awaiting process no longer exists`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
// Progress messages for orphaned requests are silently dropped
|
|
281
|
+
}
|
|
282
|
+
// Acknowledge and return - messages with correlationId never go to event handlers
|
|
283
|
+
this.channel.ack(msg);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// CASE 3: No correlationId - this is a queue-based message (from queueS3*())
|
|
287
|
+
// Route to event handlers
|
|
288
|
+
if ((0, types_1.isProgressMessage)(update)) {
|
|
289
|
+
this.emitProgressEvent(update);
|
|
290
|
+
}
|
|
291
|
+
else if ((0, types_1.isResultMessage)(update)) {
|
|
292
|
+
this.emitResultEvent(update);
|
|
293
|
+
this.warnIfNoListeners(update, 'completed');
|
|
294
|
+
}
|
|
295
|
+
else if ((0, types_1.isErrorMessage)(update)) {
|
|
296
|
+
this.emitErrorEvent(update);
|
|
297
|
+
this.warnIfNoListeners(update, 'error');
|
|
298
|
+
}
|
|
299
|
+
// Acknowledge the message
|
|
300
|
+
this.channel.ack(msg);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
// Reject malformed messages
|
|
304
|
+
this.channel.nack(msg, false, false);
|
|
305
|
+
this.emit('error', new Error(`Failed to process message: ${error.message}`));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Extract correlation ID from an update message
|
|
310
|
+
*/
|
|
311
|
+
extractCorrelationId(update) {
|
|
312
|
+
if ('details' in update && update.details?.correlationId) {
|
|
313
|
+
return update.details.correlationId;
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Warn if there are no listeners for a message type
|
|
319
|
+
* Helps detect orphaned messages that would otherwise be silently lost
|
|
320
|
+
*/
|
|
321
|
+
warnIfNoListeners(update, eventSuffix) {
|
|
322
|
+
const genericEvent = eventSuffix === 'error' ? 'processing_error' : 'completed';
|
|
323
|
+
const specificEvent = `${update.mediaType}_${eventSuffix}`;
|
|
324
|
+
const hasGenericListener = this.listenerCount(genericEvent) > 0;
|
|
325
|
+
const hasSpecificListener = this.listenerCount(specificEvent) > 0;
|
|
326
|
+
if (!hasGenericListener && !hasSpecificListener) {
|
|
327
|
+
// Emit a warning event that users can optionally listen to
|
|
328
|
+
this.emit('unhandled_message', {
|
|
329
|
+
type: eventSuffix,
|
|
330
|
+
mediaType: update.mediaType,
|
|
331
|
+
mediaId: update.mediaId,
|
|
332
|
+
message: `No listeners for '${specificEvent}' or '${genericEvent}' - message will be dropped`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Emit progress events
|
|
338
|
+
*/
|
|
339
|
+
emitProgressEvent(msg) {
|
|
340
|
+
this.emit('progress', msg);
|
|
341
|
+
this.emit(`${msg.mediaType}_progress`, msg);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Emit result events
|
|
345
|
+
*/
|
|
346
|
+
emitResultEvent(msg) {
|
|
347
|
+
this.emit('completed', msg);
|
|
348
|
+
this.emit(`${msg.mediaType}_completed`, msg);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Emit error events
|
|
352
|
+
*/
|
|
353
|
+
emitErrorEvent(msg) {
|
|
354
|
+
this.emit('processing_error', msg);
|
|
355
|
+
this.emit(`${msg.mediaType}_error`, msg);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Handle connection errors
|
|
359
|
+
*/
|
|
360
|
+
handleConnectionError(error) {
|
|
361
|
+
this._isConnected = false;
|
|
362
|
+
this.emit('error', errors_1.ConnectionError.connectionLost(error.message));
|
|
363
|
+
this.scheduleReconnect();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Handle connection close
|
|
367
|
+
*/
|
|
368
|
+
handleConnectionClose() {
|
|
369
|
+
this._isConnected = false;
|
|
370
|
+
this.emit('disconnected', { reason: 'Connection closed' });
|
|
371
|
+
this.scheduleReconnect();
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Schedule a reconnection attempt
|
|
375
|
+
*/
|
|
376
|
+
scheduleReconnect() {
|
|
377
|
+
if (!this.options.autoReconnect || this.isShuttingDown) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (this.options.maxReconnectAttempts > 0 &&
|
|
381
|
+
this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
382
|
+
this.emit('error', errors_1.ConnectionError.reconnectFailed(this.reconnectAttempts));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (this.reconnectTimer) {
|
|
386
|
+
clearTimeout(this.reconnectTimer);
|
|
387
|
+
}
|
|
388
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
389
|
+
this.reconnectAttempts++;
|
|
390
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts });
|
|
391
|
+
try {
|
|
392
|
+
await this.reconnect();
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Reconnect failed, will try again
|
|
396
|
+
this.scheduleReconnect();
|
|
397
|
+
}
|
|
398
|
+
}, this.options.reconnectDelay);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Manually trigger a reconnection
|
|
402
|
+
*/
|
|
403
|
+
async reconnect() {
|
|
404
|
+
// Clean up existing connections
|
|
405
|
+
await this.cleanupConnections();
|
|
406
|
+
// Reconnect
|
|
407
|
+
await this.connect();
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Clean up AMQP connections
|
|
411
|
+
*/
|
|
412
|
+
async cleanupConnections() {
|
|
413
|
+
try {
|
|
414
|
+
if (this.channel && this.consumerTag) {
|
|
415
|
+
await this.channel.cancel(this.consumerTag);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Ignore errors during cleanup
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
if (this.channel) {
|
|
423
|
+
await this.channel.close();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// Ignore errors during cleanup
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
if (this.connection) {
|
|
431
|
+
await this.connection.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Ignore errors during cleanup
|
|
436
|
+
}
|
|
437
|
+
this.channel = null;
|
|
438
|
+
this.connection = null;
|
|
439
|
+
this.consumerTag = null;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Disconnect from the media processor service
|
|
443
|
+
*/
|
|
444
|
+
async disconnect() {
|
|
445
|
+
this.isShuttingDown = true;
|
|
446
|
+
this._isConnected = false;
|
|
447
|
+
if (this.reconnectTimer) {
|
|
448
|
+
clearTimeout(this.reconnectTimer);
|
|
449
|
+
this.reconnectTimer = null;
|
|
450
|
+
}
|
|
451
|
+
// Reject all pending async requests
|
|
452
|
+
this.rejectAllPendingRequests('Connection closed');
|
|
453
|
+
await this.cleanupConnections();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Reject all pending async requests (called on disconnect)
|
|
457
|
+
*/
|
|
458
|
+
rejectAllPendingRequests(reason) {
|
|
459
|
+
for (const [correlationId, pending] of this.pendingRequests) {
|
|
460
|
+
if (pending.timeoutId) {
|
|
461
|
+
clearTimeout(pending.timeoutId);
|
|
462
|
+
}
|
|
463
|
+
pending.reject(new errors_1.ProcessingError(`Processing aborted: ${reason}`, 'DISCONNECTED', correlationId, // Use correlationId as placeholder
|
|
464
|
+
'unknown'));
|
|
465
|
+
}
|
|
466
|
+
this.pendingRequests.clear();
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Check if connected to the media processor
|
|
470
|
+
*/
|
|
471
|
+
isConnected() {
|
|
472
|
+
return this._isConnected;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Update the authentication token
|
|
476
|
+
*/
|
|
477
|
+
updateToken(token) {
|
|
478
|
+
this.config.token = token;
|
|
479
|
+
this.httpClient.defaults.headers['Authorization'] = `Bearer ${token}`;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Get the current system status
|
|
483
|
+
*/
|
|
484
|
+
async getSystemStatus() {
|
|
485
|
+
try {
|
|
486
|
+
const response = await this.httpClient.get(`/api/v1/systems/${this.config.system}`);
|
|
487
|
+
return response.data;
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
if (axios_1.default.isAxiosError(error)) {
|
|
491
|
+
throw errors_1.ConnectionError.restApiError(error.message, error.response?.status, error.response?.data);
|
|
492
|
+
}
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ===========================================================================
|
|
497
|
+
// Queue Methods (fire-and-forget, event-based)
|
|
498
|
+
// ===========================================================================
|
|
499
|
+
/**
|
|
500
|
+
* Queue an image for processing (fire-and-forget)
|
|
501
|
+
*
|
|
502
|
+
* Use this when you want to handle results via events.
|
|
503
|
+
*
|
|
504
|
+
* @param s3Key - S3 object key of the image to process
|
|
505
|
+
* @param options - Optional processing parameters
|
|
506
|
+
* @returns mediaId - Unique identifier for tracking this job
|
|
507
|
+
*/
|
|
508
|
+
queueS3Image(s3Key, options) {
|
|
509
|
+
return this.submitProcessingRequest('image', s3Key, options);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Queue a video for processing (fire-and-forget)
|
|
513
|
+
*
|
|
514
|
+
* Use this when you want to handle results via events.
|
|
515
|
+
*
|
|
516
|
+
* @param s3Key - S3 object key of the video to process
|
|
517
|
+
* @param options - Optional processing parameters
|
|
518
|
+
* @returns mediaId - Unique identifier for tracking this job
|
|
519
|
+
*/
|
|
520
|
+
queueS3Video(s3Key, options) {
|
|
521
|
+
return this.submitProcessingRequest('video', s3Key, options);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Queue a zip archive for processing (fire-and-forget)
|
|
525
|
+
*
|
|
526
|
+
* Use this when you want to handle results via events.
|
|
527
|
+
*
|
|
528
|
+
* @param s3Key - S3 object key of the zip file
|
|
529
|
+
* @param options - Optional processing parameters
|
|
530
|
+
* @returns mediaId - Unique identifier for tracking this job
|
|
531
|
+
*/
|
|
532
|
+
queueS3Zip(s3Key, options) {
|
|
533
|
+
return this.submitProcessingRequest('zip', s3Key, options);
|
|
534
|
+
}
|
|
535
|
+
// ===========================================================================
|
|
536
|
+
// Process Methods (async/await, Promise-based)
|
|
537
|
+
// ===========================================================================
|
|
538
|
+
/**
|
|
539
|
+
* Process an image and wait for the result
|
|
540
|
+
*
|
|
541
|
+
* @param s3Key - S3 object key of the image to process
|
|
542
|
+
* @param options - Optional processing parameters including timeout and progress callback
|
|
543
|
+
* @returns Promise that resolves with the processing result
|
|
544
|
+
* @throws ProcessingError if processing fails
|
|
545
|
+
* @throws ProcessingError with code 'PROCESSING_TIMEOUT' if timeout is exceeded
|
|
546
|
+
*/
|
|
547
|
+
processS3Image(s3Key, options) {
|
|
548
|
+
return this.submitAsyncProcessingRequest('image', s3Key, options);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Process a video and wait for the result
|
|
552
|
+
*
|
|
553
|
+
* @param s3Key - S3 object key of the video to process
|
|
554
|
+
* @param options - Optional processing parameters including timeout and progress callback
|
|
555
|
+
* @returns Promise that resolves with the processing result
|
|
556
|
+
* @throws ProcessingError if processing fails
|
|
557
|
+
* @throws ProcessingError with code 'PROCESSING_TIMEOUT' if timeout is exceeded
|
|
558
|
+
*/
|
|
559
|
+
processS3Video(s3Key, options) {
|
|
560
|
+
return this.submitAsyncProcessingRequest('video', s3Key, options);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Process a zip archive and wait for the result
|
|
564
|
+
*
|
|
565
|
+
* @param s3Key - S3 object key of the zip file
|
|
566
|
+
* @param options - Optional processing parameters including timeout and progress callback
|
|
567
|
+
* @returns Promise that resolves with the processing result
|
|
568
|
+
* @throws ProcessingError if processing fails
|
|
569
|
+
* @throws ProcessingError with code 'PROCESSING_TIMEOUT' if timeout is exceeded
|
|
570
|
+
*/
|
|
571
|
+
processS3Zip(s3Key, options) {
|
|
572
|
+
return this.submitAsyncProcessingRequest('zip', s3Key, options);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Submit an async processing request and return a Promise
|
|
576
|
+
*/
|
|
577
|
+
submitAsyncProcessingRequest(mediaType, s3Key, options) {
|
|
578
|
+
// Generate a unique correlation ID for this request
|
|
579
|
+
const correlationId = this.generateCorrelationId();
|
|
580
|
+
// Create the promise that will be resolved/rejected when result arrives
|
|
581
|
+
return new Promise((resolve, reject) => {
|
|
582
|
+
// Set up the pending request entry
|
|
583
|
+
const pendingRequest = {
|
|
584
|
+
resolve,
|
|
585
|
+
reject,
|
|
586
|
+
onProgress: options?.onProgress,
|
|
587
|
+
};
|
|
588
|
+
// Set up timeout if specified
|
|
589
|
+
if (options?.timeout) {
|
|
590
|
+
pendingRequest.timeoutId = setTimeout(() => {
|
|
591
|
+
this.pendingRequests.delete(correlationId);
|
|
592
|
+
reject(errors_1.ProcessingError.timeout(options.mediaId || correlationId, mediaType, options.timeout));
|
|
593
|
+
}, options.timeout);
|
|
594
|
+
}
|
|
595
|
+
// Store the pending request
|
|
596
|
+
this.pendingRequests.set(correlationId, pendingRequest);
|
|
597
|
+
// Submit the request with correlation ID in metadata
|
|
598
|
+
try {
|
|
599
|
+
this.submitProcessingRequest(mediaType, s3Key, {
|
|
600
|
+
...options,
|
|
601
|
+
metadata: {
|
|
602
|
+
...options?.metadata,
|
|
603
|
+
correlationId,
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
// Clean up on submission failure
|
|
609
|
+
if (pendingRequest.timeoutId) {
|
|
610
|
+
clearTimeout(pendingRequest.timeoutId);
|
|
611
|
+
}
|
|
612
|
+
this.pendingRequests.delete(correlationId);
|
|
613
|
+
reject(error);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Generate a unique correlation ID for request tracking
|
|
619
|
+
*
|
|
620
|
+
* Uses UUID to ensure uniqueness even across process restarts,
|
|
621
|
+
* preventing any possibility of ID collision with orphaned responses.
|
|
622
|
+
*/
|
|
623
|
+
generateCorrelationId() {
|
|
624
|
+
return `req-${(0, uuid_1.v4)()}`;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Submit a processing request
|
|
628
|
+
*/
|
|
629
|
+
submitProcessingRequest(mediaType, s3Key, options) {
|
|
630
|
+
if (!this._isConnected || !this.channel) {
|
|
631
|
+
throw errors_1.RequestError.notConnected();
|
|
632
|
+
}
|
|
633
|
+
if (!s3Key || typeof s3Key !== 'string') {
|
|
634
|
+
throw errors_1.RequestError.invalidS3Key(s3Key, 'S3 key must be a non-empty string');
|
|
635
|
+
}
|
|
636
|
+
const mediaId = options?.mediaId || (0, uuid_1.v4)();
|
|
637
|
+
const userId = options?.userId || this.options.defaultUserId;
|
|
638
|
+
const mimeType = options?.mimeType || this.guessMimeType(s3Key, mediaType);
|
|
639
|
+
const originalFilename = options?.originalFilename || this.extractFilename(s3Key);
|
|
640
|
+
const message = {
|
|
641
|
+
messageType: 'request',
|
|
642
|
+
mediaType,
|
|
643
|
+
mediaId,
|
|
644
|
+
userId,
|
|
645
|
+
timestamp: new Date().toISOString(),
|
|
646
|
+
s3Key,
|
|
647
|
+
originalFilename,
|
|
648
|
+
mimeType,
|
|
649
|
+
fileSize: options?.fileSize || 0,
|
|
650
|
+
metadata: {
|
|
651
|
+
jobId: `job-${mediaId}`,
|
|
652
|
+
...options?.metadata,
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
// Add video-specific options to metadata
|
|
656
|
+
if (mediaType === 'video' && options) {
|
|
657
|
+
const videoOptions = options;
|
|
658
|
+
if (videoOptions.targetQualities) {
|
|
659
|
+
message.metadata = {
|
|
660
|
+
...message.metadata,
|
|
661
|
+
targetQualities: videoOptions.targetQualities,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (videoOptions.thumbnailTimestamps) {
|
|
665
|
+
message.metadata = {
|
|
666
|
+
...message.metadata,
|
|
667
|
+
thumbnailTimestamps: videoOptions.thumbnailTimestamps,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// Add zip-specific options to metadata
|
|
672
|
+
if (mediaType === 'zip' && options) {
|
|
673
|
+
const zipOptions = options;
|
|
674
|
+
message.metadata = {
|
|
675
|
+
...message.metadata,
|
|
676
|
+
processImages: zipOptions.processImages ?? true,
|
|
677
|
+
processVideos: zipOptions.processVideos ?? true,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
const sent = this.channel.sendToQueue(this.requestQueue, Buffer.from(JSON.stringify(message)), { persistent: true });
|
|
682
|
+
if (!sent) {
|
|
683
|
+
throw errors_1.RequestError.queuePublishFailed(this.requestQueue);
|
|
684
|
+
}
|
|
685
|
+
return mediaId;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
if (error instanceof errors_1.RequestError) {
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
throw errors_1.RequestError.queuePublishFailed(this.requestQueue, error);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Guess MIME type from file extension
|
|
696
|
+
*/
|
|
697
|
+
guessMimeType(s3Key, mediaType) {
|
|
698
|
+
const ext = this.extractExtension(s3Key).toLowerCase();
|
|
699
|
+
if (ext && MIME_TYPES[ext]) {
|
|
700
|
+
return MIME_TYPES[ext];
|
|
701
|
+
}
|
|
702
|
+
// Fallback based on media type
|
|
703
|
+
switch (mediaType) {
|
|
704
|
+
case 'image':
|
|
705
|
+
return 'image/jpeg';
|
|
706
|
+
case 'video':
|
|
707
|
+
return 'video/mp4';
|
|
708
|
+
case 'zip':
|
|
709
|
+
return 'application/zip';
|
|
710
|
+
default:
|
|
711
|
+
return 'application/octet-stream';
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Extract filename from S3 key
|
|
716
|
+
*/
|
|
717
|
+
extractFilename(s3Key) {
|
|
718
|
+
const parts = s3Key.split('/');
|
|
719
|
+
return parts[parts.length - 1] || s3Key;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Extract file extension from S3 key
|
|
723
|
+
*/
|
|
724
|
+
extractExtension(s3Key) {
|
|
725
|
+
const filename = this.extractFilename(s3Key);
|
|
726
|
+
const dotIndex = filename.lastIndexOf('.');
|
|
727
|
+
return dotIndex >= 0 ? filename.substring(dotIndex) : '';
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
exports.MediaProcessor = MediaProcessor;
|
|
731
|
+
//# sourceMappingURL=MediaProcessor.js.map
|