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