From df1aa20e45fc333b1cf4fabca871a187c3fb7563 Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 8 Apr 2026 00:10:25 +0200 Subject: [PATCH] feat: add recordings table and implement fetch and sync functionality for recordings --- server/config/database.js | 45 ++++++ server/routes/recordings.js | 264 +++++++++++++++++++++++++++--------- 2 files changed, 246 insertions(+), 63 deletions(-) diff --git a/server/config/database.js b/server/config/database.js index 0afa96f..344e6ac 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -815,6 +815,51 @@ export async function initDatabase() { await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'"); } + // ── Recordings cache table ──────────────────────────────────────────── + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS recordings ( + id SERIAL PRIMARY KEY, + record_id TEXT UNIQUE NOT NULL, + meeting_id TEXT NOT NULL, + name TEXT, + state TEXT, + published INTEGER DEFAULT 1, + start_time TEXT, + end_time TEXT, + participants TEXT, + size TEXT, + formats TEXT NOT NULL DEFAULT '[]', + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id); + CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_id TEXT UNIQUE NOT NULL, + meeting_id TEXT NOT NULL, + name TEXT, + state TEXT, + published INTEGER DEFAULT 1, + start_time TEXT, + end_time TEXT, + participants TEXT, + size TEXT, + formats TEXT NOT NULL DEFAULT '[]', + metadata TEXT DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_recordings_record_id ON recordings(record_id); + CREATE INDEX IF NOT EXISTS idx_recordings_meeting_id ON recordings(meeting_id); + `); + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/routes/recordings.js b/server/routes/recordings.js index 75d280e..5bda5e1 100644 --- a/server/routes/recordings.js +++ b/server/routes/recordings.js @@ -11,6 +11,171 @@ import { const router = Router(); +/** + * Format a raw BBB recording into a normalised object. + */ +function formatBbbRecording(rec, fallbackName) { + const playback = rec.playback?.format; + let formats = []; + if (playback) { + formats = Array.isArray(playback) ? playback : [playback]; + } + + return { + recordID: rec.recordID, + meetingID: rec.meetingID, + name: rec.name || fallbackName || 'Recording', + state: rec.state, + published: rec.published === 'true', + startTime: rec.startTime, + endTime: rec.endTime, + participants: rec.participants, + size: rec.size, + formats: formats.map(f => ({ + type: f.type, + url: f.url, + length: f.length, + size: f.size, + })), + metadata: rec.metadata || {}, + }; +} + +/** + * Format a DB row into the same shape the frontend expects. + */ +function formatDbRecording(row) { + return { + recordID: row.record_id, + meetingID: row.meeting_id, + name: row.name || 'Recording', + state: row.state, + published: row.published === 1, + startTime: row.start_time, + endTime: row.end_time, + participants: row.participants, + size: row.size, + formats: JSON.parse(row.formats || '[]'), + metadata: JSON.parse(row.metadata || '{}'), + fromCache: true, + }; +} + +/** + * Upsert a single formatted recording into the DB. + * - If the record_id already exists AND is the same recording, merge new formats. + * - If it's a new recording or a different one with the same record_id, overwrite. + */ +async function upsertRecording(db, rec) { + const existing = await db.get('SELECT * FROM recordings WHERE record_id = ?', [rec.recordID]); + const formatsJson = JSON.stringify(rec.formats); + const metadataJson = JSON.stringify(rec.metadata || {}); + + if (existing) { + // Verify it's still the same recording (same startTime) + if (String(rec.startTime) === String(existing.start_time)) { + // Merge formats: keep existing formats, add any new types + const existingFormats = JSON.parse(existing.formats || '[]'); + const existingTypes = new Set(existingFormats.map(f => f.type)); + const mergedFormats = [...existingFormats]; + + for (const f of rec.formats) { + if (existingTypes.has(f.type)) { + // Update URL for existing format type (server may have changed) + const idx = mergedFormats.findIndex(ef => ef.type === f.type); + if (idx !== -1) mergedFormats[idx] = f; + } else { + // New format type added + mergedFormats.push(f); + } + } + + await db.run( + `UPDATE recordings SET name = ?, state = ?, published = ?, end_time = ?, + participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP + WHERE record_id = ?`, + [rec.name, rec.state, rec.published ? 1 : 0, rec.endTime, + rec.participants, rec.size, JSON.stringify(mergedFormats), metadataJson, + rec.recordID] + ); + } else { + // Different recording with same record_id – overwrite completely + await db.run( + `UPDATE recordings SET meeting_id = ?, name = ?, state = ?, published = ?, + start_time = ?, end_time = ?, participants = ?, size = ?, formats = ?, + metadata = ?, updated_at = CURRENT_TIMESTAMP + WHERE record_id = ?`, + [rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0, + rec.startTime, rec.endTime, rec.participants, rec.size, + formatsJson, metadataJson, rec.recordID] + ); + } + } else { + // Completely new recording – insert + await db.run( + `INSERT INTO recordings (record_id, meeting_id, name, state, published, start_time, + end_time, participants, size, formats, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [rec.recordID, rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0, + rec.startTime, rec.endTime, rec.participants, rec.size, + formatsJson, metadataJson] + ); + } +} + +/** + * Core logic: fetch recordings from BBB, sync with DB, fall back to DB on error. + * Returns an array of formatted recordings. + */ +async function fetchAndSyncRecordings(meetingID, fallbackName) { + const db = getDb(); + let bbbRecordings = null; + + try { + const raw = await getRecordings(meetingID || undefined); + bbbRecordings = raw.map(rec => formatBbbRecording(rec, fallbackName)); + } catch (err) { + log.recordings.warn(`BBB API unreachable, falling back to cached recordings: ${err.message}`); + } + + if (bbbRecordings !== null) { + // BBB API was reachable – sync each recording into the DB + for (const rec of bbbRecordings) { + try { + await upsertRecording(db, rec); + } catch (err) { + log.recordings.error(`Failed to cache recording ${rec.recordID}: ${err.message}`); + } + } + + // For recordings in DB that were NOT returned by BBB (possibly deleted on server + // or server changed), keep them in DB with their cached URLs – don't delete. + // However, we return the BBB result merged with any DB-only recordings. + const bbbIds = new Set(bbbRecordings.map(r => r.recordID)); + const params = meetingID ? [meetingID] : []; + const query = meetingID + ? 'SELECT * FROM recordings WHERE meeting_id = ?' + : 'SELECT * FROM recordings'; + const dbRows = await db.all(query, params); + + // Add DB-only recordings (not on current BBB server) with cached URLs + for (const row of dbRows) { + if (!bbbIds.has(row.record_id)) { + bbbRecordings.push(formatDbRecording(row)); + } + } + + return bbbRecordings; + } else { + // BBB API unreachable – serve everything from DB + const params = meetingID ? [meetingID] : []; + const query = meetingID + ? 'SELECT * FROM recordings WHERE meeting_id = ?' + : 'SELECT * FROM recordings'; + const rows = await db.all(query, params); + return rows.map(formatDbRecording); + } +} + // GET /api/recordings - Get recordings for a room (by meetingID/uid) router.get('/', authenticateToken, async (req, res) => { try { @@ -34,36 +199,7 @@ router.get('/', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'meetingID query parameter is required' }); } - const recordings = await getRecordings(meetingID || undefined); - - // Format recordings - const formatted = recordings.map(rec => { - const playback = rec.playback?.format; - let formats = []; - if (playback) { - formats = Array.isArray(playback) ? playback : [playback]; - } - - return { - recordID: rec.recordID, - meetingID: rec.meetingID, - name: rec.name || 'Recording', - state: rec.state, - published: rec.published === 'true', - startTime: rec.startTime, - endTime: rec.endTime, - participants: rec.participants, - size: rec.size, - formats: formats.map(f => ({ - type: f.type, - url: f.url, - length: f.length, - size: f.size, - })), - metadata: rec.metadata || {}, - }; - }); - + const formatted = await fetchAndSyncRecordings(meetingID); res.json({ recordings: formatted }); } catch (err) { log.recordings.error(`Get recordings error: ${err.message}`); @@ -89,33 +225,7 @@ router.get('/room/:uid', authenticateToken, async (req, res) => { } } - const recordings = await getRecordings(room.uid); - const formatted = recordings.map(rec => { - const playback = rec.playback?.format; - let formats = []; - if (playback) { - formats = Array.isArray(playback) ? playback : [playback]; - } - - return { - recordID: rec.recordID, - meetingID: rec.meetingID, - name: rec.name || room.name, - state: rec.state, - published: rec.published === 'true', - startTime: rec.startTime, - endTime: rec.endTime, - participants: rec.participants, - size: rec.size, - formats: formats.map(f => ({ - type: f.type, - url: f.url, - length: f.length, - size: f.size, - })), - }; - }); - + const formatted = await fetchAndSyncRecordings(room.uid, room.name); res.json({ recordings: formatted }); } catch (err) { log.recordings.error(`Get room recordings error: ${err.message}`); @@ -126,14 +236,21 @@ router.get('/room/:uid', authenticateToken, async (req, res) => { // DELETE /api/recordings/:recordID router.delete('/:recordID', authenticateToken, async (req, res) => { try { + const db = getDb(); // M14 fix: look up the recording from BBB to find its meetingID (room UID), // then verify the user owns or shares that room. if (req.user.role !== 'admin') { - const rec = await getRecordingByRecordId(req.params.recordID); + // Try BBB first, fall back to DB cache + let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null); + if (!rec) { + const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]); + if (dbRow) { + rec = { meetingID: dbRow.meeting_id }; + } + } if (!rec) { return res.status(404).json({ error: 'Recording not found' }); } - const db = getDb(); const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); if (!room) { return res.status(404).json({ error: 'Room not found' }); @@ -145,7 +262,14 @@ router.delete('/:recordID', authenticateToken, async (req, res) => { } } } - await deleteRecording(req.params.recordID); + // Try to delete on BBB (may fail if server changed – that's OK) + try { + await deleteRecording(req.params.recordID); + } catch (err) { + log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`); + } + // Always remove from local cache + await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]); res.json({ message: 'Recording deleted' }); } catch (err) { log.recordings.error(`Delete recording error: ${err.message}`); @@ -156,14 +280,20 @@ router.delete('/:recordID', authenticateToken, async (req, res) => { // PUT /api/recordings/:recordID/publish router.put('/:recordID/publish', authenticateToken, async (req, res) => { try { + const db = getDb(); // M14 fix: look up the recording from BBB to find its meetingID (room UID), // then verify the user owns or shares that room. if (req.user.role !== 'admin') { - const rec = await getRecordingByRecordId(req.params.recordID); + let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null); + if (!rec) { + const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]); + if (dbRow) { + rec = { meetingID: dbRow.meeting_id }; + } + } if (!rec) { return res.status(404).json({ error: 'Recording not found' }); } - const db = getDb(); const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); if (!room) { return res.status(404).json({ error: 'Room not found' }); @@ -176,7 +306,15 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => { } } const { publish } = req.body; - await publishRecording(req.params.recordID, publish); + // Try BBB API + try { + await publishRecording(req.params.recordID, publish); + } catch (err) { + log.recordings.warn(`BBB publishRecording failed: ${err.message}`); + } + // Update DB cache + await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?', + [publish ? 1 : 0, req.params.recordID]); res.json({ message: publish ? 'Recording published' : 'Recording unpublished' }); } catch (err) { log.recordings.error(`Publish recording error: ${err.message}`);