355 lines
10 KiB
JavaScript
355 lines
10 KiB
JavaScript
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';
|
|
|
|
// Find ffmpeg binary
|
|
const FFMPEG = ['/opt/homebrew/bin/ffmpeg', '/usr/bin/ffmpeg'].find(p => fs.existsSync(p)) || 'ffmpeg';
|
|
|
|
// 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 = '/opt/homebrew/var/www/signsync/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}`);
|
|
});
|
|
});
|