backend: Add recording request validation middleware and refactor recording metadata handling
This commit is contained in:
parent
4e8c3ebcdf
commit
e69f1dfb4b
@ -156,15 +156,45 @@ paths:
|
|||||||
For example: "roomName,preferences". Only allowed fields will be returned.
|
For example: "roomName,preferences". Only allowed fields will be returned.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: The page number for pagination (default is 1).
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 1
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: The number of rooms per page (default is 10).
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 10
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successfully retrieved the list of OpenVidu Meet rooms
|
description: Successfully retrieved the list of OpenVidu Meet rooms
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: object
|
||||||
items:
|
properties:
|
||||||
$ref: '#/components/schemas/OpenViduMeetRoom'
|
rooms:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OpenViduMeetRoom'
|
||||||
|
pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
totalItems:
|
||||||
|
type: integer
|
||||||
|
description: Total number of rooms.
|
||||||
|
totalPages:
|
||||||
|
type: integer
|
||||||
|
description: Total number of pages.
|
||||||
|
currentPage:
|
||||||
|
type: integer
|
||||||
|
description: Current page number.
|
||||||
|
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized — The API key is missing or invalid
|
description: Unauthorized — The API key is missing or invalid
|
||||||
content:
|
content:
|
||||||
@ -398,7 +428,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/OpenViduMeetRecording'
|
$ref: '#/components/schemas/MeetRecording'
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized — The API key is missing or invalid
|
description: Unauthorized — The API key is missing or invalid
|
||||||
content:
|
content:
|
||||||
@ -469,23 +499,44 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: |
|
description: |
|
||||||
The status of the recordings to retrieve. Possible values are "STARTING", "STARTED", "STOPPING", "STOPPED", "FAILED" and "READY".
|
Filter recordings by their status.
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- `STARTING`
|
||||||
|
- `ACTIVE`
|
||||||
|
- `ENDING`
|
||||||
|
- `COMPLETE`
|
||||||
|
- `FAILED`
|
||||||
|
- `ABORTED`
|
||||||
|
- `LIMITED_REACHED`
|
||||||
|
|
||||||
|
You can provide multiple statuses as a comma-separated list (e.g., `status=ACTIVE,FAILED`).
|
||||||
|
If not specified, recordings with any status will be returned.
|
||||||
|
|
||||||
|
> ⚠️ **Note:** Using this filter with multiple values or partial matches may impact performance for large datasets.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- name: page
|
- name: roomId
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: The page number for pagination (default is 1).
|
description: |
|
||||||
|
The unique identifier of the room for which you want to retrieve recordings.
|
||||||
|
If not provided, recordings from all rooms will be returned.
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: string
|
||||||
default: 1
|
- name: maxItems
|
||||||
- name: limit
|
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: The number of recordings per page (default is 10). Maximum is 100.
|
description: The number of recordings per page (default is 10). Maximum is 100.
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
default: 10
|
default: 10
|
||||||
|
- name: nextPageToken
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: The token to retrieve the next page of recordings.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successfully retrieved the list of OpenVidu Meet recordings
|
description: Successfully retrieved the list of OpenVidu Meet recordings
|
||||||
@ -497,19 +548,16 @@ paths:
|
|||||||
recordings:
|
recordings:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/OpenViduMeetRecording'
|
$ref: '#/components/schemas/MeetRecording'
|
||||||
pagination:
|
pagination:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
totalItems:
|
isTruncated:
|
||||||
type: integer
|
type: boolean
|
||||||
description: Total number of recordings.
|
description: Indicates if there are more recordings to retrieve.
|
||||||
totalPages:
|
nextPageToken:
|
||||||
type: integer
|
type: string
|
||||||
description: Total number of pages.
|
description: The token to retrieve the next page of recordings.
|
||||||
currentPage:
|
|
||||||
type: integer
|
|
||||||
description: Current page number.
|
|
||||||
|
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized — The API key is missing or invalid
|
description: Unauthorized — The API key is missing or invalid
|
||||||
@ -642,7 +690,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/OpenViduMeetRecording'
|
$ref: '#/components/schemas/MeetRecording'
|
||||||
|
|
||||||
'401':
|
'401':
|
||||||
description: Unauthorized — The API key is missing or invalid
|
description: Unauthorized — The API key is missing or invalid
|
||||||
@ -1067,24 +1115,19 @@ components:
|
|||||||
description: >
|
description: >
|
||||||
The URL for the viewer to join the room. The viewer has read-only permissions to watch the room
|
The URL for the viewer to join the room. The viewer has read-only permissions to watch the room
|
||||||
and participants.
|
and participants.
|
||||||
OpenViduMeetRecording:
|
MeetRecording:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
recordingId:
|
||||||
type: string
|
type: string
|
||||||
example: 'recording_123456'
|
example: 'room-123--EG_XYZ--XX445'
|
||||||
description: >
|
description: >
|
||||||
The unique identifier of the recording.
|
The unique identifier of the recording.
|
||||||
roomName:
|
roomId:
|
||||||
type: string
|
type: string
|
||||||
example: 'OpenVidu-123456'
|
example: 'room-123'
|
||||||
description: >
|
description: >
|
||||||
The name of the room where the recording was made.
|
The ID of the room where the recording was made.
|
||||||
# roomId:
|
|
||||||
# type: string
|
|
||||||
# example: '123456'
|
|
||||||
# description: >
|
|
||||||
# The unique identifier of the room where the recording was made.
|
|
||||||
outputMode:
|
outputMode:
|
||||||
type: string
|
type: string
|
||||||
example: 'COMPOSED'
|
example: 'COMPOSED'
|
||||||
@ -1092,20 +1135,20 @@ components:
|
|||||||
The output mode of the recording. Possible values are "COMPOSED".
|
The output mode of the recording. Possible values are "COMPOSED".
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
example: 'READY'
|
example: 'ACTIVE'
|
||||||
description: >
|
description: >
|
||||||
The status of the recording. Possible values are "STARTING", "STARTED", "STOPPING", "STOPPED", "FAILED" and "READY".
|
The status of the recording. Possible values are "STARTING", "ACTIVE", "ENDING", "COMPLETE", "FAILED", "ABORTED" and "LIMITED_REACHED".
|
||||||
filename:
|
filename:
|
||||||
type: string
|
type: string
|
||||||
example: 'recording_123456.mp4'
|
example: 'room-123--XX445.mp4'
|
||||||
description: >
|
description: >
|
||||||
The name of the recording file.
|
The name of the recording file.
|
||||||
startedAt:
|
startDate:
|
||||||
type: number
|
type: number
|
||||||
example: 1620000000000
|
example: 1620000000000
|
||||||
description: >
|
description: >
|
||||||
The date when the recording was started in milliseconds since the Unix epoch.
|
The date when the recording was started in milliseconds since the Unix epoch.
|
||||||
endedAt:
|
endDate:
|
||||||
type: number
|
type: number
|
||||||
example: 1620000000000
|
example: 1620000000000
|
||||||
description: >
|
description: >
|
||||||
@ -1120,6 +1163,21 @@ components:
|
|||||||
example: 1024
|
example: 1024
|
||||||
description: >
|
description: >
|
||||||
The size of the recording file in bytes.
|
The size of the recording file in bytes.
|
||||||
|
errorCode:
|
||||||
|
type: number
|
||||||
|
example: 100
|
||||||
|
description: >
|
||||||
|
The error code of the recording.
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
example: 'error'
|
||||||
|
description: >
|
||||||
|
The error message of the recording.
|
||||||
|
details:
|
||||||
|
type: string
|
||||||
|
example: 'Stopped using API'
|
||||||
|
description: >
|
||||||
|
Additional details about the recording.
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
@ -32,13 +32,10 @@ export const getRecordings = async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
logger.info('Getting all recordings');
|
logger.info('Getting all recordings');
|
||||||
|
|
||||||
const { status, page, limit } = req.query;
|
const queryParams = req.query;
|
||||||
|
|
||||||
// const continuationToken = req.query.continuationToken as string;
|
const response = await recordingService.getAllRecordings(queryParams);
|
||||||
const response = await recordingService.getAllRecordings();
|
return res.status(200).json(response);
|
||||||
return res
|
|
||||||
.status(200)
|
|
||||||
.json({ recordings: response.recordingInfo, continuationToken: response.continuationToken });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OpenViduMeetError) {
|
if (error instanceof OpenViduMeetError) {
|
||||||
logger.error(`Error getting all recordings: ${error.message}`);
|
logger.error(`Error getting all recordings: ${error.message}`);
|
||||||
@ -58,8 +55,7 @@ export const bulkDeleteRecordings = async (req: Request, res: Response) => {
|
|||||||
const recordingService = container.get(RecordingService);
|
const recordingService = container.get(RecordingService);
|
||||||
|
|
||||||
// TODO: Check role to determine if the request is from an admin or a participant
|
// TODO: Check role to determine if the request is from an admin or a participant
|
||||||
const role = req.body.payload.metadata.role;
|
await recordingService.bulkDeleteRecordings(recordingIds);
|
||||||
await recordingService.bulkDeleteRecordings(recordingIds, role);
|
|
||||||
|
|
||||||
return res.status(204).json();
|
return res.status(204).json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -121,8 +117,7 @@ export const deleteRecording = async (req: Request, res: Response) => {
|
|||||||
const recordingService = container.get(RecordingService);
|
const recordingService = container.get(RecordingService);
|
||||||
|
|
||||||
// TODO: Check role to determine if the request is from an admin or a participant
|
// TODO: Check role to determine if the request is from an admin or a participant
|
||||||
const role = req.body.payload.metadata.role;
|
const recordingInfo = await recordingService.deleteRecording(recordingId);
|
||||||
const recordingInfo = await recordingService.deleteRecording(recordingId, role);
|
|
||||||
|
|
||||||
return res.status(204).json(recordingInfo);
|
return res.status(204).json(recordingInfo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -11,9 +11,10 @@ export class RecordingHelper {
|
|||||||
const startDateMs = RecordingHelper.extractStartDate(egressInfo);
|
const startDateMs = RecordingHelper.extractStartDate(egressInfo);
|
||||||
const endDateMs = RecordingHelper.extractEndDate(egressInfo);
|
const endDateMs = RecordingHelper.extractEndDate(egressInfo);
|
||||||
const filename = RecordingHelper.extractFilename(egressInfo);
|
const filename = RecordingHelper.extractFilename(egressInfo);
|
||||||
|
const uid = RecordingHelper.extractUidFromFilename(filename);
|
||||||
const { egressId, roomName, errorCode, error, details } = egressInfo;
|
const { egressId, roomName, errorCode, error, details } = egressInfo;
|
||||||
return {
|
return {
|
||||||
recordingId: egressId,
|
recordingId: `${roomName}--${egressId}--${uid}`,
|
||||||
roomId: roomName,
|
roomId: roomName,
|
||||||
outputMode,
|
outputMode,
|
||||||
status,
|
status,
|
||||||
@ -76,16 +77,19 @@ export class RecordingHelper {
|
|||||||
return MeetRecordingOutputMode.COMPOSED;
|
return MeetRecordingOutputMode.COMPOSED;
|
||||||
}
|
}
|
||||||
|
|
||||||
static extractFilename(recordingInfo: MeetRecordingInfo): string | undefined;
|
/**
|
||||||
|
* Extracts the filename/path for storing the recording.
|
||||||
|
* For EgressInfo, returns the last segment of the fileResults.
|
||||||
|
* For MeetRecordingInfo, returns a combination of roomId and filename.
|
||||||
|
*/
|
||||||
|
static extractFilename(recordingInfo: MeetRecordingInfo): string;
|
||||||
|
|
||||||
static extractFilename(egressInfo: EgressInfo): string | undefined;
|
static extractFilename(egressInfo: EgressInfo): string;
|
||||||
|
|
||||||
static extractFilename(info: MeetRecordingInfo | EgressInfo): string | undefined {
|
|
||||||
if (!info) return undefined;
|
|
||||||
|
|
||||||
|
static extractFilename(info: MeetRecordingInfo | EgressInfo): string {
|
||||||
if ('request' in info) {
|
if ('request' in info) {
|
||||||
// EgressInfo
|
// EgressInfo
|
||||||
return info.fileResults?.[0]?.filename.split('/').pop();
|
return info.fileResults[0]!.filename.split('/').pop()!;
|
||||||
} else {
|
} else {
|
||||||
// MeetRecordingInfo
|
// MeetRecordingInfo
|
||||||
const { filename, roomId } = info;
|
const { filename, roomId } = info;
|
||||||
@ -94,6 +98,31 @@ export class RecordingHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the UID from the given filename.
|
||||||
|
*
|
||||||
|
* @param filename room-123--{uid}.mp4
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
static extractUidFromFilename(filename: string): string {
|
||||||
|
const uidWithExtension = filename.split('--')[1];
|
||||||
|
return uidWithExtension.split('.')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the room name, egressId, and UID from the given recordingId.
|
||||||
|
* @param recordingId ${roomId}--${egressId}--${uid}
|
||||||
|
*/
|
||||||
|
static extractInfoFromRecordingId(recordingId: string): { roomId: string; egressId: string; uid: string } {
|
||||||
|
const [roomId, egressId, uid] = recordingId.split('--');
|
||||||
|
|
||||||
|
if (!roomId || !egressId || !uid) {
|
||||||
|
throw new Error(`Invalid recordingId format: ${recordingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roomId, egressId, uid };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the duration from the given egress information.
|
* Extracts the duration from the given egress information.
|
||||||
* If the duration is not available, it returns 0.
|
* If the duration is not available, it returns 0.
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export * from './content-type.middleware.js';
|
|||||||
export * from './openapi.middleware.js';
|
export * from './openapi.middleware.js';
|
||||||
export * from './request-validators/participant-validator.middleware.js';
|
export * from './request-validators/participant-validator.middleware.js';
|
||||||
export * from './request-validators/room-validator.middleware.js';
|
export * from './request-validators/room-validator.middleware.js';
|
||||||
|
export * from './request-validators/recording-validator.middleware.js';
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const RecordingPostRequestSchema = z.object({
|
||||||
|
roomId: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'roomId is required and cannot be empty' })
|
||||||
|
.transform((val) => val.trim().replace(/\s+/g, '-'))
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRecordingsSchema = z.object({
|
||||||
|
maxItems: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.transform((val = 10) => (val > 100 ? 100 : val))
|
||||||
|
.default(10),
|
||||||
|
status: z.string().optional(),
|
||||||
|
roomId: z.string().optional(),
|
||||||
|
nextPageToken: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to validate the recording post request.
|
||||||
|
*
|
||||||
|
* This middleware uses the `RecordingPostRequestSchema` to validate the request body.
|
||||||
|
* If the validation fails, it rejects the request with an error response.
|
||||||
|
* If the validation succeeds, it passes control to the next middleware or route handler.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const withValidRecordingPostRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const { success, error } = RecordingPostRequestSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return rejectRequest(res, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withValidGetRecordingsRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const { success, error, data } = getRecordingsSchema.safeParse(req.query);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return rejectRequest(res, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.query = {
|
||||||
|
...data,
|
||||||
|
maxItems: data.maxItems?.toString()
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const { success, error } = z
|
||||||
|
.array(z.string().min(1, { message: 'recordingIds must be a non-empty string' }))
|
||||||
|
.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return rejectRequest(res, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectRequest = (res: Response, error: z.ZodError) => {
|
||||||
|
const errors = error.errors.map((error) => ({
|
||||||
|
field: error.path.join('.'),
|
||||||
|
message: error.message
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(errors);
|
||||||
|
|
||||||
|
return res.status(422).json({
|
||||||
|
error: 'Unprocessable Entity',
|
||||||
|
message: 'Invalid request body',
|
||||||
|
details: errors
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -150,8 +150,8 @@ export class LivekitWebhookService {
|
|||||||
this.logger.debug(`Processing recording ${webhookAction} webhook.`);
|
this.logger.debug(`Processing recording ${webhookAction} webhook.`);
|
||||||
|
|
||||||
const recordingInfo: MeetRecordingInfo = RecordingHelper.toRecordingInfo(egressInfo);
|
const recordingInfo: MeetRecordingInfo = RecordingHelper.toRecordingInfo(egressInfo);
|
||||||
const metadataPath = this.generateMetadataPath(recordingInfo);
|
|
||||||
const { roomId, recordingId, status } = recordingInfo;
|
const { roomId, recordingId, status } = recordingInfo;
|
||||||
|
const metadataPath = this.generateMetadataPath(recordingId);
|
||||||
|
|
||||||
this.logger.debug(`Recording '${recordingId}' for room '${roomId}' is in status '${status}'`);
|
this.logger.debug(`Recording '${recordingId}' for room '${roomId}' is in status '${status}'`);
|
||||||
|
|
||||||
@ -189,10 +189,9 @@ export class LivekitWebhookService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected generateMetadataPath(recordingInfo: MeetRecordingInfo): string {
|
protected generateMetadataPath(recordingId: string): string {
|
||||||
const { roomId, recordingId } = recordingInfo;
|
const { roomId, egressId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||||
// Remove file extension from filename
|
|
||||||
const recordingFilename = recordingInfo.filename?.split('.')[0];
|
return `${MEET_S3_RECORDINGS_PREFIX}/.metadata/${roomId}/${egressId}/${uid}.json`;
|
||||||
return `${MEET_S3_RECORDINGS_PREFIX}/.metadata/${roomId}/${recordingFilename}-${recordingId}.json`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,13 @@ import { RedisLockName } from '../models/index.js';
|
|||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { OpenViduComponentsAdapterHelper } from '../helpers/ov-components-adapter.helper.js';
|
import { OpenViduComponentsAdapterHelper } from '../helpers/ov-components-adapter.helper.js';
|
||||||
|
|
||||||
|
type GetAllRecordingsParams = {
|
||||||
|
maxItems?: number;
|
||||||
|
nextPageToken?: string;
|
||||||
|
roomId?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class RecordingService {
|
export class RecordingService {
|
||||||
protected readonly RECORDING_ACTIVE_LOCK_TTL = ms('6h');
|
protected readonly RECORDING_ACTIVE_LOCK_TTL = ms('6h');
|
||||||
@ -32,39 +39,41 @@ export class RecordingService {
|
|||||||
@inject(LoggerService) protected logger: LoggerService
|
@inject(LoggerService) protected logger: LoggerService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async startRecording(roomName: string): Promise<MeetRecordingInfo> {
|
async startRecording(roomId: string): Promise<MeetRecordingInfo> {
|
||||||
let acquiredLock: RedisLock | null = null;
|
let acquiredLock: RedisLock | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Attempt to acquire lock.
|
// Attempt to acquire lock.
|
||||||
// Note: using a high TTL to prevent expiration during a long recording.
|
// Note: using a high TTL to prevent expiration during a long recording.
|
||||||
acquiredLock = await this.acquireRoomRecordingActiveLock(roomName);
|
acquiredLock = await this.acquireRoomRecordingActiveLock(roomId);
|
||||||
|
|
||||||
if (!acquiredLock) throw errorRecordingAlreadyStarted(roomName);
|
if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId);
|
||||||
|
|
||||||
const room = await this.roomService.getOpenViduRoom(roomName);
|
const room = await this.roomService.getOpenViduRoom(roomId);
|
||||||
|
|
||||||
if (!room) throw errorRoomNotFound(roomName);
|
if (!room) throw errorRoomNotFound(roomId);
|
||||||
|
|
||||||
const options = this.generateCompositeOptionsFromRequest();
|
const options = this.generateCompositeOptionsFromRequest();
|
||||||
const output = this.generateFileOutputFromRequest(roomName);
|
const output = this.generateFileOutputFromRequest(roomId);
|
||||||
const egressInfo = await this.livekitService.startRoomComposite(roomName, output, options);
|
const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options);
|
||||||
|
|
||||||
// Return recording info without releasing the lock here,
|
// Return recording info without releasing the lock here,
|
||||||
// as it will be released in handleEgressEnded on successful completion.
|
// as it will be released in handleEgressEnded on successful completion.
|
||||||
return RecordingHelper.toRecordingInfo(egressInfo);
|
return RecordingHelper.toRecordingInfo(egressInfo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error starting recording in room ${roomName}: ${error}`);
|
this.logger.error(`Error starting recording in room ${roomId}: ${error}`);
|
||||||
|
|
||||||
if (acquiredLock) await this.releaseRoomRecordingActiveLock(roomName);
|
if (acquiredLock) await this.releaseRoomRecordingActiveLock(roomId);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopRecording(egressId: string): Promise<MeetRecordingInfo> {
|
async stopRecording(recordingId: string): Promise<MeetRecordingInfo> {
|
||||||
try {
|
try {
|
||||||
const egressArray = await this.livekitService.getActiveEgress(undefined, egressId);
|
const { roomId, egressId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||||
|
|
||||||
|
const egressArray = await this.livekitService.getActiveEgress(roomId, egressId);
|
||||||
|
|
||||||
if (egressArray.length === 0) {
|
if (egressArray.length === 0) {
|
||||||
throw errorRecordingNotFound(egressId);
|
throw errorRecordingNotFound(egressId);
|
||||||
@ -74,121 +83,144 @@ export class RecordingService {
|
|||||||
|
|
||||||
return RecordingHelper.toRecordingInfo(egressInfo);
|
return RecordingHelper.toRecordingInfo(egressInfo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error stopping recording ${egressId}: ${error}`);
|
this.logger.error(`Error stopping recording ${recordingId}: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement deleteRecording method
|
|
||||||
async deleteRecording(egressId: string, role: string): Promise<MeetRecordingInfo> {
|
|
||||||
try {
|
|
||||||
const { metadataFilePath, recordingInfo } = await this.getMeetRecordingInfoFromMetadata(egressId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
recordingInfo.status === MeetRecordingStatus.STARTING ||
|
|
||||||
recordingInfo.status === MeetRecordingStatus.ACTIVE ||
|
|
||||||
recordingInfo.status === MeetRecordingStatus.ENDING
|
|
||||||
) {
|
|
||||||
throw errorRecordingNotStopped(egressId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordingPath = RecordingHelper.extractFilename(recordingInfo);
|
|
||||||
|
|
||||||
if (!recordingPath) throw internalError(`Error extracting path from recording ${egressId}`);
|
|
||||||
|
|
||||||
this.logger.info(`Deleting recording from S3 ${recordingPath}`);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.s3Service.deleteObject(metadataFilePath),
|
|
||||||
this.s3Service.deleteObject(recordingPath)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return recordingInfo;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error deleting recording ${egressId}: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement bulkDeleteRecordings method
|
|
||||||
async bulkDeleteRecordings(egressIds: string[], role: string): Promise<MeetRecordingInfo[]> {
|
|
||||||
const promises = egressIds.map((egressId) => this.deleteRecording(egressId, role));
|
|
||||||
return Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the list of all recordings.
|
* Deletes a recording from the S3 bucket based on the provided egress ID.
|
||||||
* @returns A promise that resolves to an array of RecordingInfo objects.
|
*
|
||||||
|
* The recording is deleted only if it is not in progress state (STARTING, ACTIVE, ENDING).
|
||||||
|
* @param recordingId - The egress ID of the recording.
|
||||||
*/
|
*/
|
||||||
//TODO: Implement getAllRecordings method
|
async deleteRecording(recordingId: string): Promise<MeetRecordingInfo> {
|
||||||
async getAllRecordings(): Promise<{ recordingInfo: MeetRecordingInfo[]; continuationToken?: string }> {
|
|
||||||
try {
|
try {
|
||||||
const allEgress = await this.s3Service.listObjects('.metadata', '.json');
|
// Get the recording metada and recording info from the S3 bucket
|
||||||
const promises: Promise<MeetRecordingInfo>[] = [];
|
const { filesToDelete, recordingInfo } = await this.getDeletableRecordingData(recordingId);
|
||||||
|
|
||||||
allEgress.Contents?.forEach((item) => {
|
this.logger.verbose(
|
||||||
if (item?.Key?.includes('.json')) {
|
`Deleting recording from S3. Files: ${filesToDelete.join(', ')} for recordingId ${recordingId}`
|
||||||
|
);
|
||||||
|
await this.s3Service.deleteObjects(filesToDelete);
|
||||||
|
this.logger.info(`Deletion successful for recording ${recordingId}`);
|
||||||
|
|
||||||
|
return recordingInfo;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error deleting recording ${recordingId}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple recordings in bulk from S3.
|
||||||
|
* For each provided egressId, the metadata and recording file are deleted (only if the status is stopped).
|
||||||
|
*
|
||||||
|
* @param egressIds Array of recording identifiers.
|
||||||
|
* @returns An array with the MeetRecordingInfo of the successfully deleted recordings.
|
||||||
|
*/
|
||||||
|
async bulkDeleteRecordings(egressIds: string[]): Promise<MeetRecordingInfo[]> {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
const deletedRecordings: MeetRecordingInfo[] = [];
|
||||||
|
|
||||||
|
for (const egressId of egressIds) {
|
||||||
|
try {
|
||||||
|
const { filesToDelete, recordingInfo } = await this.getDeletableRecordingData(egressId);
|
||||||
|
keysToDelete.push(...filesToDelete);
|
||||||
|
deletedRecordings.push(recordingInfo);
|
||||||
|
this.logger.verbose(`BulkDelete: Prepared recording ${egressId} for deletion.`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`BulkDelete: Error processing recording ${egressId}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToDelete.length > 0) {
|
||||||
|
try {
|
||||||
|
await this.s3Service.deleteObjects(keysToDelete);
|
||||||
|
this.logger.info(`BulkDelete: Successfully deleted ${keysToDelete.length} objects from S3.`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`BulkDelete: Error performing bulk deletion: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`BulkDelete: No eligible recordings found for deletion.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedRecordings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the recording information for a given recording ID.
|
||||||
|
* @param recordingId - The unique identifier of the recording.
|
||||||
|
* @returns A promise that resolves to a MeetRecordingInfo object.
|
||||||
|
*/
|
||||||
|
async getRecording(recordingId: string): Promise<MeetRecordingInfo> {
|
||||||
|
const { recordingInfo } = await this.getMeetRecordingInfoFromMetadata(recordingId);
|
||||||
|
|
||||||
|
return recordingInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a paginated list of all recordings stored in the S3 bucket.
|
||||||
|
*
|
||||||
|
* @param maxItems - The maximum number of items to retrieve in a single request.
|
||||||
|
* @param nextPageToken - (Optional) A token to retrieve the next page of results.
|
||||||
|
* @returns A promise that resolves to an object containing:
|
||||||
|
* - `recordings`: An array of `MeetRecordingInfo` objects representing the recordings.
|
||||||
|
* - `isTruncated`: A boolean indicating whether there are more items to retrieve.
|
||||||
|
* - `nextPageToken`: (Optional) A token to retrieve the next page of results, if available.
|
||||||
|
* @throws Will throw an error if there is an issue retrieving the recordings.
|
||||||
|
*/
|
||||||
|
async getAllRecordings({ maxItems, nextPageToken, roomId, status }: GetAllRecordingsParams): Promise<{
|
||||||
|
recordings: MeetRecordingInfo[];
|
||||||
|
isTruncated: boolean;
|
||||||
|
nextPageToken?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const roomPrefix = roomId ? `/${roomId}` : '';
|
||||||
|
const { Contents, IsTruncated, NextContinuationToken } = await this.s3Service.listObjectsPaginated(
|
||||||
|
`${MEET_S3_RECORDINGS_PREFIX}/.metadata${roomPrefix}`,
|
||||||
|
maxItems,
|
||||||
|
nextPageToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Contents) {
|
||||||
|
this.logger.verbose('No recordings found. Returning an empty array.');
|
||||||
|
return { recordings: [], isTruncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises: Promise<MeetRecordingInfo>[] = [];
|
||||||
|
Contents.forEach((item) => {
|
||||||
|
if (item?.Key && item.Key.endsWith('.json')) {
|
||||||
promises.push(this.s3Service.getObjectAsJson(item.Key) as Promise<MeetRecordingInfo>);
|
promises.push(this.s3Service.getObjectAsJson(item.Key) as Promise<MeetRecordingInfo>);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { recordingInfo: await Promise.all(promises), continuationToken: undefined };
|
let recordings: MeetRecordingInfo[] = await Promise.all(promises);
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error getting recordings: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (status) {
|
||||||
* Retrieves all recordings for a given room.
|
// Filter recordings by status
|
||||||
*
|
const statusArray = status
|
||||||
* @param roomName - The name of the room.
|
.split(',')
|
||||||
* @param roomId - The ID of the room.
|
.map((s) => s.trim())
|
||||||
* @returns A promise that resolves to an array of MeetRecordingInfo objects.
|
.filter(Boolean)
|
||||||
* @throws If there is an error retrieving the recordings.
|
.map((s) => new RegExp(this.sanitizeRegExp(s)));
|
||||||
*/
|
|
||||||
//TODO: Implement getAllRecordingsByRoom method
|
|
||||||
async getAllRecordingsByRoom(roomName: string, roomId: string): Promise<MeetRecordingInfo[]> {
|
|
||||||
try {
|
|
||||||
// Get all recordings that match the room name and room ID from the S3 bucket
|
|
||||||
const roomNameSanitized = this.sanitizeRegExp(roomName);
|
|
||||||
const roomIdSanitized = this.sanitizeRegExp(roomId);
|
|
||||||
// Match the room name and room ID in any order
|
|
||||||
const regexPattern = `${roomNameSanitized}.*${roomIdSanitized}|${roomIdSanitized}.*${roomNameSanitized}\\.json`;
|
|
||||||
const metadatagObject = await this.s3Service.listObjects('.metadata', regexPattern);
|
|
||||||
|
|
||||||
if (!metadatagObject.Contents || metadatagObject.Contents.length === 0) {
|
|
||||||
this.logger.verbose(`No recordings found for room ${roomName}. Returning an empty array.`);
|
recordings = recordings.filter((recording) =>
|
||||||
return [];
|
statusArray.some((regex) => regex.test(recording.status))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises: Promise<MeetRecordingInfo>[] = [];
|
this.logger.info(`Retrieved ${recordings.length} recordings.`);
|
||||||
metadatagObject.Contents?.forEach((item) => {
|
|
||||||
promises.push(this.s3Service.getObjectAsJson(item.Key!) as Promise<MeetRecordingInfo>);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.all(promises);
|
return { recordings, isTruncated: !!IsTruncated, nextPageToken: NextContinuationToken };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error getting recordings: ${error}`);
|
this.logger.error(`Error getting recordings: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: Implement getRecording method
|
|
||||||
async getRecording(egressId: string): Promise<MeetRecordingInfo> {
|
|
||||||
const egressIdSanitized = this.sanitizeRegExp(egressId);
|
|
||||||
const regexPattern = `.*${egressIdSanitized}.*\\.json`;
|
|
||||||
const metadataObject = await this.s3Service.listObjects('.metadata', regexPattern);
|
|
||||||
|
|
||||||
if (!metadataObject.Contents || metadataObject.Contents.length === 0) {
|
|
||||||
throw errorRecordingNotFound(egressId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recording = (await this.s3Service.getObjectAsJson(metadataObject.Contents[0].Key!)) as MeetRecordingInfo;
|
|
||||||
return recording;
|
|
||||||
// return RecordingHelper.toRecordingInfo(recording);
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Implement getRecordingAsStream method
|
//TODO: Implement getRecordingAsStream method
|
||||||
async getRecordingAsStream(
|
async getRecordingAsStream(
|
||||||
recordingId: string,
|
recordingId: string,
|
||||||
@ -196,7 +228,7 @@ export class RecordingService {
|
|||||||
): Promise<{ fileSize: number | undefined; fileStream: Readable; start?: number; end?: number }> {
|
): Promise<{ fileSize: number | undefined; fileStream: Readable; start?: number; end?: number }> {
|
||||||
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
|
const RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
const recordingInfo: MeetRecordingInfo = await this.getRecording(recordingId);
|
const recordingInfo: MeetRecordingInfo = await this.getRecording(recordingId);
|
||||||
const recordingPath = RecordingHelper.extractFilename(recordingInfo);
|
const recordingPath = `${MEET_S3_RECORDINGS_PREFIX}/${RecordingHelper.extractFilename(recordingInfo)}`;
|
||||||
|
|
||||||
if (!recordingPath) throw new Error(`Error extracting path from recording ${recordingId}`);
|
if (!recordingPath) throw new Error(`Error extracting path from recording ${recordingId}`);
|
||||||
|
|
||||||
@ -265,25 +297,49 @@ export class RecordingService {
|
|||||||
return this.roomService.sendSignal(roomName, payload, options);
|
return this.roomService.sendSignal(roomName, payload, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMeetRecordingInfoFromMetadata(
|
/**
|
||||||
egressId: string
|
* Retrieves the data required to delete a recording, including the file paths
|
||||||
|
* to be deleted and the recording's metadata information.
|
||||||
|
*
|
||||||
|
* @param recordingId - The unique identifier of the recording egress.
|
||||||
|
*/
|
||||||
|
protected async getDeletableRecordingData(
|
||||||
|
recordingId: string
|
||||||
|
): Promise<{ filesToDelete: string[]; recordingInfo: MeetRecordingInfo }> {
|
||||||
|
const { metadataFilePath, recordingInfo } = await this.getMeetRecordingInfoFromMetadata(recordingId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
recordingInfo.status === MeetRecordingStatus.STARTING ||
|
||||||
|
recordingInfo.status === MeetRecordingStatus.ACTIVE ||
|
||||||
|
recordingInfo.status === MeetRecordingStatus.ENDING
|
||||||
|
) {
|
||||||
|
throw errorRecordingNotStopped(recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingPath = RecordingHelper.extractFilename(recordingInfo);
|
||||||
|
|
||||||
|
if (!recordingPath) {
|
||||||
|
throw internalError(`Error extracting path from recording ${recordingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filesToDelete: [metadataFilePath, recordingPath], recordingInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getMeetRecordingInfoFromMetadata(
|
||||||
|
recordingId: string
|
||||||
): Promise<{ metadataFilePath: string; recordingInfo: MeetRecordingInfo }> {
|
): Promise<{ metadataFilePath: string; recordingInfo: MeetRecordingInfo }> {
|
||||||
// Get the recording object from the S3 bucket
|
const { roomId, egressId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||||
const metadataObject = await this.s3Service.listObjects('.metadata', `.*${egressId}.*.json`);
|
|
||||||
|
|
||||||
const content = metadataObject.Contents?.[0];
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
throw errorRecordingNotFound(egressId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataPath = content.Key;
|
|
||||||
|
|
||||||
if (!metadataPath) {
|
|
||||||
throw errorRecordingNotFound(egressId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const metadataPath = `${MEET_S3_RECORDINGS_PREFIX}/.metadata/${roomId}/${egressId}/${uid}.json`;
|
||||||
|
this.logger.debug(`Retrieving metadata for recording ${recordingId} from ${metadataPath}`);
|
||||||
const recordingInfo = (await this.s3Service.getObjectAsJson(metadataPath)) as MeetRecordingInfo;
|
const recordingInfo = (await this.s3Service.getObjectAsJson(metadataPath)) as MeetRecordingInfo;
|
||||||
|
|
||||||
|
if (!recordingInfo) {
|
||||||
|
throw errorRecordingNotFound(recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Retrieved metadata for recording ${recordingId} from ${metadataPath}`);
|
||||||
|
|
||||||
return { recordingInfo, metadataFilePath: metadataPath };
|
return { recordingInfo, metadataFilePath: metadataPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,12 +358,12 @@ export class RecordingService {
|
|||||||
* @param fileName - The name of the file (default is 'recording').
|
* @param fileName - The name of the file (default is 'recording').
|
||||||
* @returns The generated file output object.
|
* @returns The generated file output object.
|
||||||
*/
|
*/
|
||||||
private generateFileOutputFromRequest(roomName: string): EncodedFileOutput {
|
private generateFileOutputFromRequest(roomId: string): EncodedFileOutput {
|
||||||
// Added unique identifier to the file path for avoiding overwriting
|
// Added unique identifier to the file path for avoiding overwriting
|
||||||
const recordingName = `${roomName}-${uid(10)}`;
|
const recordingName = `${roomId}--${uid(10)}`;
|
||||||
|
|
||||||
// Generate the file path with the openviud-meet subbucket and the recording prefix
|
// Generate the file path with the openviud-meet subbucket and the recording prefix
|
||||||
const filepath = `${MEET_S3_SUBBUCKET}/${MEET_S3_RECORDINGS_PREFIX}/${roomName}/${recordingName}`;
|
const filepath = `${MEET_S3_SUBBUCKET}/${MEET_S3_RECORDINGS_PREFIX}/${roomId}/${recordingName}`;
|
||||||
|
|
||||||
return new EncodedFileOutput({
|
return new EncodedFileOutput({
|
||||||
fileType: EncodedFileType.DEFAULT_FILETYPE,
|
fileType: EncodedFileType.DEFAULT_FILETYPE,
|
||||||
@ -316,6 +372,15 @@ export class RecordingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special characters in a string to make it safe for use in a regular expression.
|
||||||
|
* This method ensures that characters with special meaning in regular expressions
|
||||||
|
* (e.g., `.`, `*`, `+`, `?`, `^`, `$`, `{`, `}`, `(`, `)`, `|`, `[`, `]`, `\`) are
|
||||||
|
* properly escaped.
|
||||||
|
*
|
||||||
|
* @param str - The input string to sanitize for use in a regular expression.
|
||||||
|
* @returns A new string with special characters escaped.
|
||||||
|
*/
|
||||||
private sanitizeRegExp(str: string) {
|
private sanitizeRegExp(str: string) {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import {
|
|||||||
_Object,
|
_Object,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
DeleteObjectCommandOutput,
|
DeleteObjectCommandOutput,
|
||||||
|
DeleteObjectsCommand,
|
||||||
|
DeleteObjectsCommandOutput,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
GetObjectCommandOutput,
|
GetObjectCommandOutput,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
@ -129,6 +131,82 @@ export class S3Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk deletes objects from S3.
|
||||||
|
* @param keys Array of object keys to delete. Estos keys deben incluir el subbucket (se obtiene con getFullKey).
|
||||||
|
* @param bucket S3 bucket name (default: MEET_S3_BUCKET)
|
||||||
|
*/
|
||||||
|
async deleteObjects(keys: string[], bucket: string = MEET_S3_BUCKET): Promise<DeleteObjectsCommandOutput> {
|
||||||
|
try {
|
||||||
|
this.logger.info(`S3 delete: attempting to delete ${keys.length} objects from bucket ${bucket}`);
|
||||||
|
const command = new DeleteObjectsCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Delete: {
|
||||||
|
Objects: keys.map((key) => ({ Key: this.getFullKey(key) })),
|
||||||
|
Quiet: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
'command',
|
||||||
|
keys.map((key) => ({ Key: key }))
|
||||||
|
);
|
||||||
|
const result = await this.run(command);
|
||||||
|
this.logger.info(`S3 bulk delete: successfully deleted objects from bucket ${bucket}`);
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`S3 bulk delete: error deleting objects in bucket ${bucket}: ${error}`);
|
||||||
|
throw internalError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List objects with pagination.
|
||||||
|
*
|
||||||
|
* @param additionalPrefix Additional prefix relative to the subbucket.
|
||||||
|
* Por ejemplo, para listar metadata se pasa ".metadata/".
|
||||||
|
* @param searchPattern Optional regex pattern to filter keys.
|
||||||
|
* @param bucket Optional bucket name.
|
||||||
|
* @param maxKeys Maximum number of objects to return.
|
||||||
|
* @param continuationToken Token to retrieve the next page.
|
||||||
|
*
|
||||||
|
* @returns The ListObjectsV2CommandOutput with Keys and NextContinuationToken.
|
||||||
|
*/
|
||||||
|
async listObjectsPaginated(
|
||||||
|
additionalPrefix = '',
|
||||||
|
maxKeys = 50,
|
||||||
|
continuationToken?: string,
|
||||||
|
searchPattern = '',
|
||||||
|
bucket: string = MEET_S3_BUCKET
|
||||||
|
): Promise<ListObjectsV2CommandOutput> {
|
||||||
|
// Se construye el prefijo completo combinando el subbucket y el additionalPrefix.
|
||||||
|
// Ejemplo: si s3Subbucket es "recordings" y additionalPrefix es ".metadata/",
|
||||||
|
// se listarán los objetos con key que empiece por "recordings/.metadata/".
|
||||||
|
const basePrefix = this.getFullKey(additionalPrefix);
|
||||||
|
this.logger.verbose(`S3 listObjectsPaginated: listing objects with prefix "${basePrefix}"`);
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: basePrefix,
|
||||||
|
MaxKeys: maxKeys,
|
||||||
|
ContinuationToken: continuationToken
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: ListObjectsV2CommandOutput = await this.s3.send(command);
|
||||||
|
|
||||||
|
// Si se ha proporcionado searchPattern, se filtran los resultados.
|
||||||
|
if (searchPattern) {
|
||||||
|
const regex = new RegExp(searchPattern);
|
||||||
|
response.Contents = (response.Contents || []).filter((item) => item.Key && regex.test(item.Key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`S3 listObjectsPaginated: error listing objects with prefix "${basePrefix}": ${error}`);
|
||||||
|
throw internalError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all objects in an S3 bucket with optional subbucket and search pattern filtering.
|
* Lists all objects in an S3 bucket with optional subbucket and search pattern filtering.
|
||||||
*
|
*
|
||||||
@ -207,19 +285,17 @@ export class S3Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getObjectAsJson(name: string, bucket: string = MEET_S3_BUCKET): Promise<Object | undefined> {
|
async getObjectAsJson(name: string, bucket: string = MEET_S3_BUCKET): Promise<Object | undefined> {
|
||||||
const fullKey = this.getFullKey(name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const obj = await this.getObject(fullKey, bucket);
|
const obj = await this.getObject(name, bucket);
|
||||||
const str = await obj.Body?.transformToString();
|
const str = await obj.Body?.transformToString();
|
||||||
const parsed = JSON.parse(str as string);
|
const parsed = JSON.parse(str as string);
|
||||||
this.logger.info(
|
this.logger.verbose(
|
||||||
`S3 getObjectAsJson: successfully retrieved and parsed object ${fullKey} from bucket ${bucket}`
|
`S3 getObjectAsJson: successfully retrieved and parsed object ${name} from bucket ${bucket}`
|
||||||
);
|
);
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'NoSuchKey') {
|
if (error.name === 'NoSuchKey') {
|
||||||
this.logger.warn(`S3 getObjectAsJson: object '${fullKey}' does not exist in bucket ${bucket}`);
|
this.logger.warn(`S3 getObjectAsJson: object '${name}' does not exist in bucket ${bucket}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,7 +303,7 @@ export class S3Service {
|
|||||||
throw errorS3NotAvailable(error);
|
throw errorS3NotAvailable(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`S3 getObjectAsJson: error retrieving object ${fullKey} from bucket ${bucket}: ${error}`);
|
this.logger.error(`S3 getObjectAsJson: error retrieving object ${name} from bucket ${bucket}: ${error}`);
|
||||||
throw internalError(error);
|
throw internalError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,10 +313,8 @@ export class S3Service {
|
|||||||
bucket: string = MEET_S3_BUCKET,
|
bucket: string = MEET_S3_BUCKET,
|
||||||
range?: { start: number; end: number }
|
range?: { start: number; end: number }
|
||||||
): Promise<Readable> {
|
): Promise<Readable> {
|
||||||
const fullKey = this.getFullKey(name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const obj = await this.getObject(fullKey, bucket, range);
|
const obj = await this.getObject(name, bucket, range);
|
||||||
|
|
||||||
if (obj.Body) {
|
if (obj.Body) {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
@ -253,7 +327,7 @@ export class S3Service {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`S3 getObjectAsStream: error retrieving stream for object ${fullKey} from bucket ${bucket}: ${error}`
|
`S3 getObjectAsStream: error retrieving stream for object ${name} from bucket ${bucket}: ${error}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error.code === 'ECONNREFUSED') {
|
if (error.code === 'ECONNREFUSED') {
|
||||||
@ -288,11 +362,18 @@ export class S3Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares a full key path by prefixing the object's name with the subbucket.
|
* Constructs the full key for an S3 object by ensuring it includes the specified sub-bucket prefix.
|
||||||
* All operations are performed under MEET_S3_BUCKET/MEET_S3_SUBBUCKET.
|
* If the provided name already starts with the prefix, it is returned as-is.
|
||||||
|
* Otherwise, the prefix is prepended to the name.
|
||||||
*/
|
*/
|
||||||
protected getFullKey(name: string): string {
|
protected getFullKey(name: string): string {
|
||||||
return `${MEET_S3_SUBBUCKET}/${name}`;
|
const prefix = `${MEET_S3_SUBBUCKET}`;
|
||||||
|
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getObject(
|
protected async getObject(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user