UNPKG

@mediacat/mcp

Version:

A Model Context Protocol (MCP) server for MediaCAT's subtitle generation workflow with XL8.ai integration. Supports local file processing, real-time SSE updates, and dynamic language detection.

413 lines 18 kB
import FormData from 'form-data'; import nodeFetch from 'node-fetch'; import fs from 'fs'; import path from 'path'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; const XL8_API_BASE = 'https://api.xl8.ai/v1'; // Cache for supported languages (refreshed periodically) let supportedLanguagesCache = null; let languagesCacheExpiry = 0; const CACHE_DURATION = 3600000; // 1 hour // XL8.ai API Helper Functions async function getSupportedLanguages() { const now = Date.now(); // Return cached languages if still valid if (supportedLanguagesCache && now < languagesCacheExpiry) { return supportedLanguagesCache; } try { const response = await fetch('https://api.xl8.ai/v1/autotemplate/languages'); if (!response.ok) { throw new Error(`Failed to fetch languages: ${response.status}`); } const data = await response.json(); // Handle XL8.ai API response format: {"languages": [...], "status": 0} supportedLanguagesCache = Array.isArray(data.languages) ? data.languages : Array.isArray(data) ? data : []; languagesCacheExpiry = now + CACHE_DURATION; return supportedLanguagesCache || []; } catch (error) { console.warn('Failed to fetch supported languages from XL8.ai, using fallback list:', error); // Fallback to complete XL8.ai language list if API fails const fallbackLanguages = ["en-US", "en-GB", "ko-KR", "ja-JP", "tr-TR", "ar-SA", "bg-BG", "cs-CZ", "da-DK", "de-DE", "el-GR", "es-ES", "es-US", "es-VE", "fi-FI", "fr-FR", "he-IL", "hi-IN", "hr-HR", "hu-HU", "id-ID", "it-IT", "ka-GE", "kk-KZ", "ms-MY", "nl-NL", "no-NO", "pl-PL", "poly", "pt-BR", "pt-PT", "ro-RO", "ru-RU", "sl", "sv-SE", "ta-IN", "th-TH", "tl", "uk-UA", "uz-UZ", "vi-VN", "yue-Hant-HK", "zh", "zh-TW"]; supportedLanguagesCache = fallbackLanguages; languagesCacheExpiry = now + CACHE_DURATION; return fallbackLanguages; } } async function xl8ApiRequest(endpoint, apiKey, options = {}) { if (!apiKey) { throw new Error('XL8.ai API key is required but not provided'); } const url = `${XL8_API_BASE}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { const error = await response.text(); throw new Error(`XL8 API Error: ${response.status} - ${error}`); } return response.json(); } async function getXL8UploadUrl(filename, apiKey) { const response = await xl8ApiRequest('/file/upload', apiKey, { method: 'POST', body: JSON.stringify({ filename }), }); if (response.presigned_url) { return { uploadUrl: response.presigned_url.url, s3Key: response.presigned_url.fields.key, fields: response.presigned_url.fields, rawResponse: response }; } throw new Error('Invalid response from xl8.ai upload API'); } async function uploadFileToS3(fileBuffer, filename, mimeType, presignedData) { const formData = new FormData(); // Add all the required fields from xl8.ai Object.entries(presignedData.fields).forEach(([key, value]) => { formData.append(key, value); }); // Add the file last formData.append('file', fileBuffer, { filename: filename, contentType: mimeType }); const uploadResponse = await nodeFetch(presignedData.uploadUrl, { method: 'POST', body: formData, headers: formData.getHeaders() }); if (!uploadResponse.ok) { throw new Error(`Upload to xl8.ai failed: ${uploadResponse.status}`); } } async function requestXL8Subtitles(s3Key, language, apiKey, transcriptS3Key) { const response = await xl8ApiRequest('/autotemplate/request', apiKey, { method: 'POST', body: JSON.stringify({ language, media_s3_key: s3Key, transcript_s3_key: transcriptS3Key, }), }); return response.request_id; } async function getXL8SubtitleStatus(requestId, apiKey) { return xl8ApiRequest(`/autotemplate/requests/${requestId}`, apiKey); } async function downloadXL8Subtitle(requestId, format, apiKey) { try { const response = await xl8ApiRequest(`/autotemplate/request/file/${format}/${requestId}`, apiKey); // Handle xl8.ai response format with encoded_subtitle if (response.encoded_subtitle && typeof response.encoded_subtitle === 'string') { return Buffer.from(response.encoded_subtitle, 'base64').toString('utf-8'); } // Handle other possible response formats if (response.content && typeof response.content === 'string') { return Buffer.from(response.content, 'base64').toString('utf-8'); } // If response is direct text content if (typeof response === 'string') { return response; } // Check for other subtitle content fields if (response.data || response.subtitle || response.text) { const content = response.data || response.subtitle || response.text; if (typeof content === 'string') { try { return Buffer.from(content, 'base64').toString('utf-8'); } catch { return content; // Already decoded text } } } throw new Error(`Unexpected response format: ${JSON.stringify(response).substring(0, 200)}`); } catch (error) { console.error('Download subtitle error:', error); throw new Error(`Failed to download subtitles from XL8.ai: ${error.message}`); } } export class SyncService { sseManager; syncProgress = new Map(); serverApiKey; constructor(sseManager, serverApiKey) { this.sseManager = sseManager; this.serverApiKey = serverApiKey; } async runSync(options) { const { fileData, filename, mimeType, language, format, transcriptS3Key } = options; const filePath = options.filePath?.trim(); let { apiKey } = options; // Use server API key if no client API key provided if (!apiKey && this.serverApiKey) { apiKey = this.serverApiKey; } if (!apiKey) { throw new McpError(ErrorCode.InvalidParams, 'API key is required. Provide it in the request or configure it on the server.'); } // Validate inputs if (!filename || !mimeType || !language || !format) { throw new McpError(ErrorCode.InvalidParams, 'Required parameters: filename, mimeType, language, format'); } // Validate that either fileData or filePath is provided if (!fileData && !filePath) { throw new McpError(ErrorCode.InvalidParams, 'Either fileData (base64) or filePath (local file) must be provided'); } if (fileData && filePath) { throw new McpError(ErrorCode.InvalidParams, 'Provide either fileData OR filePath, not both'); } // Validate webhook_url is only used in async mode if (options.webhook_url && options.synchronous) { throw new McpError(ErrorCode.InvalidParams, 'webhook_url can only be used when synchronous is false or not set'); } // Validate webhook_url is provided for async mode if (!options.synchronous && !options.webhook_url) { throw new McpError(ErrorCode.InvalidParams, 'webhook_url is required when synchronous is false or not set. Either provide webhook_url for async results or set synchronous=true to wait for results.'); } // Validate language against XL8.ai supported languages const supportedLanguages = await getSupportedLanguages(); if (!supportedLanguages.includes(language)) { throw new McpError(ErrorCode.InvalidParams, `Unsupported language: ${language}. Supported: ${supportedLanguages.join(', ')}`); } // Step 1: Get file buffer (either from base64 data or local file) let fileBuffer; if (fileData) { // Use provided base64 data fileBuffer = Buffer.from(fileData, 'base64'); } else if (filePath) { // Read from local file if (!fs.existsSync(filePath)) { throw new McpError(ErrorCode.InvalidParams, `File not found: "${filePath}"`); } fileBuffer = fs.readFileSync(filePath); } else { throw new McpError(ErrorCode.InvalidParams, 'No file data or file path provided'); } // Step 2: Get presigned URL and upload file const presignedData = await getXL8UploadUrl(filename, apiKey); await uploadFileToS3(fileBuffer, filename, mimeType, presignedData); // Step 2: Create autotemplate task and get xl8RequestId const xl8RequestId = await requestXL8Subtitles(presignedData.s3Key, language, apiKey, transcriptS3Key); // Initialize progress tracking with xl8RequestId and webhook_url const progress = { requestId: xl8RequestId, webhook_url: options.webhook_url, status: 'processing', progress: 40, message: 'Processing video...' }; this.syncProgress.set(xl8RequestId, progress); // Create processing promise that will handle the rest const processingPromise = this.processSync(xl8RequestId, format, apiKey, filename, language, filePath, options.webhook_url); // If synchronous mode requested, wait for completion if (options.synchronous) { const result = await processingPromise; return { requestId: xl8RequestId, result }; } return { requestId: xl8RequestId, processingPromise }; } async processSync(xl8RequestId, format, apiKey, filename, language, originalFilePath, webhook_url) { try { // Poll for completion let xl8Status; let attempts = 0; const maxAttempts = 600; // 100 minutes max while (attempts < maxAttempts) { xl8Status = await getXL8SubtitleStatus(xl8RequestId, apiKey); if (xl8Status.status === 1) { // Success break; } else if (xl8Status.status === 2 || xl8Status.status === 3) { // Error throw new Error(xl8Status.error_message || 'XL8.ai processing error'); } // Update progress based on attempts const progressPercent = 40 + Math.min(50, (attempts / maxAttempts) * 50); this.updateProgress(xl8RequestId, 'processing', progressPercent, 'Processing video...'); this.sseManager.sendToChannel("output", 'sync_progress', {}); // Wait 10 seconds before next poll await new Promise(resolve => setTimeout(resolve, 10000)); attempts++; } if (attempts >= maxAttempts) { throw new Error('Subtitle generation timed out'); } // Download subtitles this.updateProgress(xl8RequestId, 'downloading', 90, 'Downloading subtitles...'); const subtitleContent = await downloadXL8Subtitle(xl8RequestId, format, apiKey); // Save file locally if originalFilePath was provided (local mode) let savedFilePath; if (originalFilePath) { const dir = path.dirname(originalFilePath); const baseName = path.basename(originalFilePath, path.extname(originalFilePath)); savedFilePath = path.join(dir, `${baseName}.${format}`); try { fs.writeFileSync(savedFilePath, subtitleContent, 'utf8'); console.log(`✅ Subtitle file saved locally: ${savedFilePath}`); } catch (error) { console.error(`Failed to save subtitle file locally: ${error}`); savedFilePath = undefined; // Reset if save failed } } // Complete const progress = this.syncProgress.get(xl8RequestId); if (progress) { progress.status = 'completed'; progress.progress = 100; progress.message = 'Sync completed successfully'; progress.result = { format, content: subtitleContent }; this.syncProgress.set(xl8RequestId, progress); } // Send completion event const completionData = { requestId: xl8RequestId, format, filename, language, content: subtitleContent }; // Include file path if saved locally if (savedFilePath) { completionData.savedFilePath = savedFilePath; } console.log(`Sending sync_completed to output channel`); this.sseManager.sendToChannel("output", 'sync_completed', completionData); const result = { success: true, requestId: xl8RequestId, format, content: subtitleContent, filename, language, message: 'Sync completed successfully' }; // Include file path if saved locally if (savedFilePath) { result.savedFilePath = savedFilePath; result.message = `Sync completed successfully. File saved to: ${savedFilePath}`; } // Send webhook notification if URL provided if (webhook_url) { try { const webhookResponse = await nodeFetch(webhook_url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(result), }); if (!webhookResponse.ok) { console.error(`Webhook notification failed: ${webhookResponse.status} ${webhookResponse.statusText}`); } else { console.log(`✅ Webhook notification sent to: ${webhook_url}`); } } catch (webhookError) { console.error(`Failed to send webhook notification: ${webhookError.message}`); // Don't fail the whole operation if webhook fails } } return result; } catch (error) { const progress = this.syncProgress.get(xl8RequestId); if (progress) { progress.status = 'error'; progress.error = error.message; this.syncProgress.set(xl8RequestId, progress); } // Send error event const errorData = { requestId: xl8RequestId, error: error.message }; // Send error to output channel for logging this.sseManager.sendToChannel("output", 'sync_failed', errorData); const errorResult = { success: false, requestId: xl8RequestId, format, filename, language, message: 'Sync failed', error: error.message }; // Send webhook notification on error if URL provided if (webhook_url) { try { const webhookResponse = await nodeFetch(webhook_url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(errorResult), }); if (!webhookResponse.ok) { console.error(`Webhook error notification failed: ${webhookResponse.status} ${webhookResponse.statusText}`); } else { console.log(`✅ Webhook error notification sent to: ${webhook_url}`); } } catch (webhookError) { console.error(`Failed to send webhook error notification: ${webhookError.message}`); // Don't fail the whole operation if webhook fails } } return errorResult; } } updateProgress(requestId, status, progress, message) { const syncProgress = this.syncProgress.get(requestId); if (syncProgress) { syncProgress.status = status; syncProgress.progress = progress; syncProgress.message = message; this.syncProgress.set(requestId, syncProgress); const progressData = { requestId, status, progress, message }; // Send to requestId channel for progress tracking this.sseManager.sendToChannel(requestId, 'sync_progress', progressData); } } getActiveSync(requestId) { return this.syncProgress.get(requestId) || null; } getAllActiveSyncs() { return Array.from(this.syncProgress.values()); } async getAvailableLanguages() { return getSupportedLanguages(); } } //# sourceMappingURL=sync.js.map