新增视频管理
This commit is contained in:
304
src/server.ts
304
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, '');
|
||||
|
||||
Reference in New Issue
Block a user