diff --git a/client/public/call/store.js b/client/public/call/store.js
index 87bd5fa..ea83db1 100644
--- a/client/public/call/store.js
+++ b/client/public/call/store.js
@@ -164,9 +164,14 @@ class CallStateManager {
}
async uploadRecording({ blob, filename }) {
const formData = new FormData();
+ const people = this.buildRecordingPeopleMetadata();
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
formData.append('userId', this.state.session.localUser.id || '');
formData.append('filename', filename);
+ if (people.host) {
+ formData.append('host', JSON.stringify(people.host));
+ }
+ formData.append('participants', JSON.stringify(people.participants));
formData.append('recording', blob, filename);
const response = await fetch('/api/recordings', {
@@ -181,6 +186,46 @@ class CallStateManager {
return responseBody;
}
+ buildRecordingPeopleMetadata() {
+ const localUser = this.state.session.localUser || {};
+ const remoteUser = this.state.session.remoteUser || {};
+ const members = Object.entries(this.state.participants || {}).map(([participantId, participant]) => (
+ this._buildRecordingPerson(participant, participant.role || 'participant', participantId)
+ ));
+ const remoteHost = members.find(member => member.role === 'host');
+ const localPerson = this._buildRecordingPerson(
+ localUser,
+ this.role === 'host' || localUser.isHost ? 'host' : 'participant',
+ this.selfParticipantId || (this.role === 'host' ? 'host' : 'local')
+ );
+
+ if (localPerson.role === 'host') {
+ return {
+ host: localPerson,
+ participants: members.filter(member => member.role !== 'host')
+ };
+ }
+
+ return {
+ host: remoteHost || this._buildRecordingPerson(remoteUser, 'host', 'host'),
+ participants: [
+ localPerson,
+ ...members.filter(member => member.role !== 'host' && member.participantId !== localPerson.participantId)
+ ]
+ };
+ }
+ _buildRecordingPerson(user = {}, role = 'participant', participantId = '') {
+ return {
+ participantId,
+ userId: user.id || user.userId || '',
+ id: user.id || user.userId || '',
+ name: user.name || '',
+ avatar: user.avatar || '',
+ role,
+ status: user.status || '',
+ mediaState: user.mediaState ? { ...user.mediaState } : undefined
+ };
+ }
async _updateLocalMediaRefactored(mediaType, value) {
if (mediaType === 'video' && value) {
await this._enableLocalVideo();
diff --git a/client/public/recordings/index.html b/client/public/recordings/index.html
index 0b43715..33520df 100644
--- a/client/public/recordings/index.html
+++ b/client/public/recordings/index.html
@@ -56,11 +56,31 @@
-
+
@@ -127,6 +147,8 @@
| 文件 |
会议 |
+ 房主 |
+ 参与者 |
大小 |
上传时间 |
操作 |
@@ -179,7 +201,7 @@
-
+
diff --git a/client/public/recordings/recordings-admin.js b/client/public/recordings/recordings-admin.js
index 4e636ab..b0e507c 100644
--- a/client/public/recordings/recordings-admin.js
+++ b/client/public/recordings/recordings-admin.js
@@ -9,6 +9,10 @@ const elements = {
refreshBtn: document.getElementById('refreshBtn'),
searchInput: document.getElementById('searchInput'),
typeFilter: document.getElementById('typeFilter'),
+ typeFilterControl: document.getElementById('typeFilterControl'),
+ typeFilterButton: document.getElementById('typeFilterButton'),
+ typeFilterText: document.getElementById('typeFilterText'),
+ typeFilterMenu: document.getElementById('typeFilterMenu'),
clearSearchBtn: document.getElementById('clearSearchBtn'),
uploadForm: document.getElementById('uploadForm'),
uploadBtn: document.getElementById('uploadBtn'),
@@ -41,6 +45,12 @@ const elements = {
notificationText: document.getElementById('notificationText')
};
+const typeFilterLabels = {
+ all: '全部格式',
+ mp4: 'MP4',
+ webm: 'WebM'
+};
+
function recordingKey(recording) {
return `${recording.meetingId}/${recording.filename}`;
}
@@ -89,6 +99,87 @@ function formatDate(value) {
});
}
+function getPersonId(person) {
+ return person?.userId || person?.id || '';
+}
+
+function getPersonName(person) {
+ return person?.name || getPersonId(person) || '-';
+}
+
+function getRecordingHost(recording) {
+ return recording.host || (recording.userId ? {
+ userId: recording.userId,
+ id: recording.userId,
+ role: 'host'
+ } : null);
+}
+
+function getRecordingParticipants(recording) {
+ return Array.isArray(recording.participants) ? recording.participants : [];
+}
+
+function getPeopleSearchText(recording) {
+ const host = getRecordingHost(recording);
+ const participants = getRecordingParticipants(recording);
+ return [
+ host?.participantId,
+ host?.userId,
+ host?.id,
+ host?.name,
+ ...participants.flatMap(participant => [
+ participant.participantId,
+ participant.userId,
+ participant.id,
+ participant.name
+ ])
+ ].filter(Boolean).join(' ');
+}
+
+function renderPersonSummary(person) {
+ if (!person) {
+ return '-';
+ }
+
+ const name = getPersonName(person);
+ const id = getPersonId(person);
+ return id && id !== name ? `${name} (${id})` : name;
+}
+
+function renderPeopleList(people) {
+ if (!people.length) {
+ return '暂无参与者
';
+ }
+
+ return people.map((person) => `
+
+
})
+
+ ${escapeHtml(getPersonName(person))}
+ ${escapeHtml(getPersonId(person) || person.participantId || '-')}
+
+
+ `).join('');
+}
+
+function setTypeFilter(value) {
+ const nextValue = typeFilterLabels[value] ? value : 'all';
+ elements.typeFilter.value = nextValue;
+ elements.typeFilterText.textContent = typeFilterLabels[nextValue];
+
+ elements.typeFilterMenu.querySelectorAll('[data-type-value]').forEach((option) => {
+ const isActive = option.dataset.typeValue === nextValue;
+ option.classList.toggle('is-active', isActive);
+ option.setAttribute('aria-selected', String(isActive));
+ });
+}
+
+function setTypeFilterMenuOpen(isOpen) {
+ elements.typeFilterControl.classList.toggle('is-open', isOpen);
+ elements.typeFilterMenu.classList.toggle('hidden', !isOpen);
+ elements.typeFilterButton.setAttribute('aria-expanded', String(isOpen));
+}
+
function showNotification(message, isError = false) {
elements.notificationText.textContent = message;
elements.notification.classList.toggle('recordings-notification-error', isError);
@@ -143,7 +234,8 @@ function applyFilters() {
recording.meetingId,
recording.filename,
recording.originalFilename,
- recording.userId
+ recording.userId,
+ getPeopleSearchText(recording)
].join(' ').toLowerCase();
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
});
@@ -169,6 +261,8 @@ function renderTable() {
const key = recordingKey(recording);
const active = key === state.selectedKey ? 'recordings-row-active' : '';
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
+ const host = getRecordingHost(recording);
+ const participants = getRecordingParticipants(recording);
return `
@@ -182,6 +276,8 @@ function renderTable() {
| ${escapeHtml(recording.meetingId)} |
+ ${escapeHtml(renderPersonSummary(host))} |
+ ${participants.length} |
${formatBytes(recording.size)} |
${formatDate(recording.uploadedAt)} |
@@ -222,6 +318,8 @@ function selectRecording(recording) {
}
state.selectedKey = recordingKey(recording);
+ const host = getRecordingHost(recording);
+ const participants = getRecordingParticipants(recording);
elements.previewVideo.src = recording.streamUrl;
elements.previewPlaceholder.classList.add('hidden');
elements.previewTitle.textContent = recording.originalFilename || recording.filename;
@@ -232,6 +330,14 @@ function selectRecording(recording) {
大小${formatBytes(recording.size)}
用户 ID${escapeHtml(recording.userId || '-')}
上传时间${formatDate(recording.uploadedAt)}
+
+ 房主
+ ${renderPeopleList(host ? [host] : [])}
+
+
+ 参与者 (${participants.length})
+ ${renderPeopleList(participants)}
+
@@ -349,9 +455,32 @@ function bindEvents() {
elements.refreshBtn.addEventListener('click', loadRecordings);
elements.searchInput.addEventListener('input', applyFilters);
elements.typeFilter.addEventListener('change', applyFilters);
+ elements.typeFilterButton.addEventListener('click', () => {
+ setTypeFilterMenuOpen(!elements.typeFilterControl.classList.contains('is-open'));
+ });
+ elements.typeFilterMenu.addEventListener('click', (event) => {
+ const option = event.target.closest('[data-type-value]');
+ if (!option) {
+ return;
+ }
+
+ setTypeFilter(option.dataset.typeValue);
+ setTypeFilterMenuOpen(false);
+ applyFilters();
+ });
+ document.addEventListener('click', (event) => {
+ if (!elements.typeFilterControl.contains(event.target)) {
+ setTypeFilterMenuOpen(false);
+ }
+ });
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ setTypeFilterMenuOpen(false);
+ }
+ });
elements.clearSearchBtn.addEventListener('click', () => {
elements.searchInput.value = '';
- elements.typeFilter.value = 'all';
+ setTypeFilter('all');
applyFilters();
});
elements.recordingFile.addEventListener('change', () => {
@@ -397,4 +526,5 @@ function bindEvents() {
}
bindEvents();
+setTypeFilter(elements.typeFilter.value);
loadRecordings();
diff --git a/client/public/styles/style.css b/client/public/styles/style.css
index 3654a8c..0aa58d6 100644
--- a/client/public/styles/style.css
+++ b/client/public/styles/style.css
@@ -266,11 +266,13 @@ body {
}
.recordings-toolbar {
+ position: relative;
+ z-index: 50;
min-height: 76px;
border-radius: 16px;
padding: 14px;
display: grid;
- grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px;
+ grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 152px;
align-items: center;
gap: 12px;
}
@@ -296,7 +298,6 @@ body {
}
.recordings-search,
-.recordings-select,
.recordings-input {
height: 42px;
border-radius: 12px;
@@ -327,13 +328,118 @@ body {
color: #64748b;
}
-.recordings-select {
- padding: 0 12px;
- outline: 0;
+.recordings-format-filter {
+ position: relative;
+ z-index: 20;
+ min-width: 152px;
}
-.recordings-select option {
- color: #0f172a;
+.recordings-select-native {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 42px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.recordings-filter-trigger {
+ width: 100%;
+ height: 42px;
+ padding: 0 12px 0 14px;
+ border: 1px solid rgba(129, 140, 248, 0.45);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.94));
+ color: #e5e7eb;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 14px;
+ font-weight: 700;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(2, 6, 23, 0.24);
+}
+
+.recordings-filter-trigger i {
+ color: #c7d2fe;
+ font-size: 12px;
+ transition: transform 0.2s ease;
+}
+
+.recordings-filter-trigger:hover,
+.recordings-format-filter.is-open .recordings-filter-trigger {
+ border-color: rgba(165, 180, 252, 0.9);
+ background: linear-gradient(180deg, rgba(49, 46, 129, 0.72), rgba(30, 41, 59, 0.96));
+}
+
+.recordings-filter-trigger:focus-visible {
+ border-color: rgba(129, 140, 248, 0.95);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.1);
+}
+
+.recordings-format-filter.is-open .recordings-filter-trigger i {
+ transform: rotate(180deg);
+}
+
+.recordings-filter-menu {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ left: 0;
+ z-index: 40;
+ padding: 6px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 14px;
+ background: rgba(15, 23, 42, 0.98);
+ box-shadow: 0 18px 40px rgba(2, 6, 23, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+}
+
+.recordings-filter-option {
+ position: relative;
+ width: 100%;
+ min-height: 50px;
+ padding: 8px 34px 8px 10px;
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ color: #e2e8f0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 2px;
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.18s, color 0.18s;
+}
+
+.recordings-filter-option span {
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.recordings-filter-option small {
+ color: #64748b;
+ font-size: 11px;
+ line-height: 1;
+}
+
+.recordings-filter-option:hover,
+.recordings-filter-option.is-active {
+ background: rgba(79, 70, 229, 0.22);
+ color: #ffffff;
+}
+
+.recordings-filter-option.is-active::after {
+ content: "\f00c";
+ position: absolute;
+ top: 50%;
+ right: 12px;
+ transform: translateY(-50%);
+ color: #a5b4fc;
+ font-family: "Font Awesome 6 Free";
+ font-size: 12px;
+ font-weight: 900;
}
.recordings-content {
@@ -415,7 +521,6 @@ body {
}
.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);
@@ -534,11 +639,13 @@ body {
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 th:nth-child(1) { width: 28%; }
+.recordings-table th:nth-child(2) { width: 12%; }
+.recordings-table th:nth-child(3) { width: 15%; }
+.recordings-table th:nth-child(4) { width: 8%; }
+.recordings-table th:nth-child(5) { width: 9%; }
+.recordings-table th:nth-child(6) { width: 14%; }
+.recordings-table th:nth-child(7) { width: 14%; }
.recordings-table tbody tr {
transition: background 0.2s;
@@ -670,6 +777,53 @@ body {
word-break: break-all;
}
+.recordings-people-section {
+ padding: 12px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.recordings-people-title {
+ margin-bottom: 10px;
+ color: #94a3b8;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.recordings-person {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 0;
+}
+
+.recordings-person img {
+ width: 34px;
+ height: 34px;
+ border-radius: 999px;
+ object-fit: cover;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.recordings-person strong,
+.recordings-person span {
+ display: block;
+ max-width: 230px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.recordings-person strong {
+ color: #ffffff;
+ font-size: 13px;
+}
+
+.recordings-person span,
+.recordings-person-empty {
+ color: #64748b;
+ font-size: 12px;
+}
+
.recordings-preview-actions {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -738,7 +892,7 @@ body {
}
.recordings-search,
- .recordings-select {
+ .recordings-format-filter {
grid-column: 1 / -1;
}
@@ -755,6 +909,6 @@ body {
}
.recordings-table {
- min-width: 760px;
+ min-width: 920px;
}
}
diff --git a/src/server.ts b/src/server.ts
index 91a3fc8..c890bf2 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -27,10 +27,23 @@ type RecordingMetadata = {
mimetype?: string;
size?: number;
userId?: string;
+ host?: RecordingPerson;
+ participants?: RecordingPerson[];
uploadedAt?: string;
updatedAt?: string;
};
+type RecordingPerson = {
+ participantId?: string;
+ userId?: string;
+ id?: string;
+ name?: string;
+ avatar?: string;
+ role?: string;
+ status?: string;
+ mediaState?: any;
+};
+
function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase();
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
@@ -101,6 +114,88 @@ function isAllowedRecording(file: any): boolean {
return ext.length > 0 && isCompatibleMime;
}
+function getRecordingMimeTypeFromExtension(ext: string): string {
+ return ext.toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm';
+}
+
+function parseJsonField(value: any): any {
+ if (value === undefined || value === null || value === '') {
+ return undefined;
+ }
+
+ if (typeof value !== 'string') {
+ return value;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch (_error) {
+ return undefined;
+ }
+}
+
+function sanitizeMetadataString(value: any, maxLength = 200): string {
+ if (value === undefined || value === null) {
+ return '';
+ }
+
+ return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength);
+}
+
+function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
+ const parsed = parseJsonField(value);
+ if (!parsed || typeof parsed !== 'object') {
+ return undefined;
+ }
+
+ const person: RecordingPerson = {
+ participantId: sanitizeMetadataString(parsed.participantId || parsed.connectionId, 120),
+ userId: sanitizeMetadataString(parsed.userId || parsed.id, 120),
+ id: sanitizeMetadataString(parsed.id || parsed.userId, 120),
+ name: sanitizeMetadataString(parsed.name, 120),
+ avatar: sanitizeMetadataString(parsed.avatar, 400),
+ role: sanitizeMetadataString(parsed.role || fallbackRole, 40),
+ status: sanitizeMetadataString(parsed.status, 40)
+ };
+
+ if (parsed.mediaState && typeof parsed.mediaState === 'object') {
+ person.mediaState = {
+ audio: Boolean(parsed.mediaState.audio),
+ video: Boolean(parsed.mediaState.video),
+ screenShare: Boolean(parsed.mediaState.screenShare),
+ recording: Boolean(parsed.mediaState.recording),
+ isSpeaking: Boolean(parsed.mediaState.isSpeaking)
+ };
+ }
+
+ return person;
+}
+
+function sanitizeRecordingParticipants(value: any): RecordingPerson[] {
+ const parsed = parseJsonField(value);
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+
+ return parsed
+ .slice(0, 100)
+ .map((participant) => sanitizeRecordingPerson(participant, 'participant'))
+ .filter((participant) => Boolean(participant));
+}
+
+function buildFallbackRecordingHost(userId: string | undefined): RecordingPerson | undefined {
+ const safeUserId = sanitizeMetadataString(userId, 120);
+ if (!safeUserId) {
+ return undefined;
+ }
+
+ return {
+ userId: safeUserId,
+ id: safeUserId,
+ role: 'host'
+ };
+}
+
function readRecordingMetadata(metadataPath: string): RecordingMetadata {
try {
if (!fs.existsSync(metadataPath)) {
@@ -115,11 +210,13 @@ function readRecordingMetadata(metadataPath: string): RecordingMetadata {
}
function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string {
- if (metadata.mimetype) {
- return metadata.mimetype;
+ const ext = path.extname(filename);
+ const mimetype = normalizeMimeType(metadata.mimetype);
+ if (mimetype && mimetype !== 'text/plain' && mimetype !== 'application/octet-stream') {
+ return mimetype;
}
- return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm';
+ return getRecordingMimeTypeFromExtension(ext);
}
function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) {
@@ -129,6 +226,7 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
const metadata = readRecordingMetadata(metadataPath);
const resolvedMeetingId = metadata.meetingId || meetingId;
const resolvedFilename = metadata.filename || filename;
+ const participants = Array.isArray(metadata.participants) ? metadata.participants : [];
return {
id: metadata.id || path.basename(filename, path.extname(filename)),
@@ -138,6 +236,9 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
mimetype: getRecordingMimeType(filename, metadata),
size: stat.size,
userId: metadata.userId || '',
+ host: metadata.host || buildFallbackRecordingHost(metadata.userId),
+ participants,
+ participantCount: participants.length,
uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(),
updatedAt: metadata.updatedAt || stat.mtime.toISOString(),
modifiedAt: stat.mtime.toISOString(),
@@ -394,6 +495,9 @@ export const createServer = (config: Options): express.Express => {
const recordingId = uuid();
const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown');
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`);
+ const userId = sanitizeMetadataString(request.body.userId, 120);
+ const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
+ const participants = sanitizeRecordingParticipants(request.body.participants);
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`;
const meetingDir = path.join(recordingRoot, meetingId);
const finalPath = path.join(meetingDir, finalFilename);
@@ -425,9 +529,11 @@ export const createServer = (config: Options): express.Express => {
meetingId,
filename: finalFilename,
originalFilename,
- mimetype: normalizeMimeType(request.file.mimetype),
+ mimetype: getRecordingMimeTypeFromExtension(ext),
size: request.file.size,
- userId: request.body.userId || '',
+ userId,
+ host,
+ participants,
uploadedAt: new Date().toISOString()
};
fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined);
@@ -516,6 +622,16 @@ export const createServer = (config: Options): express.Express => {
}
const metadata = readRecordingMetadata(targetMetadataPath);
+ const nextUserId = typeof req.body.userId === 'string' ? sanitizeMetadataString(req.body.userId, 120) : metadata.userId || '';
+ const shouldSyncHostFromUserId = !metadata.host
+ || metadata.host.userId === metadata.userId
+ || metadata.host.id === metadata.userId;
+ const nextHost = req.body.host !== undefined
+ ? sanitizeRecordingPerson(req.body.host, 'host') || buildFallbackRecordingHost(nextUserId)
+ : shouldSyncHostFromUserId ? buildFallbackRecordingHost(nextUserId) : metadata.host;
+ const nextParticipants = req.body.participants !== undefined
+ ? sanitizeRecordingParticipants(req.body.participants)
+ : Array.isArray(metadata.participants) ? metadata.participants : [];
const nextMetadata = {
...metadata,
meetingId: nextMeetingId,
@@ -523,8 +639,10 @@ export const createServer = (config: Options): express.Express => {
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'),
+ userId: nextUserId,
+ host: nextHost,
+ participants: nextParticipants,
+ mimetype: metadata.mimetype || getRecordingMimeTypeFromExtension(ext),
size: fs.statSync(targetPath).size,
uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(),
updatedAt: new Date().toISOString()
|