Skip to content

Commit

Permalink
[Feat] 녹화 영상 조회 api 구현
Browse files Browse the repository at this point in the history
[Feat] 녹화 영상 조회 api 구현
  • Loading branch information
seungheon123 authored Nov 28, 2024
2 parents ab83bc3 + d60e342 commit 79baa66
Show file tree
Hide file tree
Showing 19 changed files with 230 additions and 33 deletions.
11 changes: 10 additions & 1 deletion .github/workflows/record-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
NCLOUD_ACCESS_KEY: ${{ secrets.NCLOUD_ACCESS_KEY }}
NCLOUD_SECRET_KEY: ${{ secrets.NCLOUD_SECRET_KEY }}
NCLOUD_BUCKET_NAME: ${{ secrets.NCLOUD_BUCKET_NAME }}
API_SERVER_URL: ${{ secrets.API_SERVER_URL }}
CDN_URL: ${{ secrets.CDN_URL }}

steps:
- name: Checkout code
Expand All @@ -35,6 +37,8 @@ jobs:
--build-arg NCLOUD_ACCESS_KEY=$NCLOUD_ACCESS_KEY \
--build-arg NCLOUD_SECRET_KEY=$NCLOUD_SECRET_KEY \
--build-arg NCLOUD_BUCKET_NAME=$NCLOUD_BUCKET_NAME \
--build-arg API_SERVER_URL=$API_SERVER_URL \
--build-arg CDN_URL=$CDN_URL \
.
- name: Push to Ncloud Container Registry
Expand All @@ -51,6 +55,9 @@ jobs:
NCLOUD_SECRET_KEY: ${{ secrets.NCLOUD_SECRET_KEY }}
NCLOUD_BUCKET_NAME: ${{ secrets.NCLOUD_BUCKET_NAME }}
NCLOUD_REGISTRY_URL: ${{ secrets.NCLOUD_REGISTRY_URL }}
API_SERVER_URL: ${{ secrets.API_SERVER_URL }}
CDN_URL: ${{ secrets.CDN_URL }}

steps:
- name: Checkout for docker-compose
uses: actions/checkout@v2
Expand All @@ -71,7 +78,7 @@ jobs:
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_SERVER_KEY }}
port: 22
envs: RECORD_PORT,NCLOUD_ACCESS_KEY,NCLOUD_SECRET_KEY,NCLOUD_BUCKET_NAME,NCLOUD_REGISTRY_URL
envs: RECORD_PORT,NCLOUD_ACCESS_KEY,NCLOUD_SECRET_KEY,NCLOUD_BUCKET_NAME,NCLOUD_REGISTRY_URL,API_SERVER_URL,CDN_URL
script: |
cd /home/${{ secrets.SERVER_USER }}/camon
Expand All @@ -80,6 +87,8 @@ jobs:
echo "NCLOUD_SECRET_KEY=$NCLOUD_SECRET_KEY" >> .env
echo "NCLOUD_BUCKET_NAME=$NCLOUD_BUCKET_NAME" >> .env
echo "NCLOUD_REGISTRY_URL=$NCLOUD_REGISTRY_URL" >> .env
echo "API_SERVER_URL=$API_SERVER_URL" >> .env
echo "CDN_URL=$CDN_URL" >> .env
sudo docker login -u $NCLOUD_ACCESS_KEY -p $NCLOUD_SECRET_KEY $NCLOUD_REGISTRY_URL
Expand Down
3 changes: 2 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ GG_CALLBACK_URL=


#JWT
JWT_SECRET=
JWT_SECRET=
CALLBACK_URI=
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { typeormConfig } from './config/typeorm.config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { RecordModule } from './record/record.module';

@Module({
imports: [
Expand All @@ -27,6 +28,7 @@ import { AuthModule } from './auth/auth.module';
BroadcastModule,
AttendanceModule,
AuthModule,
RecordModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/common/responses/exceptions/errorStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ export class ErrorStatus {
// Broadcast Errors
static readonly BROADCAST_NOT_FOUND = new ErrorStatus(404, 'BROADCAST_4000', '방송 정보가 존재하지 않습니다.');
static readonly BROADCAST_ALREADY_EXISTS = new ErrorStatus(400, 'BROADCAST_4001', '이미 존재하는 방송입니다.');

//Attendance
static readonly ATTENDANCE_NOT_FOUND = new ErrorStatus(404, 'ATTENDANCE_4000', '출석 정보가 존재하지 않습니다.');
}
4 changes: 4 additions & 0 deletions apps/api/src/record/dto/create-record.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class CreateRecordDto {
title: string;
roomId: string;
}
55 changes: 55 additions & 0 deletions apps/api/src/record/dto/records-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ApiProperty } from '@nestjs/swagger';
import { Record } from '../record.entity';

class RecordDto {
@ApiProperty()
recordId: number;

@ApiProperty()
video: string;

@ApiProperty()
title: string;

@ApiProperty()
date: string;

static from(record: Record) {
const dto = new RecordDto();
dto.recordId = record.id;
dto.video = record.video;
dto.title = record.title;
dto.date = this.formatDate(new Date(record.attendance.startTime));
return dto;
}

static fromList(records: Record[]) {
return records.map(record => this.from(record));
}

private static formatDate(date: Date): string {
return date
.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.replace(/\. /g, '.')
.slice(0, -1);
}
}

export class RecordsResponseDto {
@ApiProperty({
type: RecordDto,
isArray: true,
})
records: RecordDto[];

static from(records: Record[]) {
const dto = new RecordsResponseDto();
dto.records = RecordDto.fromList(records);

return dto;
}
}
4 changes: 4 additions & 0 deletions apps/api/src/record/dto/update-record.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class UpdateRecordDto {
video: string;
roomId: string;
}
37 changes: 37 additions & 0 deletions apps/api/src/record/record.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { RecordService } from './record.service';
import { CreateRecordDto } from './dto/create-record.dto';
import { UpdateRecordDto } from './dto/update-record.dto';
import { RecordsResponseDto } from './dto/records-response.dto';
import { SwaggerTag } from 'src/common/constants/swagger-tag.enum';
import { ApiSuccessResponse } from 'src/common/decorators/success-res.decorator';
import { SuccessStatus } from 'src/common/responses/bases/successStatus';
import { ApiErrorResponse } from 'src/common/decorators/error-res.decorator';
import { ErrorStatus } from 'src/common/responses/exceptions/errorStatus';
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@Controller('v1/records')
export class RecordController {
constructor(private readonly recordService: RecordService) {}

@Get(':attendanceId')
@ApiTags(SwaggerTag.MYPAGE)
@ApiOperation({ summary: '녹화 리스트 조회' })
@ApiSuccessResponse(SuccessStatus.OK(RecordsResponseDto), RecordsResponseDto)
@ApiErrorResponse(500, ErrorStatus.INTERNAL_SERVER_ERROR)
async getRecordsByAttendanceId(@Param('attendanceId') attendanceId: string) {
const records = await this.recordService.getRecordsByAttendanceId(attendanceId);

return RecordsResponseDto.from(records);
}

@Post()
async createRecord(@Body() createRecordDto: CreateRecordDto) {
return this.recordService.createRecord(createRecordDto);
}

@Patch()
async updateRecord(@Body() updateRecordDto: UpdateRecordDto) {
return this.recordService.updateRecord(updateRecordDto);
}
}
2 changes: 1 addition & 1 deletion apps/api/src/record/record.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class Record {
@PrimaryGeneratedColumn({ name: 'recordId' })
id: number;

@Column({ type: 'text', nullable: false })
@Column({ type: 'text', nullable: true })
video: string;

@Column({ nullable: false })
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/record/record.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Record } from './record.entity';
import { RecordService } from './record.service';
import { AttendanceModule } from 'src/attendance/attendance.module';
import { RecordController } from './record.controller';

@Module({})
@Module({
imports: [TypeOrmModule.forFeature([Record]), AttendanceModule],
controllers: [RecordController],
providers: [RecordService],
})
export class RecordModule {}
52 changes: 51 additions & 1 deletion apps/api/src/record/record.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,58 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Record } from './record.entity';
import { Repository } from 'typeorm';
import { CreateRecordDto } from './dto/create-record.dto';
import { Attendance } from 'src/attendance/attendance.entity';
import { UpdateRecordDto } from './dto/update-record.dto';
import { CustomException } from 'src/common/responses/exceptions/custom.exception';
import { ErrorStatus } from 'src/common/responses/exceptions/errorStatus';

@Injectable()
export class RecordService {
constructor(@InjectRepository(Record) private attendanceRepository: Repository<Record>) {}
private readonly videoUrl: string;

constructor(
@InjectRepository(Record) private recordRepository: Repository<Record>,
@InjectRepository(Attendance) private attendanceRepository: Repository<Attendance>,
) {}

async getRecordsByAttendanceId(attendanceId: string) {
return await this.recordRepository
.createQueryBuilder('record')
.innerJoinAndSelect('record.attendance', 'attendance')
.where('attendance.id = :attendanceId AND record.video IS NOT NULL', { attendanceId })
.getMany();
}

async createRecord({ title, roomId }: CreateRecordDto) {
const attendance = await this.attendanceRepository.findOne({
where: {
broadcastId: roomId,
},
});

const record = this.recordRepository.create({
title,
attendance,
});

this.recordRepository.save(record);
}

async updateRecord({ roomId, video }: UpdateRecordDto) {
const attendance = await this.attendanceRepository.findOne({
where: {
broadcastId: roomId,
},
});

if (!attendance) new CustomException(ErrorStatus.ATTENDANCE_NOT_FOUND);

await this.recordRepository
.createQueryBuilder('record')
.update(Record)
.set({ video })
.where('attendanceId = :attendanceId AND video IS NULL', { attendanceId: attendance.id })
.execute();
}
}
2 changes: 1 addition & 1 deletion apps/media/src/auth/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ErrorStatus } from '../common/responses/exceptions/errorStatus';
export class JwtAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const client = context.switchToWs().getClient<Socket>();
console.log(client);

return {
headers: {
authorization: client.handshake.auth.accessToken,
Expand Down
6 changes: 4 additions & 2 deletions apps/media/src/sfu/services/record.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ const STREAM_TYPE = {
@Injectable()
export class RecordService {
private readonly recordServerUrl: string;
private readonly apiServerUrl: string;
private readonly serverPrivateIp: string;
private readonly announcedIp: string;

private transports = new Map<string, mediasoup.types.Transport>();

constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) {
this.recordServerUrl = this.configService.get<string>('RECORD_SERVER_URL', 'http://localhost:3003');
this.apiServerUrl = this.configService.get<string>('API_SERVER_URL', '127.0.0.1');
this.serverPrivateIp = this.configService.get<string>('SERVER_PRIVATE_IP', '127.0.0.1');
this.announcedIp = this.configService.get<string>('ANNOUNCED_IP', '127.0.0.1');
}
Expand Down Expand Up @@ -96,8 +98,8 @@ export class RecordService {
}
recordTransport.close();
this.transports.delete(room.id);
//TODO:DB 저장 호출 로직으로 변경
console.log(title);

await lastValueFrom(this.httpService.post(`${this.apiServerUrl}/v1/records`, { title, roomId: room.id }));
}

async createPlainTransport(room: mediasoup.types.Router) {
Expand Down
4 changes: 3 additions & 1 deletion apps/record/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
RECORD_PORT=
NCLOUD_ACCESS_KEY=
NCLOUD_SECRET_KEY=
NCLOUD_BUCKET_NAME=
NCLOUD_BUCKET_NAME=
API_SERVER_URL=
CDN_URL=
5 changes: 5 additions & 0 deletions apps/record/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ ARG RECORD_PORT
ARG NCLOUD_ACCESS_KEY
ARG NCLOUD_SECRET_KEY
ARG NCLOUD_BUCKET_NAME
ARG API_SERVER_URL
ARG CDN_URL

ENV RECORD_PORT=$RECORD_PORT
ENV NCLOUD_ACCESS_KEY=$NCLOUD_ACCESS_KEY
ENV NCLOUD_SECRET_KEY=$NCLOUD_SECRET_KEY
ENV NCLOUD_BUCKET_NAME=$NCLOUD_BUCKET_NAME
ENV API_SERVER_URL=$API_SERVER_URL
ENV CDN_URL=$CDN_URL

# Copy dependency files
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/record/package.json apps/record/
Expand Down
1 change: 1 addition & 0 deletions apps/record/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@babel/preset-env": "^7.26.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@types/fluent-ffmpeg": "^2.1.27",
"axios": "^1.7.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
Expand Down
22 changes: 15 additions & 7 deletions apps/record/src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import fs from 'fs';
import dotenv from 'dotenv';
import path from 'path';
import axios from 'axios';
dotenv.config();

const endpoint = 'https://kr.object.ncloudstorage.com';
Expand All @@ -10,6 +11,8 @@ const region = 'kr-standard';
const ACCESS_KEY = process.env.NCLOUD_ACCESS_KEY;
const SECRET_KEY = process.env.NCLOUD_SECRET_KEY;
const BUCKET_NAME = process.env.NCLOUD_BUCKET_NAME;
const API_SERVER_URL = process.env.API_SERVER_URL;
const CDN_URL = process.env.CDN_URL;

if (!ACCESS_KEY || !SECRET_KEY) {
throw new Error('Access key or secret key is missing');
Expand All @@ -27,12 +30,14 @@ const s3Client = new S3Client({
export const uploadObjectFromDir = async (roomId: string, dirPath: string) => {
const folderPath = `${dirPath}/records/${roomId}`;
const files = fs.readdirSync(folderPath);
const endTime = `${formatDate(new Date())}_${formatTime(new Date())}`;
const endTime = `${formatDate(new Date())}.${formatTime(new Date())}`;
const video = `${CDN_URL}/records/${roomId}/${endTime}/video.m3u8`;

for (const file of files) {
const filePath = path.join(folderPath, file);
const fileStream = fs.createReadStream(filePath);
const objectKey = `records/${roomId}/${endTime}/${file}`;

try {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Expand All @@ -41,6 +46,7 @@ export const uploadObjectFromDir = async (roomId: string, dirPath: string) => {
ACL: 'public-read',
});
await s3Client.send(command);
await axios.patch(`${API_SERVER_URL}/v1/records`, { roomId, video });
} catch (error) {
console.error('Error uploading file:', error);
throw error;
Expand All @@ -60,10 +66,12 @@ const formatDate = (date: Date) => {
};

const formatTime = (date: Date) => {
return date.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return date
.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(':', '.');
};
Loading

0 comments on commit 79baa66

Please sign in to comment.