import express from 'express'; import cors from 'cors'; import crypto from 'crypto'; import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import multer from 'multer'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import { spawn } from 'child_process'; // Generate a unique link ID const generateUniqueId = () => { return crypto.randomBytes(8).toString('hex'); }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT ? Number(process.env.PORT) : 3005; // Middleware app.use(cors()); app.use(express.json()); // Ensure media directory exists const mediaDir = path.join(__dirname, '..', 'public', 'media'); if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } // Configure multer for video uploads const getExtensionForMime = (mimetype) => { if (!mimetype) return '.webm'; if (mimetype.includes('mp4')) return '.mp4'; if (mimetype.includes('webm')) return '.webm'; if (mimetype.includes('quicktime')) return '.mov'; return '.webm'; }; const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, mediaDir); }, filename: (req, file, cb) => { const timestamp = Date.now(); const ext = getExtensionForMime(file.mimetype); const safeName = `recording_${timestamp}${ext}`; cb(null, safeName); } }); const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 }, // 100MB limit fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('video/')) { cb(null, true); } else { cb(new Error('Only video files are allowed')); } } }); // Initialize SQLite database let db; const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => { const args = [ '-y', '-i', inputPath, '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-profile:v', 'baseline', '-level', '3.0', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', outputPath ]; const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' }); ffmpeg.on('error', reject); ffmpeg.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`ffmpeg exited with code ${code}`)); } }); }); const isMp4Upload = (file) => { if (!file) return false; if (file.mimetype && file.mimetype.includes('mp4')) return true; return path.extname(file.filename).toLowerCase() === '.mp4'; }; const transcodeQueue = []; let isTranscoding = false; const processTranscodeQueue = async () => { if (isTranscoding) return; const job = transcodeQueue.shift(); if (!job) return; isTranscoding = true; try { await job(); } catch (error) { console.error('Transcode job failed:', error); } finally { isTranscoding = false; processTranscodeQueue(); } }; const enqueueTranscode = (job) => { transcodeQueue.push(job); processTranscodeQueue(); }; async function initializeDatabase() { db = await open({ filename: path.join(__dirname, 'signsync.db'), driver: sqlite3.Database }); await db.exec(` CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, unique_link TEXT UNIQUE NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL, recorded_video_path TEXT NOT NULL, recorded_video_processing INTEGER DEFAULT 0, youtube_video_url TEXT NOT NULL, name_2 TEXT, email_2 TEXT, recorded_video_path_2 TEXT, recorded_video_processing_2 INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); const addColumnIfMissing = async (columnDef) => { try { await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`); } catch (error) { if (!String(error.message).toLowerCase().includes('duplicate column')) { console.error('Error adding column:', error); } } }; await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0'); await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0'); await db.run('UPDATE recordings SET recorded_video_processing = 0 WHERE recorded_video_processing IS NULL'); await db.run('UPDATE recordings SET recorded_video_processing_2 = 0 WHERE recorded_video_processing_2 IS NULL'); console.log('Database initialized'); } // API Routes // Save recording with user info app.post('/api/recordings', upload.single('video'), async (req, res) => { try { const { name, email, youtubeVideoUrl } = req.body; if (!name || !email || !req.file) { return res.status(400).json({ error: 'Name, email, and video file are required' }); } const recordedVideoPath = `/media/${req.file.filename}`; const uniqueLink = generateUniqueId(); const needsTranscode = !isMp4Upload(req.file); const recordedVideoProcessing = needsTranscode ? 1 : 0; const result = await db.run( `INSERT INTO recordings (unique_link, name, email, recorded_video_path, recorded_video_processing, youtube_video_url) VALUES (?, ?, ?, ?, ?, ?)`, [uniqueLink, name, email, recordedVideoPath, recordedVideoProcessing, youtubeVideoUrl || ''] ); res.json({ success: true, id: result.lastID, uniqueLink, recordedVideoPath, recordedVideoProcessing, message: 'Recording saved successfully' }); if (needsTranscode) { const inputPath = req.file.path; const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4'); enqueueTranscode(async () => { try { await transcodeToMp4(inputPath, outputPath); await db.run( `UPDATE recordings SET recorded_video_path = ?, recorded_video_processing = 0 WHERE unique_link = ?`, [`/media/${path.basename(outputPath)}`, uniqueLink] ); fs.unlink(inputPath, () => {}); } catch (transcodeError) { console.error('Transcode failed, keeping original file:', transcodeError); await db.run( `UPDATE recordings SET recorded_video_processing = 0 WHERE unique_link = ?`, [uniqueLink] ); } }); } } catch (error) { console.error('Error saving recording:', error); res.status(500).json({ error: 'Failed to save recording' }); } }); // Get all recordings app.get('/api/recordings', async (req, res) => { try { const recordings = await db.all( 'SELECT * FROM recordings ORDER BY created_at DESC' ); res.json(recordings); } catch (error) { console.error('Error fetching recordings:', error); res.status(500).json({ error: 'Failed to fetch recordings' }); } }); // Get recording by ID app.get('/api/recordings/:id', async (req, res) => { try { const recording = await db.get( 'SELECT * FROM recordings WHERE id = ?', [req.params.id] ); if (!recording) { return res.status(404).json({ error: 'Recording not found' }); } res.json(recording); } catch (error) { console.error('Error fetching recording:', error); res.status(500).json({ error: 'Failed to fetch recording' }); } }); // Get recording by unique link app.get('/api/share/:uniqueLink', async (req, res) => { try { const recording = await db.get( 'SELECT * FROM recordings WHERE unique_link = ?', [req.params.uniqueLink] ); if (!recording) { return res.status(404).json({ error: 'Recording not found' }); } res.json(recording); } catch (error) { console.error('Error fetching recording:', error); res.status(500).json({ error: 'Failed to fetch recording' }); } }); // Add second recording to an existing shared recording app.post('/api/share/:uniqueLink/second-video', upload.single('video'), async (req, res) => { try { const { name, email } = req.body; const { uniqueLink } = req.params; if (!name || !email || !req.file) { return res.status(400).json({ error: 'Name, email, and video file are required' }); } // Check if recording exists const recording = await db.get( 'SELECT * FROM recordings WHERE unique_link = ?', [uniqueLink] ); if (!recording) { return res.status(404).json({ error: 'Recording not found' }); } if (recording.recorded_video_path_2) { return res.status(400).json({ error: 'Second video already recorded' }); } const recordedVideoPath2 = `/media/${req.file.filename}`; const needsTranscode = !isMp4Upload(req.file); const recordedVideoProcessing2 = needsTranscode ? 1 : 0; await db.run( `UPDATE recordings SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ?, recorded_video_processing_2 = ? WHERE unique_link = ?`, [name, email, recordedVideoPath2, recordedVideoProcessing2, uniqueLink] ); res.json({ success: true, recordedVideoPath2, recordedVideoProcessing2, message: 'Second recording saved successfully' }); if (needsTranscode) { const inputPath = req.file.path; const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4'); enqueueTranscode(async () => { try { await transcodeToMp4(inputPath, outputPath); await db.run( `UPDATE recordings SET recorded_video_path_2 = ?, recorded_video_processing_2 = 0 WHERE unique_link = ?`, [`/media/${path.basename(outputPath)}`, uniqueLink] ); fs.unlink(inputPath, () => {}); } catch (transcodeError) { console.error('Transcode failed, keeping original file:', transcodeError); await db.run( `UPDATE recordings SET recorded_video_processing_2 = 0 WHERE unique_link = ?`, [uniqueLink] ); } }); } } catch (error) { console.error('Error saving second recording:', error); res.status(500).json({ error: 'Failed to save second recording' }); } }); // Start server initializeDatabase().then(() => { app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); });