media-history-server.js•33.7 kB
/**
* Media History Server
* Enhanced voice assistant server with media storage and MCP browser tools integration
*/
const express = require('express');
const WebSocket = require('ws');
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
const multer = require('multer');
const sqlite3 = require('sqlite3').verbose();
const { open } = require('sqlite');
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
const cors = require('cors');
const dotenv = require('dotenv');
// Load environment variables
dotenv.config();
// Set ffmpeg path
ffmpeg.setFfmpegPath(ffmpegPath);
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// Configuration
const CONFIG = {
port: process.env.PORT || 3000,
wsPort: process.env.WS_PORT || 3001,
mediaDir: process.env.MEDIA_STORAGE_PATH || './media',
dbPath: './media-history.db',
thumbnailSize: { width: 300, height: 200 },
maxStorageGB: parseInt(process.env.MAX_STORAGE_GB) || 5,
projectRoot: process.env.PROJECT_ROOT || process.cwd()
};
// Initialize storage
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const type = file.mimetype.startsWith('video') ? 'recordings' : 'screenshots';
const dir = path.join(CONFIG.mediaDir, type);
await fs.mkdir(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const ext = path.extname(file.originalname);
cb(null, `${timestamp}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit
});
// Database setup
let db;
async function initDatabase() {
db = await open({
filename: CONFIG.dbPath,
driver: sqlite3.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS media_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
filename TEXT NOT NULL,
thumbnail TEXT,
url TEXT,
duration INTEGER,
size INTEGER,
width INTEGER,
height INTEGER,
project TEXT,
context TEXT,
annotations TEXT,
tags TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_time INTEGER NOT NULL,
end_time INTEGER,
project TEXT,
media_count INTEGER DEFAULT 0,
total_duration INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_id INTEGER,
type TEXT,
data TEXT,
created_at INTEGER,
FOREIGN KEY (media_id) REFERENCES media_history(id)
);
CREATE INDEX IF NOT EXISTS idx_media_created ON media_history(created_at);
CREATE INDEX IF NOT EXISTS idx_media_type ON media_history(type);
CREATE INDEX IF NOT EXISTS idx_media_project ON media_history(project);
`);
}
// MCP Client Management
const mcpClients = new Map();
let scsMcpClient = null;
let elevenLabsClient = null;
let browserMcpClient = null;
async function initializeMCPClients() {
try {
// Initialize SCS-MCP client
console.log('Initializing SCS-MCP client...');
const scsTransport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../src/index.js')],
env: { ...process.env, PROJECT_ROOT: CONFIG.projectRoot }
});
scsMcpClient = new Client({
name: 'media-assistant-scs',
version: '1.0.0'
}, {
capabilities: {}
});
await scsMcpClient.connect(scsTransport);
mcpClients.set('scsMcp', scsMcpClient);
console.log('✅ SCS-MCP connected');
// Initialize ElevenLabs MCP client (if API key available)
if (process.env.ELEVEN_LABS_API_KEY) {
console.log('Initializing ElevenLabs MCP client...');
const elevenLabsTransport = new StdioClientTransport({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-elevenlabs'],
env: { ...process.env }
});
elevenLabsClient = new Client({
name: 'media-assistant-elevenlabs',
version: '1.0.0'
}, {
capabilities: {}
});
await elevenLabsClient.connect(elevenLabsTransport);
mcpClients.set('elevenLabs', elevenLabsClient);
console.log('✅ ElevenLabs MCP connected');
}
// Initialize Browser MCP client (Playwright)
if (process.env.ENABLE_BROWSER_MCP !== 'false') {
console.log('Initializing Browser MCP client...');
const browserTransport = new StdioClientTransport({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-playwright'],
env: { ...process.env }
});
browserMcpClient = new Client({
name: 'media-assistant-browser',
version: '1.0.0'
}, {
capabilities: {}
});
await browserMcpClient.connect(browserTransport);
mcpClients.set('browser', browserMcpClient);
console.log('✅ Browser MCP connected');
}
} catch (error) {
console.error('Failed to initialize MCP clients:', error);
// Continue without MCP for development/testing
}
}
// WebSocket Server
const wss = new WebSocket.Server({ port: CONFIG.wsPort });
const connections = new Map();
let currentEditorContext = null;
wss.on('connection', (ws, req) => {
const id = Date.now().toString();
const clientType = req.url === '/vscode' ? 'vscode' : 'web';
connections.set(id, {
ws,
type: clientType,
connected: new Date()
});
console.log(`Client connected: ${clientType} (${id})`);
// Send initial status
ws.send(JSON.stringify({
type: 'status',
data: {
connected: true,
scsMcp: scsMcpClient !== null,
elevenLabs: elevenLabsClient !== null,
browser: browserMcpClient !== null,
clientId: id
}
}));
ws.on('message', async (message) => {
try {
const data = JSON.parse(message.toString());
await handleWebSocketMessage(ws, id, data);
} catch (error) {
console.error('WebSocket message error:', error);
ws.send(JSON.stringify({
type: 'error',
error: error.message
}));
}
});
ws.on('close', () => {
connections.delete(id);
console.log(`Connection closed: ${clientType} (${id})`);
});
});
// WebSocket Message Handler
async function handleWebSocketMessage(ws, clientId, data) {
const client = connections.get(clientId);
if (!client) return;
switch (data.type) {
// Voice commands
case 'voice':
await handleVoiceInput(clientId, data.text, data.context);
break;
case 'context':
if (client.type === 'vscode') {
currentEditorContext = data.data;
broadcastToWebClients({
type: 'context_update',
data: currentEditorContext
});
}
break;
case 'command':
await handleCommand(clientId, data.command, data.context);
break;
case 'tts':
await handleTextToSpeech(clientId, data.text, data.voice);
break;
// Media commands
case 'screenshot':
await handleScreenshot(ws, data);
break;
case 'browser_capture':
await handleBrowserCapture(ws, data);
break;
case 'start_recording':
await handleStartRecording(ws, data);
break;
case 'stop_recording':
await handleStopRecording(ws, data);
break;
case 'get_history':
await sendMediaHistory(ws, data.filters);
break;
case 'annotate':
await handleAnnotation(ws, data);
break;
case 'mcp_tool':
await handleMCPTool(ws, data);
break;
default:
console.log('Unknown message type:', data.type);
}
}
// Voice Input Handler (from original server)
async function handleVoiceInput(clientId, text, additionalContext) {
console.log(`Voice input from ${clientId}: "${text}"`);
const context = {
...currentEditorContext,
...additionalContext,
voiceInput: text
};
// Check for media-related commands
const lowerText = text.toLowerCase();
if (lowerText.includes('screenshot') || lowerText.includes('capture')) {
await handleVoiceScreenshot(clientId, text, context);
return;
}
if (lowerText.includes('start recording') || lowerText.includes('record')) {
await handleVoiceStartRecording(clientId, context);
return;
}
if (lowerText.includes('stop recording')) {
await handleVoiceStopRecording(clientId);
return;
}
if (lowerText.includes('show media') || lowerText.includes('media history')) {
await handleVoiceShowMedia(clientId);
return;
}
// Otherwise handle as normal SCS-MCP command
const intent = await determineIntent(text, context);
const result = await executeScsTool(intent.tool, intent.arguments);
const response = await generateResponse(result, intent);
sendToClient(clientId, {
type: 'response',
data: {
text: response.text,
code: response.code,
action: intent.tool,
context: context
}
});
// Generate audio if ElevenLabs available
if (elevenLabsClient) {
const audio = await generateSpeech(response.text);
if (audio) {
sendToClient(clientId, {
type: 'audio',
data: audio
});
}
}
}
// Voice Media Commands
async function handleVoiceScreenshot(clientId, text, context) {
const ws = connections.get(clientId)?.ws;
if (!ws) return;
await handleScreenshot(ws, {
fullPage: text.includes('full') || text.includes('entire'),
element: context.selectedCode ? 'selection' : null,
project: context.currentFile ? path.basename(path.dirname(context.currentFile)) : 'default',
context: context
});
sendToClient(clientId, {
type: 'response',
data: {
text: 'Screenshot captured successfully',
action: 'screenshot'
}
});
}
async function handleVoiceStartRecording(clientId, context) {
const ws = connections.get(clientId)?.ws;
if (!ws) return;
await handleStartRecording(ws, {
project: context.currentFile ? path.basename(path.dirname(context.currentFile)) : 'default',
context: context
});
sendToClient(clientId, {
type: 'response',
data: {
text: 'Recording started. Say "stop recording" when finished.',
action: 'start_recording'
}
});
}
async function handleVoiceStopRecording(clientId) {
const ws = connections.get(clientId)?.ws;
if (!ws) return;
// Find active recording for this client
const recordingId = Array.from(activeRecordings.keys()).find(
id => activeRecordings.get(id).ws === ws
);
if (recordingId) {
await handleStopRecording(ws, { id: recordingId });
sendToClient(clientId, {
type: 'response',
data: {
text: 'Recording saved successfully',
action: 'stop_recording'
}
});
} else {
sendToClient(clientId, {
type: 'response',
data: {
text: 'No active recording found',
action: 'stop_recording'
}
});
}
}
async function handleVoiceShowMedia(clientId) {
const ws = connections.get(clientId)?.ws;
if (!ws) return;
await sendMediaHistory(ws, {});
sendToClient(clientId, {
type: 'response',
data: {
text: 'Opening media history viewer',
action: 'show_media'
}
});
}
// Screenshot Handler
async function handleScreenshot(ws, data) {
try {
let screenshotData = null;
// Try to use browser MCP if available
if (browserMcpClient) {
try {
const result = await browserMcpClient.callTool('browser_take_screenshot', {
fullPage: data.fullPage || false,
element: data.element,
ref: data.ref
});
screenshotData = result.content[0]?.data;
} catch (error) {
console.log('Browser MCP screenshot failed, using fallback');
}
}
// Fallback: create a placeholder or use system screenshot
if (!screenshotData) {
// Create a placeholder image for testing
const placeholder = await sharp({
create: {
width: 1920,
height: 1080,
channels: 4,
background: { r: 102, g: 126, b: 234, alpha: 1 }
}
})
.png()
.toBuffer();
screenshotData = placeholder.toString('base64');
}
// Save screenshot
const filename = `screenshot-${Date.now()}.png`;
const filepath = path.join(CONFIG.mediaDir, 'screenshots', filename);
const buffer = Buffer.from(screenshotData, 'base64');
await fs.mkdir(path.dirname(filepath), { recursive: true });
await fs.writeFile(filepath, buffer);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filepath, 'screenshot');
// Get image dimensions
const metadata = await sharp(filepath).metadata();
// Save to database
const mediaEntry = await db.run(`
INSERT INTO media_history (
type, filename, thumbnail, url, size, width, height,
project, context, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
'screenshot',
filename,
path.basename(thumbnailPath),
`/media/screenshots/${filename}`,
buffer.length,
metadata.width,
metadata.height,
data.project || 'default',
JSON.stringify(data.context || {}),
Date.now()
]);
ws.send(JSON.stringify({
type: 'screenshot_saved',
data: {
id: mediaEntry.lastID,
url: `/media/screenshots/${filename}`,
thumbnail: `/media/thumbnails/${path.basename(thumbnailPath)}`,
metadata
}
}));
broadcastUpdate('new_media', {
type: 'screenshot',
id: mediaEntry.lastID
});
} catch (error) {
console.error('Screenshot error:', error);
ws.send(JSON.stringify({
type: 'error',
error: 'Failed to capture screenshot'
}));
}
}
// Browser Capture Handler
async function handleBrowserCapture(ws, data) {
try {
if (!browserMcpClient) {
throw new Error('Browser MCP not available');
}
// Navigate to URL if provided
if (data.url) {
await browserMcpClient.callTool('browser_navigate', { url: data.url });
await browserMcpClient.callTool('browser_wait_for', { time: 2 });
}
// Take snapshot
const snapshot = await browserMcpClient.callTool('browser_snapshot', {});
// Take screenshot
const screenshot = await browserMcpClient.callTool('browser_take_screenshot', {
fullPage: true
});
// Save files
const timestamp = Date.now();
const screenshotFile = `browser-${timestamp}.png`;
const snapshotFile = `browser-${timestamp}.json`;
const screenshotPath = path.join(CONFIG.mediaDir, 'screenshots', screenshotFile);
const snapshotPath = path.join(CONFIG.mediaDir, 'snapshots', snapshotFile);
await fs.mkdir(path.join(CONFIG.mediaDir, 'snapshots'), { recursive: true });
const buffer = Buffer.from(screenshot.content[0]?.data || '', 'base64');
await fs.writeFile(screenshotPath, buffer);
await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2));
// Generate thumbnail
const thumbnailPath = await generateThumbnail(screenshotPath, 'browser');
// Save to database
const mediaEntry = await db.run(`
INSERT INTO media_history (
type, filename, thumbnail, url, context, created_at
) VALUES (?, ?, ?, ?, ?, ?)
`, [
'browser',
screenshotFile,
path.basename(thumbnailPath),
data.url || '',
JSON.stringify({
snapshot: snapshotFile,
url: data.url,
...data.context
}),
Date.now()
]);
ws.send(JSON.stringify({
type: 'browser_captured',
data: {
id: mediaEntry.lastID,
screenshot: `/media/screenshots/${screenshotFile}`,
snapshot: `/media/snapshots/${snapshotFile}`,
thumbnail: `/media/thumbnails/${path.basename(thumbnailPath)}`
}
}));
broadcastUpdate('new_media', {
type: 'browser',
id: mediaEntry.lastID
});
} catch (error) {
console.error('Browser capture error:', error);
ws.send(JSON.stringify({
type: 'error',
error: 'Browser capture not available. Install @modelcontextprotocol/server-playwright'
}));
}
}
// Recording Handlers
const activeRecordings = new Map();
async function handleStartRecording(ws, data) {
try {
const recordingId = Date.now().toString();
const filename = `recording-${recordingId}.webm`;
const filepath = path.join(CONFIG.mediaDir, 'recordings', filename);
await fs.mkdir(path.dirname(filepath), { recursive: true });
// Store recording session
activeRecordings.set(recordingId, {
ws,
filename,
filepath,
startTime: Date.now(),
chunks: []
});
// Create session in database
const session = await db.run(`
INSERT INTO sessions (start_time, project) VALUES (?, ?)
`, [Date.now(), data.project || 'default']);
ws.send(JSON.stringify({
type: 'recording_started',
data: {
id: recordingId,
sessionId: session.lastID
}
}));
} catch (error) {
console.error('Start recording error:', error);
ws.send(JSON.stringify({
type: 'error',
error: 'Failed to start recording'
}));
}
}
async function handleStopRecording(ws, data) {
try {
const recording = activeRecordings.get(data.id);
if (!recording) throw new Error('Recording not found');
// For now, create a placeholder video file
// In production, you'd receive video chunks from the client
const placeholderVideo = Buffer.from('placeholder video data');
await fs.writeFile(recording.filepath, placeholderVideo);
// Generate thumbnail (placeholder for now)
const thumbnailPath = await generateVideoThumbnail(recording.filepath);
// Mock metadata
const metadata = {
duration: Math.floor((Date.now() - recording.startTime) / 1000),
size: placeholderVideo.length,
width: 1920,
height: 1080
};
// Save to database
const mediaEntry = await db.run(`
INSERT INTO media_history (
type, filename, thumbnail, url, duration, size,
project, context, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
'recording',
recording.filename,
path.basename(thumbnailPath),
`/media/recordings/${recording.filename}`,
metadata.duration,
metadata.size,
data.project || 'default',
JSON.stringify(data.context || {}),
Date.now()
]);
activeRecordings.delete(data.id);
ws.send(JSON.stringify({
type: 'recording_saved',
data: {
id: mediaEntry.lastID,
url: `/media/recordings/${recording.filename}`,
thumbnail: `/media/thumbnails/${path.basename(thumbnailPath)}`,
duration: metadata.duration
}
}));
broadcastUpdate('new_media', {
type: 'recording',
id: mediaEntry.lastID
});
} catch (error) {
console.error('Stop recording error:', error);
ws.send(JSON.stringify({
type: 'error',
error: 'Failed to save recording'
}));
}
}
// Thumbnail Generation
async function generateThumbnail(imagePath, type) {
const thumbnailDir = path.join(CONFIG.mediaDir, 'thumbnails');
await fs.mkdir(thumbnailDir, { recursive: true });
const filename = path.basename(imagePath, path.extname(imagePath));
const thumbnailPath = path.join(thumbnailDir, `${filename}-thumb.png`);
try {
await sharp(imagePath)
.resize(CONFIG.thumbnailSize.width, CONFIG.thumbnailSize.height, {
fit: 'cover',
position: 'center'
})
.toFile(thumbnailPath);
} catch (error) {
// Create placeholder thumbnail on error
await sharp({
create: {
width: CONFIG.thumbnailSize.width,
height: CONFIG.thumbnailSize.height,
channels: 4,
background: { r: 128, g: 128, b: 128, alpha: 1 }
}
})
.png()
.toFile(thumbnailPath);
}
return thumbnailPath;
}
async function generateVideoThumbnail(videoPath) {
const thumbnailDir = path.join(CONFIG.mediaDir, 'thumbnails');
await fs.mkdir(thumbnailDir, { recursive: true });
const filename = path.basename(videoPath, path.extname(videoPath));
const thumbnailPath = path.join(thumbnailDir, `${filename}-thumb.png`);
// For now, create a placeholder thumbnail
// In production, you'd extract a frame from the video
await sharp({
create: {
width: CONFIG.thumbnailSize.width,
height: CONFIG.thumbnailSize.height,
channels: 4,
background: { r: 100, g: 100, b: 200, alpha: 1 }
}
})
.png()
.toFile(thumbnailPath);
return thumbnailPath;
}
// Annotation Handler
async function handleAnnotation(ws, data) {
try {
const result = await db.run(`
INSERT INTO annotations (media_id, type, data, created_at)
VALUES (?, ?, ?, ?)
`, [data.mediaId, data.type, JSON.stringify(data.data), Date.now()]);
await db.run(`
UPDATE media_history
SET annotations = ?, updated_at = ?
WHERE id = ?
`, [JSON.stringify(data.data), Date.now(), data.mediaId]);
ws.send(JSON.stringify({
type: 'annotation_saved',
data: { id: result.lastID }
}));
broadcastUpdate('annotation_added', {
mediaId: data.mediaId,
annotationId: result.lastID
});
} catch (error) {
console.error('Annotation error:', error);
ws.send(JSON.stringify({
type: 'error',
error: 'Failed to save annotation'
}));
}
}
// Send Media History
async function sendMediaHistory(ws, filters = {}) {
try {
let query = 'SELECT * FROM media_history WHERE 1=1';
const params = [];
if (filters.type && filters.type !== 'all') {
query += ' AND type = ?';
params.push(filters.type);
}
if (filters.project && filters.project !== 'all') {
query += ' AND project = ?';
params.push(filters.project);
}
if (filters.dateFrom) {
query += ' AND created_at >= ?';
params.push(new Date(filters.dateFrom).getTime());
}
if (filters.dateTo) {
query += ' AND created_at <= ?';
params.push(new Date(filters.dateTo).getTime() + 86400000);
}
if (filters.search) {
query += ' AND (filename LIKE ? OR context LIKE ? OR tags LIKE ?)';
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm);
}
query += ' ORDER BY created_at DESC LIMIT 100';
const media = await db.all(query, params);
ws.send(JSON.stringify({
type: 'media_history',
data: media
}));
} catch (error) {
console.error('Get history error:', error);
ws.send(JSON.stringify({
type: 'error',
error: 'Failed to get media history'
}));
}
}
// Helper functions from original voice server
async function determineIntent(text, context) {
const lowerText = text.toLowerCase();
const intents = [
{
patterns: ['review', 'check', 'analyze', 'look at'],
tool: 'instant_review',
extractArgs: (text, ctx) => ({
code: ctx.selectedCode || ctx.currentFunction,
file_path: ctx.currentFile
})
},
{
patterns: ['find similar', 'similar code', 'patterns like'],
tool: 'find_similar',
extractArgs: (text, ctx) => ({
code: ctx.selectedCode || ctx.currentFunction,
limit: 5
})
},
{
patterns: ['explain', 'what does', 'how does', 'tell me about'],
tool: 'analyze_symbol',
extractArgs: (text, ctx) => ({
symbol_name: ctx.currentSymbol || extractSymbolFromText(text),
include_usages: true
})
},
{
patterns: ['model', 'which model', 'capabilities', 'can you'],
tool: 'get_current_model_status',
extractArgs: () => ({})
}
];
for (const intent of intents) {
if (intent.patterns.some(pattern => lowerText.includes(pattern))) {
return {
tool: intent.tool,
arguments: intent.extractArgs(text, context)
};
}
}
return {
tool: 'search',
arguments: { query: text, limit: 10 }
};
}
async function executeScsTool(toolName, args) {
if (!scsMcpClient) {
return {
error: 'SCS-MCP not connected',
fallback: 'I would help you with that request.'
};
}
try {
const result = await scsMcpClient.callTool(toolName, args);
return result;
} catch (error) {
return {
error: error.message,
fallback: 'I encountered an error processing your request.'
};
}
}
async function generateResponse(result, intent) {
if (result.error) {
return {
text: `I encountered an error: ${result.error}. ${result.fallback || ''}`,
code: null
};
}
const content = result.content?.[0]?.text || JSON.stringify(result);
return {
text: content.split('\n').slice(0, 3).join(' '),
code: content
};
}
async function generateSpeech(text, voice = 'rachel') {
if (!elevenLabsClient) return null;
try {
const result = await elevenLabsClient.callTool('text_to_speech', {
text: text,
voice_name: voice,
model_id: 'eleven_turbo_v2',
output_format: 'mp3_44100_128'
});
return result.content?.[0]?.data;
} catch (error) {
console.error('Speech generation failed:', error);
return null;
}
}
async function handleCommand(clientId, command, context) {
await handleVoiceInput(clientId, command, context);
}
async function handleTextToSpeech(clientId, text, voice) {
const audio = await generateSpeech(text, voice);
if (audio) {
sendToClient(clientId, {
type: 'audio',
data: audio
});
}
}
async function handleMCPTool(ws, data) {
try {
const client = mcpClients.get(data.server || 'browser');
if (!client) throw new Error('MCP server not connected');
const result = await client.callTool(data.tool, data.params);
ws.send(JSON.stringify({
type: 'mcp_result',
data: result
}));
} catch (error) {
console.error('MCP tool error:', error);
ws.send(JSON.stringify({
type: 'error',
error: `MCP tool failed: ${error.message}`
}));
}
}
function extractSymbolFromText(text) {
const match = text.match(/(?:the|this)\s+(\w+)\s+(?:function|method|class|variable)/);
return match ? match[1] : null;
}
function sendToClient(clientId, message) {
const client = connections.get(clientId);
if (client && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(message));
}
}
function broadcastToWebClients(message) {
connections.forEach((client) => {
if (client.type === 'web' && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(message));
}
});
}
function broadcastUpdate(type, data) {
const message = JSON.stringify({ type, data });
connections.forEach((client) => {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(message);
}
});
}
// REST API Endpoints
// Get media history
app.get('/api/media', async (req, res) => {
try {
const { type, project, from, to, search, limit = 100, offset = 0 } = req.query;
let query = 'SELECT * FROM media_history WHERE 1=1';
const params = [];
if (type) {
query += ' AND type = ?';
params.push(type);
}
if (project) {
query += ' AND project = ?';
params.push(project);
}
if (from) {
query += ' AND created_at >= ?';
params.push(parseInt(from));
}
if (to) {
query += ' AND created_at <= ?';
params.push(parseInt(to));
}
if (search) {
query += ' AND (filename LIKE ? OR context LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(parseInt(limit), parseInt(offset));
const media = await db.all(query, params);
res.json(media);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get sessions
app.get('/api/sessions', async (req, res) => {
try {
const sessions = await db.all(`
SELECT * FROM sessions
ORDER BY start_time DESC
LIMIT 50
`);
res.json(sessions);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get analytics
app.get('/api/analytics', async (req, res) => {
try {
const stats = await db.get(`
SELECT
COUNT(*) as total_media,
COUNT(CASE WHEN type = 'screenshot' THEN 1 END) as screenshots,
COUNT(CASE WHEN type = 'recording' THEN 1 END) as recordings,
COUNT(CASE WHEN type = 'browser' THEN 1 END) as browser_captures,
SUM(size) as total_size
FROM media_history
WHERE created_at > ?
`, [Date.now() - 7 * 24 * 60 * 60 * 1000]);
res.json({
stats,
storageLimit: CONFIG.maxStorageGB * 1024 * 1024 * 1024
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Upload recording
app.post('/api/recordings', upload.single('recording'), async (req, res) => {
try {
const { context } = req.body;
const file = req.file;
// Generate thumbnail
const thumbnailPath = await generateVideoThumbnail(file.path);
// Mock metadata
const metadata = {
duration: 60,
size: file.size,
width: 1920,
height: 1080
};
// Save to database
const result = await db.run(`
INSERT INTO media_history (
type, filename, thumbnail, url, duration, size,
context, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
'recording',
file.filename,
path.basename(thumbnailPath),
`/media/recordings/${file.filename}`,
metadata.duration,
metadata.size,
context,
Date.now()
]);
res.json({
id: result.lastID,
url: `/media/recordings/${file.filename}`,
thumbnail: `/media/thumbnails/${path.basename(thumbnailPath)}`
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete media
app.delete('/api/media/:id', async (req, res) => {
try {
const { id } = req.params;
const media = await db.get('SELECT * FROM media_history WHERE id = ?', [id]);
if (!media) {
return res.status(404).json({ error: 'Media not found' });
}
// Delete files
const mediaPath = path.join(CONFIG.mediaDir, media.type + 's', media.filename);
const thumbnailPath = path.join(CONFIG.mediaDir, 'thumbnails', media.thumbnail);
await fs.unlink(mediaPath).catch(() => {});
await fs.unlink(thumbnailPath).catch(() => {});
// Delete from database
await db.run('DELETE FROM media_history WHERE id = ?', [id]);
await db.run('DELETE FROM annotations WHERE media_id = ?', [id]);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Serve media files
app.use('/media', express.static(CONFIG.mediaDir));
// Storage cleanup
async function cleanupOldMedia() {
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
const oldMedia = await db.all(
'SELECT * FROM media_history WHERE created_at < ?',
[cutoff]
);
for (const media of oldMedia) {
const mediaPath = path.join(CONFIG.mediaDir, media.type + 's', media.filename);
const thumbnailPath = path.join(CONFIG.mediaDir, 'thumbnails', media.thumbnail);
await fs.unlink(mediaPath).catch(() => {});
await fs.unlink(thumbnailPath).catch(() => {});
}
await db.run('DELETE FROM media_history WHERE created_at < ?', [cutoff]);
console.log(`Cleaned up ${oldMedia.length} old media files`);
}
// Start Server
async function start() {
// Initialize database
await initDatabase();
// Initialize MCP clients
await initializeMCPClients();
// Create media directories
await fs.mkdir(CONFIG.mediaDir, { recursive: true });
await fs.mkdir(path.join(CONFIG.mediaDir, 'screenshots'), { recursive: true });
await fs.mkdir(path.join(CONFIG.mediaDir, 'recordings'), { recursive: true });
await fs.mkdir(path.join(CONFIG.mediaDir, 'thumbnails'), { recursive: true });
// Start cleanup scheduler
setInterval(cleanupOldMedia, 24 * 60 * 60 * 1000); // Daily
// Start HTTP server
app.listen(CONFIG.port, () => {
console.log(`✅ Media History Server running on port ${CONFIG.port}`);
console.log(`✅ WebSocket server running on port ${CONFIG.wsPort}`);
console.log(`📁 Media storage: ${CONFIG.mediaDir}`);
console.log(`🌐 Open http://localhost:${CONFIG.port} to access the UI`);
});
}
start().catch(console.error);
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down...');
// Close WebSocket connections
connections.forEach((client) => client.ws.close());
// Disconnect MCP clients
for (const client of mcpClients.values()) {
try {
await client.close();
} catch (e) {
// Ignore errors during shutdown
}
}
// Close database
if (db) await db.close();
process.exit(0);
});