@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.
@@ -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