diff --git a/.gitignore b/.gitignore index bfa5d2f..49f709d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ node_modules/ # Coverage coverage/ recordings/ +!client/public/recordings/ +!client/public/recordings/** *.lcov .nyc_output diff --git a/client/public/css/style.css b/client/public/css/style.css index 7277e68..3654a8c 100644 --- a/client/public/css/style.css +++ b/client/public/css/style.css @@ -253,3 +253,508 @@ body { from { opacity: 0; transform: translateX(-50%) translateY(8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } + +.recordings-page { + overflow: hidden; +} + +.recordings-shell { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.recordings-toolbar { + min-height: 76px; + border-radius: 16px; + padding: 14px; + display: grid; + grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px; + align-items: center; + gap: 12px; +} + +.recordings-stat { + min-width: 112px; + padding: 6px 10px; +} + +.recordings-stat-value { + display: block; + font-size: 20px; + line-height: 1.1; + font-weight: 700; + color: #ffffff; +} + +.recordings-stat-label { + display: block; + margin-top: 4px; + font-size: 12px; + color: #94a3b8; +} + +.recordings-search, +.recordings-select, +.recordings-input { + height: 42px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(15, 23, 42, 0.58); + color: #ffffff; +} + +.recordings-search { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; +} + +.recordings-search input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #ffffff; + font-size: 14px; +} + +.recordings-search input::placeholder, +.recordings-input::placeholder { + color: #64748b; +} + +.recordings-select { + padding: 0 12px; + outline: 0; +} + +.recordings-select option { + color: #0f172a; +} + +.recordings-content { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 280px minmax(420px, 1fr) 360px; + gap: 16px; +} + +.recordings-upload, +.recordings-list, +.recordings-preview { + min-height: 0; + border-radius: 16px; +} + +.recordings-upload { + padding: 18px; +} + +.recordings-list { + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.recordings-list-head { + min-height: 72px; + padding: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.recordings-preview { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + overflow: hidden; +} + +.recordings-dropzone { + min-height: 132px; + border-radius: 14px; + border: 1px dashed rgba(129, 140, 248, 0.55); + background: rgba(79, 70, 229, 0.08); + color: #c7d2fe; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + text-align: center; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.recordings-dropzone:hover { + background: rgba(79, 70, 229, 0.16); + border-color: rgba(165, 180, 252, 0.8); +} + +.recordings-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + color: #94a3b8; +} + +.recordings-input { + width: 100%; + padding: 0 12px; + outline: 0; +} + +.recordings-input:focus, +.recordings-select:focus, +.recordings-search:focus-within { + border-color: rgba(129, 140, 248, 0.9); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22); +} + +.recordings-primary-btn, +.recordings-secondary-btn, +.recordings-text-btn, +.recordings-icon-btn { + border: 0; + outline: 0; + cursor: pointer; + transition: transform 0.2s, background 0.2s, color 0.2s, border-color 0.2s; +} + +.recordings-primary-btn, +.recordings-secondary-btn { + min-height: 42px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 14px; + font-weight: 600; +} + +.recordings-primary-btn { + width: 100%; + background: #4f46e5; + color: #ffffff; +} + +.recordings-primary-btn:hover { + background: #4338ca; +} + +.recordings-primary-btn:disabled { + opacity: 0.55; + cursor: wait; +} + +.recordings-secondary-btn { + width: 100%; + background: rgba(255, 255, 255, 0.08); + color: #e2e8f0; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.recordings-secondary-btn:hover { + background: rgba(255, 255, 255, 0.14); +} + +.recordings-text-btn { + display: inline-flex; + align-items: center; + gap: 8px; + color: #c7d2fe; + background: transparent; + font-size: 13px; +} + +.recordings-icon-btn { + width: 38px; + height: 38px; + border-radius: 11px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #cbd5e1; + background: rgba(255, 255, 255, 0.06); +} + +.recordings-icon-btn:hover { + transform: translateY(-1px); + color: #ffffff; + background: rgba(255, 255, 255, 0.12); +} + +.recordings-danger { + color: #fca5a5; +} + +.recordings-danger:hover { + color: #ffffff; + background: rgba(239, 68, 68, 0.75); +} + +.recordings-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.recordings-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.recordings-table th, +.recordings-table td { + padding: 13px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; + vertical-align: middle; +} + +.recordings-table th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(15, 23, 42, 0.95); + color: #94a3b8; + font-size: 12px; + font-weight: 600; +} + +.recordings-table th:nth-child(1) { width: 38%; } +.recordings-table th:nth-child(2) { width: 18%; } +.recordings-table th:nth-child(3) { width: 12%; } +.recordings-table th:nth-child(4) { width: 18%; } +.recordings-table th:nth-child(5) { width: 14%; } + +.recordings-table tbody tr { + transition: background 0.2s; +} + +.recordings-table tbody tr:hover, +.recordings-row-active { + background: rgba(99, 102, 241, 0.14); +} + +.recordings-file-cell { + max-width: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.recordings-file-icon { + flex: 0 0 auto; + width: 46px; + height: 30px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(16, 185, 129, 0.16); + color: #6ee7b7; + font-size: 11px; + font-weight: 700; +} + +.recordings-file-name, +.recordings-file-sub { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recordings-file-name { + color: #ffffff; + font-size: 14px; + font-weight: 600; +} + +.recordings-file-sub { + margin-top: 3px; + color: #64748b; + font-size: 12px; +} + +.recordings-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.recordings-empty { + flex: 1; + min-height: 260px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: #94a3b8; +} + +.recordings-preview-video { + position: relative; + aspect-ratio: 16 / 9; + border-radius: 14px; + overflow: hidden; + background: #020617; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.recordings-preview-video video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.recordings-preview-placeholder { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: #94a3b8; + background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(8, 47, 73, 0.45)); +} + +.recordings-preview-meta { + min-height: 0; + overflow: auto; +} + +.recordings-preview-meta h2 { + margin-bottom: 14px; + font-size: 17px; + font-weight: 700; + word-break: break-word; +} + +.recordings-detail-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.recordings-detail-row span { + color: #94a3b8; +} + +.recordings-detail-row strong { + color: #e2e8f0; + font-weight: 600; + text-align: right; + word-break: break-all; +} + +.recordings-preview-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 16px; +} + +.recordings-dialog { + width: min(440px, calc(100vw - 32px)); + border-radius: 18px; + padding: 22px; +} + +.recordings-notification { + position: fixed; + top: 82px; + left: 50%; + transform: translate(-50%, -16px); + z-index: 60; + min-height: 44px; + padding: 0 18px; + border-radius: 999px; + display: flex; + align-items: center; + gap: 10px; + color: #ffffff; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; +} + +.recordings-notification-visible { + opacity: 1; + transform: translate(-50%, 0); +} + +.recordings-notification-error i { + color: #f87171; +} + +@media (max-width: 1180px) { + .recordings-content { + grid-template-columns: 260px minmax(420px, 1fr); + } + + .recordings-preview { + grid-column: 1 / -1; + min-height: 360px; + display: grid; + grid-template-columns: minmax(360px, 0.8fr) 1fr; + } +} + +@media (max-width: 860px) { + .recordings-page { + overflow: auto; + } + + .recordings-shell { + padding: 14px; + overflow: visible; + } + + .recordings-toolbar { + grid-template-columns: 1fr 1fr; + } + + .recordings-search, + .recordings-select { + grid-column: 1 / -1; + } + + .recordings-content, + .recordings-preview { + display: flex; + flex-direction: column; + } + + .recordings-list, + .recordings-upload, + .recordings-preview { + flex: none; + } + + .recordings-table { + min-width: 760px; + } +} diff --git a/client/public/recordings/index.html b/client/public/recordings/index.html new file mode 100644 index 0000000..d66d5ec --- /dev/null +++ b/client/public/recordings/index.html @@ -0,0 +1,205 @@ + + + + + + + VideoCall - 录制管理 + + + + + + +
+
+
+
+ +
+
+

录制管理后台

+
+ + recordings +
+
+
+ +
+ + + + +
+
+ +
+
+
+ 0 + 总录制 +
+
+ 0 + 会议数 +
+
+ 0 MB + 占用空间 +
+ + + + +
+ +
+ + +
+
+
+

录制文件

+

等待加载

+
+ +
+ +
+
+ 正在读取 recordings 目录... +
+ + + + +
+ + +
+
+
+ + + +
+ + +
+ + + + + diff --git a/client/public/recordings/recordings-admin.js b/client/public/recordings/recordings-admin.js new file mode 100644 index 0000000..4e636ab --- /dev/null +++ b/client/public/recordings/recordings-admin.js @@ -0,0 +1,400 @@ +const state = { + recordings: [], + filtered: [], + selectedKey: '', + editing: null +}; + +const elements = { + refreshBtn: document.getElementById('refreshBtn'), + searchInput: document.getElementById('searchInput'), + typeFilter: document.getElementById('typeFilter'), + clearSearchBtn: document.getElementById('clearSearchBtn'), + uploadForm: document.getElementById('uploadForm'), + uploadBtn: document.getElementById('uploadBtn'), + recordingFile: document.getElementById('recordingFile'), + fileNameText: document.getElementById('fileNameText'), + uploadMeetingId: document.getElementById('uploadMeetingId'), + uploadUserId: document.getElementById('uploadUserId'), + totalCount: document.getElementById('totalCount'), + meetingCount: document.getElementById('meetingCount'), + storageSize: document.getElementById('storageSize'), + recordingRootText: document.getElementById('recordingRootText'), + listSummary: document.getElementById('listSummary'), + loadingState: document.getElementById('loadingState'), + emptyState: document.getElementById('emptyState'), + recordingsTableWrap: document.getElementById('recordingsTableWrap'), + recordingsTableBody: document.getElementById('recordingsTableBody'), + previewVideo: document.getElementById('previewVideo'), + previewPlaceholder: document.getElementById('previewPlaceholder'), + previewTitle: document.getElementById('previewTitle'), + previewDetails: document.getElementById('previewDetails'), + editDialog: document.getElementById('editDialog'), + editForm: document.getElementById('editForm'), + editFilenameText: document.getElementById('editFilenameText'), + editMeetingId: document.getElementById('editMeetingId'), + editOriginalFilename: document.getElementById('editOriginalFilename'), + editUserId: document.getElementById('editUserId'), + closeEditBtn: document.getElementById('closeEditBtn'), + cancelEditBtn: document.getElementById('cancelEditBtn'), + notification: document.getElementById('notification'), + notificationText: document.getElementById('notificationText') +}; + +function recordingKey(recording) { + return `${recording.meetingId}/${recording.filename}`; +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatBytes(bytes) { + const value = Number(bytes) || 0; + if (value < 1024) { + return `${value} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB']; + let size = value / 1024; + let index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; +} + +function formatDate(value) { + if (!value) { + return '-'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '-'; + } + + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +function showNotification(message, isError = false) { + elements.notificationText.textContent = message; + elements.notification.classList.toggle('recordings-notification-error', isError); + elements.notification.classList.add('recordings-notification-visible'); + window.clearTimeout(showNotification.timer); + showNotification.timer = window.setTimeout(() => { + elements.notification.classList.remove('recordings-notification-visible'); + }, 2600); +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, options); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success === false) { + throw new Error(payload.message || `请求失败: ${response.status}`); + } + return payload; +} + +function setLoading(isLoading) { + elements.loadingState.classList.toggle('hidden', !isLoading); + elements.recordingsTableWrap.classList.toggle('hidden', isLoading || state.filtered.length === 0); + elements.emptyState.classList.toggle('hidden', isLoading || state.filtered.length > 0); +} + +async function loadRecordings() { + setLoading(true); + try { + const payload = await requestJson('/api/recordings'); + state.recordings = payload.recordings || []; + elements.recordingRootText.textContent = payload.root || 'recordings'; + applyFilters(); + const selected = state.filtered.find(item => recordingKey(item) === state.selectedKey) || state.filtered[0]; + selectRecording(selected || null); + } catch (error) { + state.recordings = []; + applyFilters(); + selectRecording(null); + showNotification(error.message, true); + } finally { + setLoading(false); + } +} + +function applyFilters() { + const query = elements.searchInput.value.trim().toLowerCase(); + const type = elements.typeFilter.value; + + state.filtered = state.recordings.filter((recording) => { + const extension = (recording.filename || '').split('.').pop().toLowerCase(); + const haystack = [ + recording.meetingId, + recording.filename, + recording.originalFilename, + recording.userId + ].join(' ').toLowerCase(); + return (type === 'all' || extension === type) && (!query || haystack.includes(query)); + }); + + elements.clearSearchBtn.classList.toggle('hidden', !query && type === 'all'); + renderSummary(); + renderTable(); + setLoading(false); +} + +function renderSummary() { + const meetings = new Set(state.recordings.map(recording => recording.meetingId)); + const totalSize = state.recordings.reduce((sum, recording) => sum + (Number(recording.size) || 0), 0); + + elements.totalCount.textContent = state.recordings.length; + elements.meetingCount.textContent = meetings.size; + elements.storageSize.textContent = formatBytes(totalSize); + elements.listSummary.textContent = `当前显示 ${state.filtered.length} 条,共 ${state.recordings.length} 条`; +} + +function renderTable() { + elements.recordingsTableBody.innerHTML = state.filtered.map((recording) => { + const key = recordingKey(recording); + const active = key === state.selectedKey ? 'recordings-row-active' : ''; + const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase()); + + return ` + + + + + ${escapeHtml(recording.meetingId)} + ${formatBytes(recording.size)} + ${formatDate(recording.uploadedAt)} + +
+ + + + + + +
+ + + `; + }).join(''); +} + +function findRecording(key) { + return state.recordings.find(recording => recordingKey(recording) === key); +} + +function selectRecording(recording) { + if (!recording) { + state.selectedKey = ''; + elements.previewVideo.removeAttribute('src'); + elements.previewVideo.load(); + elements.previewPlaceholder.classList.remove('hidden'); + elements.previewTitle.textContent = '未选择录制'; + elements.previewDetails.innerHTML = '

从左侧列表选择文件后,可播放、下载、编辑或删除。

'; + renderTable(); + return; + } + + state.selectedKey = recordingKey(recording); + elements.previewVideo.src = recording.streamUrl; + elements.previewPlaceholder.classList.add('hidden'); + elements.previewTitle.textContent = recording.originalFilename || recording.filename; + elements.previewDetails.innerHTML = ` +
会议 ID${escapeHtml(recording.meetingId)}
+
文件名${escapeHtml(recording.filename)}
+
格式${escapeHtml(recording.mimetype)}
+
大小${formatBytes(recording.size)}
+
用户 ID${escapeHtml(recording.userId || '-')}
+
上传时间${formatDate(recording.uploadedAt)}
+
+ + + 下载 + + +
+ `; + renderTable(); +} + +function openEdit(recording) { + if (!recording) { + return; + } + + state.editing = recording; + elements.editFilenameText.textContent = recording.filename; + elements.editMeetingId.value = recording.meetingId || ''; + elements.editOriginalFilename.value = recording.originalFilename || recording.filename || ''; + elements.editUserId.value = recording.userId || ''; + elements.editDialog.classList.remove('hidden'); + elements.editDialog.classList.add('flex'); + elements.editMeetingId.focus(); +} + +function closeEdit() { + state.editing = null; + elements.editDialog.classList.add('hidden'); + elements.editDialog.classList.remove('flex'); +} + +async function handleUpload(event) { + event.preventDefault(); + const file = elements.recordingFile.files[0]; + if (!file) { + showNotification('请选择录制文件', true); + return; + } + + const formData = new FormData(); + formData.append('recording', file, file.name); + formData.append('filename', file.name); + formData.append('meetingId', elements.uploadMeetingId.value.trim()); + formData.append('userId', elements.uploadUserId.value.trim()); + + elements.uploadBtn.disabled = true; + try { + await requestJson('/api/recordings', { + method: 'POST', + body: formData + }); + elements.uploadForm.reset(); + elements.fileNameText.textContent = '选择 MP4 或 WebM 文件'; + showNotification('录制已上传'); + await loadRecordings(); + } catch (error) { + showNotification(error.message, true); + } finally { + elements.uploadBtn.disabled = false; + } +} + +async function handleEdit(event) { + event.preventDefault(); + if (!state.editing) { + return; + } + + const recording = state.editing; + try { + const payload = await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + meetingId: elements.editMeetingId.value.trim(), + originalFilename: elements.editOriginalFilename.value.trim(), + userId: elements.editUserId.value.trim() + }) + }); + state.selectedKey = recordingKey(payload.recording); + closeEdit(); + showNotification('录制信息已更新'); + await loadRecordings(); + } catch (error) { + showNotification(error.message, true); + } +} + +async function deleteRecording(recording) { + if (!recording) { + return; + } + + const confirmed = window.confirm(`确定删除录制文件 "${recording.originalFilename || recording.filename}" 吗?`); + if (!confirmed) { + return; + } + + try { + await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, { + method: 'DELETE' + }); + if (state.selectedKey === recordingKey(recording)) { + state.selectedKey = ''; + } + showNotification('录制已删除'); + await loadRecordings(); + } catch (error) { + showNotification(error.message, true); + } +} + +function bindEvents() { + elements.refreshBtn.addEventListener('click', loadRecordings); + elements.searchInput.addEventListener('input', applyFilters); + elements.typeFilter.addEventListener('change', applyFilters); + elements.clearSearchBtn.addEventListener('click', () => { + elements.searchInput.value = ''; + elements.typeFilter.value = 'all'; + applyFilters(); + }); + elements.recordingFile.addEventListener('change', () => { + const file = elements.recordingFile.files[0]; + elements.fileNameText.textContent = file ? file.name : '选择 MP4 或 WebM 文件'; + }); + elements.uploadForm.addEventListener('submit', handleUpload); + elements.editForm.addEventListener('submit', handleEdit); + elements.closeEditBtn.addEventListener('click', closeEdit); + elements.cancelEditBtn.addEventListener('click', closeEdit); + elements.editDialog.addEventListener('click', (event) => { + if (event.target === elements.editDialog) { + closeEdit(); + } + }); + + elements.recordingsTableBody.addEventListener('click', (event) => { + const control = event.target.closest('[data-action]'); + if (!control) { + return; + } + + const recording = findRecording(control.dataset.key); + const action = control.dataset.action; + if (action === 'select' || action === 'preview') { + selectRecording(recording); + if (action === 'preview') { + elements.previewVideo.play().catch(() => undefined); + } + } else if (action === 'edit') { + openEdit(recording); + } else if (action === 'delete') { + deleteRecording(recording); + } + }); + + elements.previewDetails.addEventListener('click', (event) => { + const control = event.target.closest('[data-preview-action]'); + if (control && control.dataset.previewAction === 'edit') { + openEdit(findRecording(state.selectedKey)); + } + }); +} + +bindEvents(); +loadRecordings(); diff --git a/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png b/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png new file mode 100644 index 0000000..d7e88ac Binary files /dev/null and b/client/public/uploads/avatars/avatar_1d006f98-8352-47d5-bf35-c65a98dfc377.png differ diff --git a/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png b/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png new file mode 100644 index 0000000..613eea9 Binary files /dev/null and b/client/public/uploads/avatars/avatar_be8f2ecc-726c-4b99-bcd0-818aa962e311.png differ diff --git a/src/server.ts b/src/server.ts index e28eb34..af2c466 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,18 @@ const DEFAULT_RECORDING_UPLOAD_LIMIT_BYTES = 2 * 1024 * 1024 * 1024; const ALLOWED_RECORDING_MIME_TYPES = new Set(['video/webm', 'video/mp4', 'application/octet-stream']); const ALLOWED_RECORDING_EXTENSIONS = new Set(['.webm', '.mp4']); +type RecordingMetadata = { + id?: string; + meetingId?: string; + filename?: string; + originalFilename?: string; + mimetype?: string; + size?: number; + userId?: string; + uploadedAt?: string; + updatedAt?: string; +}; + function safeAvatarExtension(file: any): string { const originalExt = path.extname(file.originalname || '').toLowerCase(); if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) { @@ -89,6 +101,89 @@ function isAllowedRecording(file: any): boolean { return ext.length > 0 && isCompatibleMime; } +function readRecordingMetadata(metadataPath: string): RecordingMetadata { + try { + if (!fs.existsSync(metadataPath)) { + return {}; + } + + return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + } catch (error) { + log(LogLevel.warn, 'Failed to read recording metadata:', error); + return {}; + } +} + +function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string { + if (metadata.mimetype) { + return metadata.mimetype; + } + + return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm'; +} + +function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) { + const filePath = path.join(recordingRoot, meetingId, filename); + const metadataPath = path.join(recordingRoot, meetingId, `${filename}.json`); + const stat = fs.statSync(filePath); + const metadata = readRecordingMetadata(metadataPath); + const resolvedMeetingId = metadata.meetingId || meetingId; + const resolvedFilename = metadata.filename || filename; + + return { + id: metadata.id || path.basename(filename, path.extname(filename)), + meetingId: resolvedMeetingId, + filename: resolvedFilename, + originalFilename: metadata.originalFilename || filename, + mimetype: getRecordingMimeType(filename, metadata), + size: stat.size, + userId: metadata.userId || '', + uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(), + updatedAt: metadata.updatedAt || stat.mtime.toISOString(), + modifiedAt: stat.mtime.toISOString(), + downloadUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/download`, + streamUrl: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(filename)}/stream` + }; +} + +function listRecordings(recordingRoot: string) { + if (!fs.existsSync(recordingRoot)) { + return []; + } + + const recordings = []; + const meetingIds = fs.readdirSync(recordingRoot).filter((name) => { + const fullPath = path.join(recordingRoot, name); + return name !== '.tmp' && fs.statSync(fullPath).isDirectory(); + }); + + meetingIds.forEach((meetingId) => { + const meetingDir = path.join(recordingRoot, meetingId); + fs.readdirSync(meetingDir).forEach((filename) => { + const ext = path.extname(filename).toLowerCase(); + const filePath = path.join(meetingDir, filename); + if (!ALLOWED_RECORDING_EXTENSIONS.has(ext) || !fs.statSync(filePath).isFile()) { + return; + } + + recordings.push(buildRecordingInfo(recordingRoot, meetingId, filename)); + }); + }); + + recordings.sort((a, b) => Date.parse(b.uploadedAt) - Date.parse(a.uploadedAt)); + return recordings; +} + +function removeEmptyDirectory(directory: string): void { + try { + if (fs.existsSync(directory) && fs.readdirSync(directory).length === 0) { + fs.rmdirSync(directory); + } + } catch (error) { + log(LogLevel.warn, 'Failed to remove empty recording directory:', error); + } +} + export const createServer = (config: Options): express.Express => { const app: express.Express = express(); resetHandler(config.mode); @@ -110,6 +205,20 @@ export const createServer = (config: Options): express.Express => { })); app.use('/signaling', signaling); + + app.get(['/recordings', '/recordings/'], (_req, res) => { + const recordingsPagePath = path.join(__dirname, '../client/public/recordings/index.html'); + fs.access(recordingsPagePath, (err) => { + if (err) { + log(LogLevel.warn, `Can't find file '${recordingsPagePath}'`); + res.status(404).send(`Can't find file ${recordingsPagePath}`); + return; + } + + res.sendFile(recordingsPagePath); + }); + }); + app.use(express.static(path.join(__dirname, '../client/public'))); app.use('/module', express.static(path.join(__dirname, '../client/src'))); @@ -232,6 +341,21 @@ export const createServer = (config: Options): express.Express => { } }); + app.get('/api/recordings', (_req: express.Request, res: express.Response) => { + try { + const recordings = listRecordings(recordingRoot); + res.json({ + success: true, + recordings, + totalCount: recordings.length, + root: recordingRoot + }); + } catch (error) { + log(LogLevel.error, 'Error listing recordings:', error); + res.status(500).json({ success: false, message: 'Failed to list recordings' }); + } + }); + app.post('/api/recordings', (req: express.Request, res: express.Response) => { recordingUpload.single('recording')(req, res, (error: Error) => { if (error) { @@ -312,6 +436,186 @@ export const createServer = (config: Options): express.Express => { }); }); + app.get('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const filePath = path.join(recordingRoot, meetingId, filename); + if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + try { + res.json({ success: true, recording: buildRecordingInfo(recordingRoot, meetingId, filename) }); + } catch (error) { + log(LogLevel.error, 'Error reading recording:', error); + res.status(500).json({ success: false, message: 'Failed to read recording' }); + } + }); + + app.patch('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const nextMeetingId = sanitizePathSegment(req.body.meetingId, meetingId); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const sourceDir = path.join(recordingRoot, meetingId); + const sourcePath = path.join(sourceDir, filename); + const sourceMetadataPath = path.join(sourceDir, `${filename}.json`); + const targetDir = path.join(recordingRoot, nextMeetingId); + const targetPath = path.join(targetDir, filename); + const targetMetadataPath = path.join(targetDir, `${filename}.json`); + + if (!isPathInside(recordingRoot, sourcePath) || !isPathInside(recordingRoot, targetPath)) { + res.status(400).json({ success: false, message: 'Invalid recording path' }); + return; + } + + if (!fs.existsSync(sourcePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + if (sourcePath !== targetPath && fs.existsSync(targetPath)) { + res.status(409).json({ success: false, message: 'Recording already exists in target meeting' }); + return; + } + + try { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + if (sourcePath !== targetPath) { + fs.renameSync(sourcePath, targetPath); + if (fs.existsSync(sourceMetadataPath)) { + fs.renameSync(sourceMetadataPath, targetMetadataPath); + } + } + + const metadata = readRecordingMetadata(targetMetadataPath); + const nextMetadata = { + ...metadata, + meetingId: nextMeetingId, + filename, + originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim() + ? path.basename(req.body.originalFilename.trim()) + : metadata.originalFilename || filename, + userId: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '', + mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'), + size: fs.statSync(targetPath).size, + uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(), + updatedAt: new Date().toISOString() + }; + + fs.writeFileSync(targetMetadataPath, JSON.stringify(nextMetadata, null, 2)); + if (sourcePath !== targetPath) { + removeEmptyDirectory(sourceDir); + } + + res.json({ success: true, recording: buildRecordingInfo(recordingRoot, nextMeetingId, filename) }); + } catch (error) { + log(LogLevel.error, 'Error updating recording:', error); + res.status(500).json({ success: false, message: 'Failed to update recording' }); + } + }); + + app.delete('/api/recordings/:meetingId/:filename', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const meetingDir = path.join(recordingRoot, meetingId); + const filePath = path.join(meetingDir, filename); + const metadataPath = path.join(meetingDir, `${filename}.json`); + if (!isPathInside(recordingRoot, filePath)) { + res.status(400).json({ success: false, message: 'Invalid recording path' }); + return; + } + + if (!fs.existsSync(filePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + try { + fs.unlinkSync(filePath); + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath); + } + removeEmptyDirectory(meetingDir); + res.json({ success: true }); + } catch (error) { + log(LogLevel.error, 'Error deleting recording:', error); + res.status(500).json({ success: false, message: 'Failed to delete recording' }); + } + }); + + app.get('/api/recordings/:meetingId/:filename/stream', (req: express.Request, res: express.Response) => { + const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); + const filename = sanitizePathSegment(req.params.filename, ''); + const ext = path.extname(filename).toLowerCase(); + + if (!filename || !ALLOWED_RECORDING_EXTENSIONS.has(ext)) { + res.status(400).json({ success: false, message: 'Invalid recording filename' }); + return; + } + + const filePath = path.join(recordingRoot, meetingId, filename); + if (!isPathInside(recordingRoot, filePath) || !fs.existsSync(filePath)) { + res.status(404).json({ success: false, message: 'Recording not found' }); + return; + } + + const stat = fs.statSync(filePath); + const range = req.headers.range; + const contentType = ext === '.mp4' ? 'video/mp4' : 'video/webm'; + + if (!range) { + res.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes' + }); + fs.createReadStream(filePath).pipe(res); + return; + } + + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; + + if (Number.isNaN(start) || Number.isNaN(end) || start >= stat.size || end >= stat.size || start > end) { + res.status(416).set('Content-Range', `bytes */${stat.size}`).end(); + return; + } + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': contentType + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + }); + app.get('/api/recordings/:meetingId/:filename/download', (req: express.Request, res: express.Response) => { const meetingId = sanitizePathSegment(req.params.meetingId, 'unknown'); const filename = sanitizePathSegment(req.params.filename, '');