diff --git a/.github/workflows/develop-api-deploy.yml b/.github/workflows/develop-api-deploy.yml index eae87545..368e8ce9 100644 --- a/.github/workflows/develop-api-deploy.yml +++ b/.github/workflows/develop-api-deploy.yml @@ -5,7 +5,7 @@ on: branches: - develop paths: - - "Backend/src/**" + - "Backend/apps/api/src/**" jobs: deploy: diff --git a/Backend/src/app.controller.spec.ts b/Backend/apps/api/src/app.controller.spec.ts similarity index 100% rename from Backend/src/app.controller.spec.ts rename to Backend/apps/api/src/app.controller.spec.ts diff --git a/Backend/src/app.controller.ts b/Backend/apps/api/src/app.controller.ts similarity index 100% rename from Backend/src/app.controller.ts rename to Backend/apps/api/src/app.controller.ts diff --git a/Backend/src/app.module.ts b/Backend/apps/api/src/app.module.ts similarity index 73% rename from Backend/src/app.module.ts rename to Backend/apps/api/src/app.module.ts index ff0329be..2e92a848 100644 --- a/Backend/src/app.module.ts +++ b/Backend/apps/api/src/app.module.ts @@ -4,7 +4,6 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { CategoriesModule } from './categories/categories.module'; -import { VideosModule } from './videos/videos.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { typeOrmConfig } from './config/typeorm.config'; @@ -15,6 +14,11 @@ import sqliteConfig from './config/sqlite.config'; import mysqlConfig from './config/mysql.config'; import { RedisModule } from '@nestjs-modules/ioredis'; import { redisConfig } from './config/redis.config'; +import { WinstonModule } from 'nest-winston'; +import { winstonConfig } from './config/logger.config'; +import { APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; +import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @Module({ imports: [ @@ -32,15 +36,24 @@ import { redisConfig } from './config/redis.config'; inject: [ConfigService], useFactory: async (configService: ConfigService) => redisConfig(configService), }), + WinstonModule.forRoot(winstonConfig), AuthModule, UsersModule, CategoriesModule, - VideosModule, LivesModule, ChatsModule, FollowModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_INTERCEPTOR, + useClass: LoggingInterceptor, + }, + { + provide: APP_FILTER, + useClass: HttpExceptionFilter, + },], }) export class AppModule {} diff --git a/Backend/src/app.service.ts b/Backend/apps/api/src/app.service.ts similarity index 100% rename from Backend/src/app.service.ts rename to Backend/apps/api/src/app.service.ts diff --git a/Backend/src/auth/auth.controller.spec.ts b/Backend/apps/api/src/auth/auth.controller.spec.ts similarity index 100% rename from Backend/src/auth/auth.controller.spec.ts rename to Backend/apps/api/src/auth/auth.controller.spec.ts diff --git a/Backend/src/auth/auth.controller.ts b/Backend/apps/api/src/auth/auth.controller.ts similarity index 100% rename from Backend/src/auth/auth.controller.ts rename to Backend/apps/api/src/auth/auth.controller.ts diff --git a/Backend/src/auth/auth.module.ts b/Backend/apps/api/src/auth/auth.module.ts similarity index 100% rename from Backend/src/auth/auth.module.ts rename to Backend/apps/api/src/auth/auth.module.ts diff --git a/Backend/src/auth/auth.service.spec.ts b/Backend/apps/api/src/auth/auth.service.spec.ts similarity index 100% rename from Backend/src/auth/auth.service.spec.ts rename to Backend/apps/api/src/auth/auth.service.spec.ts diff --git a/Backend/src/auth/auth.service.ts b/Backend/apps/api/src/auth/auth.service.ts similarity index 100% rename from Backend/src/auth/auth.service.ts rename to Backend/apps/api/src/auth/auth.service.ts diff --git a/Backend/apps/api/src/auth/guards/jwt-auth.guard.ts b/Backend/apps/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..7dd3fb57 --- /dev/null +++ b/Backend/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,16 @@ +import { Injectable, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + private readonly logger = new Logger(JwtAuthGuard.name); + + handleRequest(err: any, user: any, info: any, context: ExecutionContext) { + if (err || !user) { + const request = context.switchToHttp().getRequest(); + this.logger.warn(`Unauthorized access attempt: ${request.method} ${request.url}`); + throw err || new UnauthorizedException('Unauthorized'); + } + return user; + } +} \ No newline at end of file diff --git a/Backend/src/auth/strategies/github.strategy.ts b/Backend/apps/api/src/auth/strategies/github.strategy.ts similarity index 100% rename from Backend/src/auth/strategies/github.strategy.ts rename to Backend/apps/api/src/auth/strategies/github.strategy.ts diff --git a/Backend/src/auth/strategies/google.strategy.ts b/Backend/apps/api/src/auth/strategies/google.strategy.ts similarity index 100% rename from Backend/src/auth/strategies/google.strategy.ts rename to Backend/apps/api/src/auth/strategies/google.strategy.ts diff --git a/Backend/src/auth/strategies/jwt.strategy.ts b/Backend/apps/api/src/auth/strategies/jwt.strategy.ts similarity index 100% rename from Backend/src/auth/strategies/jwt.strategy.ts rename to Backend/apps/api/src/auth/strategies/jwt.strategy.ts diff --git a/Backend/src/auth/strategies/naver.strategy.ts b/Backend/apps/api/src/auth/strategies/naver.strategy.ts similarity index 100% rename from Backend/src/auth/strategies/naver.strategy.ts rename to Backend/apps/api/src/auth/strategies/naver.strategy.ts diff --git a/Backend/src/categories/categories.controller.spec.ts b/Backend/apps/api/src/categories/categories.controller.spec.ts similarity index 100% rename from Backend/src/categories/categories.controller.spec.ts rename to Backend/apps/api/src/categories/categories.controller.spec.ts diff --git a/Backend/src/categories/categories.controller.ts b/Backend/apps/api/src/categories/categories.controller.ts similarity index 100% rename from Backend/src/categories/categories.controller.ts rename to Backend/apps/api/src/categories/categories.controller.ts diff --git a/Backend/src/categories/categories.module.ts b/Backend/apps/api/src/categories/categories.module.ts similarity index 89% rename from Backend/src/categories/categories.module.ts rename to Backend/apps/api/src/categories/categories.module.ts index 80952f60..6ad32d91 100644 --- a/Backend/src/categories/categories.module.ts +++ b/Backend/apps/api/src/categories/categories.module.ts @@ -3,7 +3,7 @@ import { CategoriesController } from './categories.controller'; import { CategoriesService } from './categories.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CategoryEntity } from './entity/category.entity'; -import { LivesModule } from 'src/lives/lives.module'; +import { LivesModule } from '../lives/lives.module'; @Module({ imports: [TypeOrmModule.forFeature([CategoryEntity]), LivesModule], diff --git a/Backend/src/categories/categories.service.spec.ts b/Backend/apps/api/src/categories/categories.service.spec.ts similarity index 100% rename from Backend/src/categories/categories.service.spec.ts rename to Backend/apps/api/src/categories/categories.service.spec.ts diff --git a/Backend/src/categories/categories.service.ts b/Backend/apps/api/src/categories/categories.service.ts similarity index 100% rename from Backend/src/categories/categories.service.ts rename to Backend/apps/api/src/categories/categories.service.ts diff --git a/Backend/src/categories/dto/categories.dto.ts b/Backend/apps/api/src/categories/dto/categories.dto.ts similarity index 100% rename from Backend/src/categories/dto/categories.dto.ts rename to Backend/apps/api/src/categories/dto/categories.dto.ts diff --git a/Backend/src/categories/dto/category.dto.ts b/Backend/apps/api/src/categories/dto/category.dto.ts similarity index 100% rename from Backend/src/categories/dto/category.dto.ts rename to Backend/apps/api/src/categories/dto/category.dto.ts diff --git a/Backend/src/categories/entity/category.entity.ts b/Backend/apps/api/src/categories/entity/category.entity.ts similarity index 100% rename from Backend/src/categories/entity/category.entity.ts rename to Backend/apps/api/src/categories/entity/category.entity.ts diff --git a/Backend/src/categories/error/error.message.enum.ts b/Backend/apps/api/src/categories/error/error.message.enum.ts similarity index 100% rename from Backend/src/categories/error/error.message.enum.ts rename to Backend/apps/api/src/categories/error/error.message.enum.ts diff --git a/Backend/src/videos/videos.controller.spec.ts b/Backend/apps/api/src/chats/chats.controller.spec.ts similarity index 52% rename from Backend/src/videos/videos.controller.spec.ts rename to Backend/apps/api/src/chats/chats.controller.spec.ts index d0ab091d..78e9aed9 100644 --- a/Backend/src/videos/videos.controller.spec.ts +++ b/Backend/apps/api/src/chats/chats.controller.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { VideosController } from './videos.controller'; +import { ChatsController } from './chats.controller'; -describe('VideosController', () => { - let controller: VideosController; +describe('ChatsController', () => { + let controller: ChatsController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [VideosController], + controllers: [ChatsController], }).compile(); - controller = module.get(VideosController); + controller = module.get(ChatsController); }); it('should be defined', () => { diff --git a/Backend/apps/api/src/chats/chats.controller.ts b/Backend/apps/api/src/chats/chats.controller.ts new file mode 100644 index 00000000..592d3c54 --- /dev/null +++ b/Backend/apps/api/src/chats/chats.controller.ts @@ -0,0 +1,16 @@ +import { Body, Controller, Post, Req, UseGuards, ValidationPipe } from '@nestjs/common'; +import { SendChatDto } from './dto/send.chat.dto'; +import { ChatsService } from './chats.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserEntity } from '../users/entity/user.entity'; + +@Controller('chats') +export class ChatsController { + constructor(private readonly chatsService: ChatsService) {} + + @Post() + @UseGuards(JwtAuthGuard) + async sendChat(@Body(ValidationPipe) sendChatDto: SendChatDto, @Req() req: Request & { user: UserEntity }) { + this.chatsService.ingestChat({ ...sendChatDto, userId: req.user.id, nickname: req.user.nickname }); + } +} diff --git a/Backend/src/chats/chats.module.ts b/Backend/apps/api/src/chats/chats.module.ts similarity index 73% rename from Backend/src/chats/chats.module.ts rename to Backend/apps/api/src/chats/chats.module.ts index 9f557595..5dabc3bf 100644 --- a/Backend/src/chats/chats.module.ts +++ b/Backend/apps/api/src/chats/chats.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ChatsGateway } from './chats.gateway'; import { ChatsService } from './chats.service'; import { RedisModule } from '@nestjs-modules/ioredis'; -import { UsersModule } from 'src/users/users.module'; import { JwtModule } from '@nestjs/jwt'; +import { ChatsController } from './chats.controller'; +import { UsersModule } from '../users/users.module'; +import { HttpModule } from '@nestjs/axios'; @Module({ imports: [ @@ -18,8 +19,10 @@ import { JwtModule } from '@nestjs/jwt'; }), inject: [ConfigService], }), + HttpModule, ], - providers: [ChatsGateway, ChatsService], + providers: [ChatsService], exports: [ChatsService], + controllers: [ChatsController], }) export class ChatsModule {} diff --git a/Backend/src/chats/chats.service.spec.ts b/Backend/apps/api/src/chats/chats.service.spec.ts similarity index 100% rename from Backend/src/chats/chats.service.spec.ts rename to Backend/apps/api/src/chats/chats.service.spec.ts diff --git a/Backend/apps/api/src/chats/chats.service.ts b/Backend/apps/api/src/chats/chats.service.ts new file mode 100644 index 00000000..20bf7f20 --- /dev/null +++ b/Backend/apps/api/src/chats/chats.service.ts @@ -0,0 +1,116 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { UUID } from 'crypto'; +import Redis from 'ioredis'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class ChatsService { + constructor( + @InjectRedis() private readonly redisClient: Redis, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) {} + + async ingestChat({ + channelId, + message, + userId, + nickname, + }: { + channelId; + message: string; + userId: number; + nickname: string; + }) { + const chatId = crypto.randomUUID(); + const chat = { + content: message, + userId, + nickname, + timestamp: new Date(), + channelId, + chatId, + filteringResult: true, + }; + const chatString = JSON.stringify(chat); + await this.redisClient.multi().publish(`${channelId}:chat`, chatString).rpush('chatQueue', chatId).exec(); + this.clovaFiltering(chat); + } + + async readViewers(channelId: UUID) { + return await this.redisClient.hlen(`${channelId}:viewers`); + } + + async clearChat(channelId: UUID) { + this.redisClient.del(`${channelId}:chats`); + } + + async clovaFiltering(chat) { + const postData = { + messages: [ + { + role: 'system', + content: this.configService.get('CLOVA_CHAT_FILTERING_SYSTEM_PROMPT'), + }, + { + role: 'user', + content: `채팅내용 : "${chat.content}"`, + }, + ], + maxTokens: this.configService.get('CLOVA_CHAT_FILTERING_MAX_TOKEN') || 10, + topP: 0.8, + topK: 1, + temperature: 0.1, + repeatPenalty: 1.0, + includeAiFilters: true, + seed: 0, + }; + const { data } = await firstValueFrom( + this.httpService.post(this.configService.get('CLOVA_API_URL'), postData, { + headers: { + 'X-NCP-CLOVASTUDIO-API-KEY': this.configService.get('CLOVA_API_KEY'), + 'X-NCP-APIGW-API-KEY': this.configService.get('CLOVA_API_GATEWAY_KEY'), + 'X-NCP-CLOVASTUDIO-REQUEST-ID': this.configService.get('CLOVA_REQUEST_ID'), + }, + }), + ); + + chat.filteringResult = data?.result?.message?.content?.includes('true'); + await this.redisClient + .multi() + .publish( + `${chat.channelId}:filter`, + JSON.stringify({ chatId: chat.chatId, filteringResult: chat.filteringResult }), + ) + .hset('chatCache', chat.chatId, JSON.stringify(chat)) + .exec(); + this.flushChat(); + } + + async flushChat() { + const lockKey = 'chat:flush:lock'; + const lock = await this.redisClient.set(lockKey, 'lock', 'NX'); + + try { + if (lock) { + while (true) { + const frontChatId = await this.redisClient.lindex('chatQueue', 0); + const chatString = await this.redisClient.hget('chatCache', frontChatId); + if (!chatString) { + break; + } else { + const chat = JSON.parse(chatString); + await this.redisClient.multi().rpush(`${chat.channelId}:chats`, chatString).lpop('chatQueue').exec(); + } + } + } + } catch (err) { + console.log(err); + } finally { + await this.redisClient.del(lockKey); + } + } +} diff --git a/Backend/apps/api/src/chats/dto/send.chat.dto.ts b/Backend/apps/api/src/chats/dto/send.chat.dto.ts new file mode 100644 index 00000000..de403d88 --- /dev/null +++ b/Backend/apps/api/src/chats/dto/send.chat.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsUUID, IsString } from 'class-validator'; +import { UUID } from 'crypto'; +export class SendChatDto { + @IsUUID() + @IsNotEmpty() + channelId: UUID; + + @IsString() + @IsNotEmpty() + message: string; +} diff --git a/Backend/apps/api/src/common/filters/http-exception.filter.ts b/Backend/apps/api/src/common/filters/http-exception.filter.ts new file mode 100644 index 00000000..195c2907 --- /dev/null +++ b/Backend/apps/api/src/common/filters/http-exception.filter.ts @@ -0,0 +1,45 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Inject, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { Logger } from 'winston'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + constructor( + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + ) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.getResponse() + : exception; + + this.logger.error( + `HTTP Status: ${status} Error Message: ${JSON.stringify(message)}`, + ); + + response.status(status).json({ + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/Backend/apps/api/src/common/interceptors/logging.interceptor.ts b/Backend/apps/api/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 00000000..f9eb35ac --- /dev/null +++ b/Backend/apps/api/src/common/interceptors/logging.interceptor.ts @@ -0,0 +1,45 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + Inject, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request, Response } from 'express'; +import { Logger } from 'winston'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + constructor( + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const now = Date.now(); + + const ctx = context.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const { method, originalUrl } = request; + const headers = { ...request.headers }; + delete headers['authorization']; // Authorization 헤더 제외 - 보안상 + + return next.handle().pipe( + tap(() => { + const statusCode = response.statusCode; + const contentLength = response.get('content-length') || '0'; + + this.logger.info( + `${method} ${originalUrl} ${statusCode} ${contentLength} - ${ + Date.now() - now + }ms`, + { headers }, // 제외한 헤더를 로그에 포함 + ); + }), + ); + } +} diff --git a/Backend/apps/api/src/config/logger.config.ts b/Backend/apps/api/src/config/logger.config.ts new file mode 100644 index 00000000..0d7a8bb9 --- /dev/null +++ b/Backend/apps/api/src/config/logger.config.ts @@ -0,0 +1,26 @@ +import * as winston from 'winston'; +import 'winston-daily-rotate-file'; + +const logLevel = 'warn'; +const logDir = './logs'; + +export const winstonConfig: winston.LoggerOptions = { + level: logLevel, + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf(({ timestamp, level, message }) => { + return `${timestamp} [${level.toUpperCase()}]: ${message}`; + }), + ), + transports: [ + new winston.transports.Console(), + new winston.transports.DailyRotateFile({ + dirname: logDir, + filename: 'application-%DATE%.log', + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + }), + ], +}; \ No newline at end of file diff --git a/Backend/src/config/mysql.config.ts b/Backend/apps/api/src/config/mysql.config.ts similarity index 100% rename from Backend/src/config/mysql.config.ts rename to Backend/apps/api/src/config/mysql.config.ts diff --git a/Backend/src/config/redis.config.ts b/Backend/apps/api/src/config/redis.config.ts similarity index 100% rename from Backend/src/config/redis.config.ts rename to Backend/apps/api/src/config/redis.config.ts diff --git a/Backend/src/config/sqlite.config.ts b/Backend/apps/api/src/config/sqlite.config.ts similarity index 100% rename from Backend/src/config/sqlite.config.ts rename to Backend/apps/api/src/config/sqlite.config.ts diff --git a/Backend/src/config/typeorm.config.ts b/Backend/apps/api/src/config/typeorm.config.ts similarity index 65% rename from Backend/src/config/typeorm.config.ts rename to Backend/apps/api/src/config/typeorm.config.ts index aa7bcb4e..92d15d4b 100644 --- a/Backend/src/config/typeorm.config.ts +++ b/Backend/apps/api/src/config/typeorm.config.ts @@ -1,5 +1,8 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; +import { UserEntity } from '../users/entity/user.entity'; +import { CategoryEntity } from '../categories/entity/category.entity'; +import { LiveEntity } from '../lives/entity/live.entity'; export const typeOrmConfig = (configService: ConfigService): TypeOrmModuleOptions => { const dbType = configService.get('DB_TYPE'); @@ -8,7 +11,7 @@ export const typeOrmConfig = (configService: ConfigService): TypeOrmModuleOption return { type: dbType, ...dbConfig, - entities: [__dirname + '/../**/*.entity{.ts,.js}'], + entities: [UserEntity, CategoryEntity, LiveEntity], synchronize: configService.get('DB_SYNCHRONIZE'), logging: configService.get('DB_LOGGING'), }; diff --git a/Backend/src/follow/follow.controller.spec.ts b/Backend/apps/api/src/follow/follow.controller.spec.ts similarity index 100% rename from Backend/src/follow/follow.controller.spec.ts rename to Backend/apps/api/src/follow/follow.controller.spec.ts diff --git a/Backend/src/follow/follow.controller.ts b/Backend/apps/api/src/follow/follow.controller.ts similarity index 80% rename from Backend/src/follow/follow.controller.ts rename to Backend/apps/api/src/follow/follow.controller.ts index b43ea45c..78e0ce24 100644 --- a/Backend/src/follow/follow.controller.ts +++ b/Backend/apps/api/src/follow/follow.controller.ts @@ -15,12 +15,12 @@ import { Request } from 'express'; import { UserEntity } from '../users/entity/user.entity'; @Controller('follow') -@UseGuards(JwtAuthGuard) export class FollowController { constructor(private readonly followService: FollowService) {} // 팔로우한 스트리머 목록 조회 @Get() + @UseGuards(JwtAuthGuard) async getFollowing(@Req() req: Request & { user: UserEntity }) { const userId = req.user.id; return this.followService.getFollowingStreamers(userId); @@ -28,6 +28,7 @@ export class FollowController { // 스트리머 팔로우 @Post(':streamerId') + @UseGuards(JwtAuthGuard) @HttpCode(201) async followStreamer( @Req() req: Request & { user: UserEntity }, @@ -40,6 +41,7 @@ export class FollowController { // 스트리머 언팔로우 @Delete(':streamerId') + @UseGuards(JwtAuthGuard) async unfollowStreamer( @Req() req: Request & { user: UserEntity }, @Param('streamerId', ParseIntPipe) streamerId: number, @@ -48,4 +50,12 @@ export class FollowController { await this.followService.unfollowStreamer(userId, streamerId); return { message: '언팔로우 성공' }; } + + @Get('count/:streamerId') + async getFollowerCount( + @Param('streamerId', ParseIntPipe) streamerId: number, + ) { + const followerCount = await this.followService.getFollowerCount(streamerId); + return { streamerId, followerCount }; + } } diff --git a/Backend/src/follow/follow.module.ts b/Backend/apps/api/src/follow/follow.module.ts similarity index 100% rename from Backend/src/follow/follow.module.ts rename to Backend/apps/api/src/follow/follow.module.ts diff --git a/Backend/src/follow/follow.service.spec.ts b/Backend/apps/api/src/follow/follow.service.spec.ts similarity index 100% rename from Backend/src/follow/follow.service.spec.ts rename to Backend/apps/api/src/follow/follow.service.spec.ts diff --git a/Backend/src/follow/follow.service.ts b/Backend/apps/api/src/follow/follow.service.ts similarity index 88% rename from Backend/src/follow/follow.service.ts rename to Backend/apps/api/src/follow/follow.service.ts index ff1ca799..59ef1f33 100644 --- a/Backend/src/follow/follow.service.ts +++ b/Backend/apps/api/src/follow/follow.service.ts @@ -83,6 +83,18 @@ export class FollowService { usersNickname: streamer.nickname, usersProfileImage: streamer.profileImage, onAir: streamer.live?.onAir || false, + viewers: streamer.live?.viewers || 0 })); } + + // 팔로워 수 + async getFollowerCount(streamerId: number): Promise { + const count = await this.usersRepository + .createQueryBuilder('user') + .innerJoin('user.followers', 'follower') + .where('user.id = :streamerId', { streamerId }) + .getCount(); + + return count; + } } \ No newline at end of file diff --git a/Backend/src/lives/dto/live.dto.ts b/Backend/apps/api/src/lives/dto/live.dto.ts similarity index 100% rename from Backend/src/lives/dto/live.dto.ts rename to Backend/apps/api/src/lives/dto/live.dto.ts diff --git a/Backend/src/lives/dto/lives.dto.ts b/Backend/apps/api/src/lives/dto/lives.dto.ts similarity index 100% rename from Backend/src/lives/dto/lives.dto.ts rename to Backend/apps/api/src/lives/dto/lives.dto.ts diff --git a/Backend/src/lives/dto/status.dto.ts b/Backend/apps/api/src/lives/dto/status.dto.ts similarity index 100% rename from Backend/src/lives/dto/status.dto.ts rename to Backend/apps/api/src/lives/dto/status.dto.ts diff --git a/Backend/src/lives/dto/update.live.dto.ts b/Backend/apps/api/src/lives/dto/update.live.dto.ts similarity index 100% rename from Backend/src/lives/dto/update.live.dto.ts rename to Backend/apps/api/src/lives/dto/update.live.dto.ts diff --git a/Backend/src/lives/entity/live.entity.ts b/Backend/apps/api/src/lives/entity/live.entity.ts similarity index 100% rename from Backend/src/lives/entity/live.entity.ts rename to Backend/apps/api/src/lives/entity/live.entity.ts diff --git a/Backend/src/lives/error/error.message.enum.ts b/Backend/apps/api/src/lives/error/error.message.enum.ts similarity index 100% rename from Backend/src/lives/error/error.message.enum.ts rename to Backend/apps/api/src/lives/error/error.message.enum.ts diff --git a/Backend/src/lives/lives.controller.spec.ts b/Backend/apps/api/src/lives/lives.controller.spec.ts similarity index 100% rename from Backend/src/lives/lives.controller.spec.ts rename to Backend/apps/api/src/lives/lives.controller.spec.ts diff --git a/Backend/src/lives/lives.controller.ts b/Backend/apps/api/src/lives/lives.controller.ts similarity index 84% rename from Backend/src/lives/lives.controller.ts rename to Backend/apps/api/src/lives/lives.controller.ts index 2646a3e4..f4528fe6 100644 --- a/Backend/src/lives/lives.controller.ts +++ b/Backend/apps/api/src/lives/lives.controller.ts @@ -10,16 +10,16 @@ import { Req, UseGuards, ValidationPipe, - Query + Query, } from '@nestjs/common'; import { LivesService } from './lives.service'; import { LivesDto } from './dto/lives.dto'; import { LiveDto } from './dto/live.dto'; import { UpdateLiveDto } from './dto/update.live.dto'; import { UUID } from 'crypto'; -import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; -import { UserEntity } from 'src/users/entity/user.entity'; import { StatusDto } from './dto/status.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserEntity } from '../users/entity/user.entity'; @Controller('lives') export class LivesController { @@ -53,7 +53,7 @@ export class LivesController { @Body(ValidationPipe) updateLiveDto: UpdateLiveDto, @Req() req: Request & { user: UserEntity }, ) { - await this.livesService.updateLive({channelId, updateLiveDto, userId: req.user.id}); + await this.livesService.updateLive({ channelId, updateLiveDto, userId: req.user.id }); } @Get('/channel-id/:streamingKey') @@ -68,10 +68,10 @@ export class LivesController { return { code: 0 }; } - @Delete('/onair/:channelId') + @Delete('/onair/:streamingKey') @HttpCode(202) - async endLive(@Param('channelId') channelId: UUID) { - this.livesService.endLive(channelId); + async endLive(@Param('streamingKey') streamingKey: UUID) { + this.livesService.endLive(streamingKey); } @Get('/status/:channelId') diff --git a/Backend/src/lives/lives.module.ts b/Backend/apps/api/src/lives/lives.module.ts similarity index 90% rename from Backend/src/lives/lives.module.ts rename to Backend/apps/api/src/lives/lives.module.ts index 3a029158..377ffdd8 100644 --- a/Backend/src/lives/lives.module.ts +++ b/Backend/apps/api/src/lives/lives.module.ts @@ -3,8 +3,8 @@ import { LivesService } from './lives.service'; import { LivesController } from './lives.controller'; import { LiveEntity } from './entity/live.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ChatsModule } from 'src/chats/chats.module'; import { RedisModule } from '@nestjs-modules/ioredis'; +import { ChatsModule } from '../chats/chats.module'; @Module({ imports: [TypeOrmModule.forFeature([LiveEntity]), ChatsModule, RedisModule], diff --git a/Backend/src/lives/lives.service.spec.ts b/Backend/apps/api/src/lives/lives.service.spec.ts similarity index 100% rename from Backend/src/lives/lives.service.spec.ts rename to Backend/apps/api/src/lives/lives.service.spec.ts diff --git a/Backend/src/lives/lives.service.ts b/Backend/apps/api/src/lives/lives.service.ts similarity index 89% rename from Backend/src/lives/lives.service.ts rename to Backend/apps/api/src/lives/lives.service.ts index dfb6142d..8af7726b 100644 --- a/Backend/src/lives/lives.service.ts +++ b/Backend/apps/api/src/lives/lives.service.ts @@ -1,7 +1,7 @@ import { ConflictException, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { LiveEntity } from './entity/live.entity'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsWhere, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { LivesDto } from './dto/lives.dto'; import { LiveDto } from './dto/live.dto'; import { UUID } from 'crypto'; @@ -20,7 +20,6 @@ export interface ReadLivesOptions { onAir?: boolean; } - @Injectable() export class LivesService { constructor( @@ -70,12 +69,9 @@ export class LivesService { } // 페이지네이션 적용 - const lives = await queryBuilder - .skip(offset) - .take(limit) - .getMany(); + const lives = await queryBuilder.skip(offset).take(limit).getMany(); - return lives.map((entity) => entity.toLivesDto()); + return lives.map(entity => entity.toLivesDto()); } async readLive(channelId: string): Promise { @@ -88,20 +84,28 @@ export class LivesService { return live.toLiveDto(); } - async updateLive({channelId, updateLiveDto, userId}: {channelId: UUID; updateLiveDto: UpdateLiveDto; userId: number;}) { + async updateLive({ + channelId, + updateLiveDto, + userId, + }: { + channelId: UUID; + updateLiveDto: UpdateLiveDto; + userId: number; + }) { const live = await this.livesRepository.findOne({ where: { channelId }, relations: ['user'], }); - + if (!live) { throw new NotFoundException(ErrorMessage.LIVE_NOT_FOUND); } - + if (live.user.id !== userId) { throw new ForbiddenException('권한이 없습니다.'); } - + await this.livesRepository.update({ channelId }, updateLiveDto); } @@ -127,11 +131,11 @@ export class LivesService { } this.chatsService.clearChat(live.channelId as UUID); - await this.livesRepository.update({ streamingKey }, { startedAt: new Date(), onAir: true }); + await this.livesRepository.update({ streamingKey }, { startedAt: new Date(), onAir: true, viewers: 0 }); } - async endLive(channelId: UUID) { - this.livesRepository.update({ channelId }, { onAir: false }); + async endLive(streamingKey: UUID) { + this.livesRepository.update({ streamingKey }, { onAir: false }); } async readStatus(channelId: UUID) { @@ -153,9 +157,7 @@ export class LivesService { } // 카테고리 통계 - async getCategoryStats(): Promise< - { categoriesId: number; liveCount: number; viewerCount: number }[] - > { + async getCategoryStats(): Promise<{ categoriesId: number; liveCount: number; viewerCount: number }[]> { const stats = await this.livesRepository .createQueryBuilder('live') .select('live.categoriesId', 'categoriesId') @@ -165,7 +167,7 @@ export class LivesService { .groupBy('live.categoriesId') .getRawMany(); - return stats.map((stat) => ({ + return stats.map(stat => ({ categoriesId: Number(stat.categoriesId), liveCount: Number(stat.liveCount), viewerCount: Number(stat.viewerCount) || 0, diff --git a/Backend/src/main.ts b/Backend/apps/api/src/main.ts similarity index 76% rename from Backend/src/main.ts rename to Backend/apps/api/src/main.ts index 8594e22e..6c000f6d 100644 --- a/Backend/src/main.ts +++ b/Backend/apps/api/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import * as cookieParser from 'cookie-parser'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -9,13 +10,16 @@ async function bootstrap() { // ConfigService 가져오기 const configService = app.get(ConfigService); + // Winston 로거를 애플리케이션의 로거로 설정 + app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); + // PORT 설정 const port = configService.get('PORT') || 3000; app.use(cookieParser()); // cookie-parser 미들웨어 사용 app.enableCors({ // CORS 설정 - origin: configService.get('CORS').split(',') || '*', + origin: configService.get('CORS')?.split(',') || '*', credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization'], diff --git a/Backend/src/users/dto/create.user.dto.ts b/Backend/apps/api/src/users/dto/create.user.dto.ts similarity index 100% rename from Backend/src/users/dto/create.user.dto.ts rename to Backend/apps/api/src/users/dto/create.user.dto.ts diff --git a/Backend/src/users/dto/update.user.dto.ts b/Backend/apps/api/src/users/dto/update.user.dto.ts similarity index 100% rename from Backend/src/users/dto/update.user.dto.ts rename to Backend/apps/api/src/users/dto/update.user.dto.ts diff --git a/Backend/src/users/entity/user.entity.ts b/Backend/apps/api/src/users/entity/user.entity.ts similarity index 74% rename from Backend/src/users/entity/user.entity.ts rename to Backend/apps/api/src/users/entity/user.entity.ts index 8d89eff6..586a19f7 100644 --- a/Backend/src/users/entity/user.entity.ts +++ b/Backend/apps/api/src/users/entity/user.entity.ts @@ -47,18 +47,20 @@ export class UserEntity { @JoinColumn({ name: 'lives_id' }) live: LiveEntity; - // 👇 following 프로퍼티 추가 - @ManyToMany(() => UserEntity) - @JoinTable({ - name: 'follows', - joinColumn: { - name: 'follower_id', - referencedColumnName: 'id', - }, - inverseJoinColumn: { - name: 'streamer_id', - referencedColumnName: 'id', - }, - }) - following: UserEntity[]; + @ManyToMany(() => UserEntity, (user) => user.followers) + @JoinTable({ + name: 'follows', + joinColumn: { + name: 'follower_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'streamer_id', + referencedColumnName: 'id', + }, + }) + following: UserEntity[]; + + @ManyToMany(() => UserEntity, (user) => user.following) + followers: UserEntity[]; } \ No newline at end of file diff --git a/Backend/src/users/users.controller.spec.ts b/Backend/apps/api/src/users/users.controller.spec.ts similarity index 100% rename from Backend/src/users/users.controller.spec.ts rename to Backend/apps/api/src/users/users.controller.spec.ts diff --git a/Backend/src/users/users.controller.ts b/Backend/apps/api/src/users/users.controller.ts similarity index 100% rename from Backend/src/users/users.controller.ts rename to Backend/apps/api/src/users/users.controller.ts diff --git a/Backend/src/users/users.module.ts b/Backend/apps/api/src/users/users.module.ts similarity index 100% rename from Backend/src/users/users.module.ts rename to Backend/apps/api/src/users/users.module.ts diff --git a/Backend/src/users/users.service.spec.ts b/Backend/apps/api/src/users/users.service.spec.ts similarity index 100% rename from Backend/src/users/users.service.spec.ts rename to Backend/apps/api/src/users/users.service.spec.ts diff --git a/Backend/src/users/users.service.ts b/Backend/apps/api/src/users/users.service.ts similarity index 100% rename from Backend/src/users/users.service.ts rename to Backend/apps/api/src/users/users.service.ts diff --git a/Backend/test/app.e2e-spec.ts b/Backend/apps/api/test/app.e2e-spec.ts similarity index 100% rename from Backend/test/app.e2e-spec.ts rename to Backend/apps/api/test/app.e2e-spec.ts diff --git a/Backend/test/jest-e2e.json b/Backend/apps/api/test/jest-e2e.json similarity index 100% rename from Backend/test/jest-e2e.json rename to Backend/apps/api/test/jest-e2e.json diff --git a/Backend/apps/api/tsconfig.app.json b/Backend/apps/api/tsconfig.app.json new file mode 100644 index 00000000..e2e0b2ff --- /dev/null +++ b/Backend/apps/api/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/api" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/Backend/apps/chats/src/chats.controller.ts b/Backend/apps/chats/src/chats.controller.ts new file mode 100644 index 00000000..f854ae14 --- /dev/null +++ b/Backend/apps/chats/src/chats.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class ChatsController { + constructor() {} + + @Get() + healthCheck() {} +} diff --git a/Backend/src/chats/chats.gateway.spec.ts b/Backend/apps/chats/src/chats.gateway.spec.ts similarity index 100% rename from Backend/src/chats/chats.gateway.spec.ts rename to Backend/apps/chats/src/chats.gateway.spec.ts diff --git a/Backend/apps/chats/src/chats.gateway.ts b/Backend/apps/chats/src/chats.gateway.ts new file mode 100644 index 00000000..cae287ec --- /dev/null +++ b/Backend/apps/chats/src/chats.gateway.ts @@ -0,0 +1,82 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import Redis from 'ioredis'; +import { Server, Socket } from 'socket.io'; +import * as crypto from 'crypto'; +import { UUID } from 'crypto'; +import { plainToInstance } from 'class-transformer'; +import { ChatDto } from './dto/chat.dto'; + +@WebSocketGateway() +export class ChatsGateway implements OnGatewayDisconnect, OnGatewayConnection { + @WebSocketServer() + server: Server; + + private readonly OLD_CHATS_MAXIMUM_SIZE = 50; + + constructor(@InjectRedis() private redisClient: Redis) { + const subscriber = this.redisClient.duplicate(); + subscriber.psubscribe('*:chat'); + subscriber.psubscribe('*:filter'); + subscriber.on('pmessage', async (pattern, channel, message) => { + if (pattern === '*:chat') { + const channelId = channel.split(':')[0]; + const chat = plainToInstance(ChatDto, message); + this.emitChat({ channelId, chat }); + } + }); + + subscriber.on('pmessage', async (pattern, channel, message) => { + if (pattern === '*:filter') { + const channelId = channel.split(':')[0]; + this.emitFilter({ channelId, filteringResult: JSON.parse(message) }); + } + }); + } + + async handleConnection(socket: Socket) { + socket.data.hash = crypto.createHash('sha256').update(socket.handshake.address).digest('hex'); + } + + @SubscribeMessage('join') + async joinChatRoom(@ConnectedSocket() socket: Socket, @MessageBody('channelId') channelId: UUID) { + socket.join(channelId); + socket.data.channelId = channelId; + + const results = await this.redisClient + .multi() + .hincrby(`${channelId}:viewers`, socket.data.hash, 1) + .lrange(`${channelId}:chats`, -this.OLD_CHATS_MAXIMUM_SIZE, -1) + .exec(); + + socket.emit('chat', results[1][1]); + } + + async handleDisconnect(socket: Socket) { + const { hash, channelId } = socket.data; + const redisKey = `${channelId}:viewers`; + + // 사용자 제거 + const results = await this.redisClient.multi().hincrby(redisKey, hash, -1).hget(redisKey, hash).exec(); + + if (typeof results[1][1] === 'string' && parseInt(results[1][1]) <= 0) { + this.redisClient.hdel(redisKey, hash); + } + } + + async emitChat({ channelId, chat }) { + this.server.to(channelId).emit('chat', [chat]); + } + + async emitFilter({ channelId, filteringResult }) { + this.server.to(channelId).emit('filter', [filteringResult]); + } +} diff --git a/Backend/apps/chats/src/chats.module.ts b/Backend/apps/chats/src/chats.module.ts new file mode 100644 index 00000000..23093ea2 --- /dev/null +++ b/Backend/apps/chats/src/chats.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ChatsGateway } from './chats.gateway'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ChatsController } from './chats.controller'; +import { redisConfig } from './config/redis.config'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + RedisModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => redisConfig(configService), + }), + ], + providers: [ChatsGateway], + controllers: [ChatsController], +}) +export class ChatsModule {} diff --git a/Backend/apps/chats/src/config/redis.config.ts b/Backend/apps/chats/src/config/redis.config.ts new file mode 100644 index 00000000..8d2719cd --- /dev/null +++ b/Backend/apps/chats/src/config/redis.config.ts @@ -0,0 +1,11 @@ +import { RedisModuleOptions } from '@nestjs-modules/ioredis'; +import { ConfigService } from '@nestjs/config'; + +export const redisConfig = (configService: ConfigService): RedisModuleOptions => ({ + type: 'single', + url: configService.get('REDIS_URL'), + options: { + password: configService.get('REDIS_PASSWORD'), + retryStrategy: times => configService.get('REDIS_RETRY_MILLISECONDS'), + }, +}); diff --git a/Backend/apps/chats/src/dto/chat.dto.ts b/Backend/apps/chats/src/dto/chat.dto.ts new file mode 100644 index 00000000..0a3e1328 --- /dev/null +++ b/Backend/apps/chats/src/dto/chat.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsNotEmpty, IsNumber, IsDate, IsBoolean } from 'class-validator'; +export class ChatDto { + @IsString() + @IsNotEmpty() + content: string; + + @IsNumber() + @IsNotEmpty() + userId: number; + + @IsString() + @IsNotEmpty() + nickname: string; + + @IsDate() + timestamp: Date; + + @IsBoolean() + fillteringResult: boolean; +} diff --git a/Backend/apps/chats/src/main.ts b/Backend/apps/chats/src/main.ts new file mode 100644 index 00000000..9b17faba --- /dev/null +++ b/Backend/apps/chats/src/main.ts @@ -0,0 +1,40 @@ +import { NestFactory } from '@nestjs/core'; +import { ChatsModule } from './chats.module'; +import { ConfigService } from '@nestjs/config'; + +async function bootstrap() { + const app = await NestFactory.create(ChatsModule); + + const configService = app.get(ConfigService); + + app.enableCors({ + // CORS 설정 + origin: configService.get('CORS')?.split(',') || '*', + methods: ['GET'], + }); + + let port = configService.get('CHATS_PORT') || 9000; + const maxPort = configService.get('CHATS_MAX_PORT') || 10000; + + while (port <= maxPort) { + try { + await app.listen(port); + console.log(`chats is running on: ${await app.getUrl()}`); + break; // 포트를 성공적으로 사용하면 루프 종료 + } catch (error) { + if (error.code === 'EADDRINUSE') { + console.warn(`Port ${port} is already in use. Trying next port...`); + port++; // 포트 번호 증가 + } else { + console.error('Failed to start the server:', error); + process.exit(1); // 다른 오류 발생 시 종료 + } + } + } + + if (port > maxPort) { + console.error('포트 범위를 확인해 주세요'); + process.exit(1); + } +} +bootstrap(); diff --git a/Backend/apps/chats/test/app.e2e-spec.ts b/Backend/apps/chats/test/app.e2e-spec.ts new file mode 100644 index 00000000..d62d54a7 --- /dev/null +++ b/Backend/apps/chats/test/app.e2e-spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { ChatsModule } from './../src/chats.module'; + +describe('ChatsController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ChatsModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/Backend/apps/chats/test/jest-e2e.json b/Backend/apps/chats/test/jest-e2e.json new file mode 100644 index 00000000..e9d912f3 --- /dev/null +++ b/Backend/apps/chats/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/Backend/apps/chats/tsconfig.app.json b/Backend/apps/chats/tsconfig.app.json new file mode 100644 index 00000000..1d8460b0 --- /dev/null +++ b/Backend/apps/chats/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "outDir": "../../dist/apps/chats" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/Backend/nest-cli.json b/Backend/nest-cli.json index f9aa683b..4739c663 100644 --- a/Backend/nest-cli.json +++ b/Backend/nest-cli.json @@ -1,8 +1,32 @@ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", - "sourceRoot": "src", + "sourceRoot": "apps/api/src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "webpack": true, + "tsConfigPath": "apps/api/tsconfig.app.json" + }, + "monorepo": true, + "root": "apps/api", + "projects": { + "backend": { + "type": "application", + "root": "apps/api", + "entryFile": "main", + "sourceRoot": "apps/api/src", + "compilerOptions": { + "tsConfigPath": "apps/api/tsconfig.app.json" + } + }, + "chats": { + "type": "application", + "root": "apps/chats", + "entryFile": "main", + "sourceRoot": "apps/chats/src", + "compilerOptions": { + "tsConfigPath": "apps/chats/tsconfig.app.json" + } + } } } diff --git a/Backend/package-lock.json b/Backend/package-lock.json index c63f57fc..2cc3d57a 100644 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.691.0", "@aws-sdk/lib-storage": "^3.691.0", "@nestjs-modules/ioredis": "^2.0.2", + "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -22,6 +23,7 @@ "@nestjs/platform-socket.io": "^10.4.7", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", @@ -38,7 +40,8 @@ "rxjs": "^7.8.1", "socket.io": "^4.8.1", "typeorm": "^0.3.20", - "winston": "^3.15.0" + "winston": "^3.15.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -2570,6 +2573,17 @@ "ioredis": ">=5.0.0" } }, + "node_modules/@nestjs/axios": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.2.tgz", + "integrity": "sha512-pFlfi4ZQsZtTNNhvgssbxjCHUd1nMpV3sXy/xOOB2uEJhw3M8j8SFR08gjFNil2we2Har7VCsXLfCkwbMHECFQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -5295,7 +5309,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -5334,6 +5347,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -6428,7 +6452,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6873,7 +6896,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -8385,6 +8407,15 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -8506,6 +8537,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -8589,7 +8640,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -11743,6 +11793,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12945,6 +13004,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -15797,6 +15862,24 @@ "node": ">= 12.0.0" } }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, "node_modules/winston-transport": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.8.0.tgz", diff --git a/Backend/package.json b/Backend/package.json index 7cf1a2b6..a077ada2 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -7,23 +7,24 @@ "license": "UNLICENSED", "scripts": { "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "node dist/apps/backend/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./apps/backend/test/jest-e2e.json" }, "dependencies": { "22": "^0.0.0", "@aws-sdk/client-s3": "^3.691.0", "@aws-sdk/lib-storage": "^3.691.0", "@nestjs-modules/ioredis": "^2.0.2", + "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -33,6 +34,7 @@ "@nestjs/platform-socket.io": "^10.4.7", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", @@ -49,7 +51,8 @@ "rxjs": "^7.8.1", "socket.io": "^4.8.1", "typeorm": "^0.3.20", - "winston": "^3.15.0" + "winston": "^3.15.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -88,7 +91,7 @@ "json", "ts" ], - "rootDir": "src", + "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" @@ -96,7 +99,10 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" + "coverageDirectory": "./coverage", + "testEnvironment": "node", + "roots": [ + "/apps/" + ] } } diff --git a/Backend/server/api/deploy.sh b/Backend/server/api/deploy.sh index 13211c63..5f5f03b8 100644 --- a/Backend/server/api/deploy.sh +++ b/Backend/server/api/deploy.sh @@ -23,8 +23,8 @@ npm install || exit 5 npm run build api || exit 6 # 서버 재시작 -forever stop dist/main.js || true # 기존 프로세스가 없어도 오류 발생 방지 -forever start dist/main.js +forever stop dist/apps/api/main.js || true # 기존 프로세스가 없어도 오류 발생 방지 +forever start dist/apps/api/main.js echo "배포 성공" exit 0 diff --git a/Backend/server/encoding/cleanup.sh b/Backend/server/encoding/cleanup.sh index f9bc25cb..c97c85c2 100644 --- a/Backend/server/encoding/cleanup.sh +++ b/Backend/server/encoding/cleanup.sh @@ -1,18 +1,30 @@ #!/bin/bash -#LOG_FILE="/lico/script/cleanup.log" -#exec > >(tee -a "$LOG_FILE") 2>&1 +LOG_FILE="/lico/script/cleanup.log" +exec > >(tee -a "$LOG_FILE") 2>&1 APP_NAME=$1 -CHANNEL_ID=$2 +STREAM_KEY=$2 -echo "Cleanup FFmpeg script for APP_NAME: $APP_NAME, CHANNEL_ID: $CHANNEL_ID" +sleep 5; # 송출 종료 5초 후 방종 처리 +echo "Cleanup FFmpeg script for APP_NAME: $APP_NAME, STREAM_KEY: $STREAM_KEY" +# API 요청으로 채널 아이디 획득 if [[ "$APP_NAME" == "live" ]]; then - curl -X DELETE http://192.168.1.9:3000/lives/onair/$CHANNEL_ID + CHANNEL_ID=$(curl -s http://192.168.1.9:3000/lives/channel-id/$STREAM_KEY | jq -r '.channelId') elif [[ "$APP_NAME" == "dev" ]]; then - curl -X DELETE http://192.168.1.7:3000/lives/onair/$CHANNEL_ID + CHANNEL_ID=$(curl -s http://192.168.1.7:3000/lives/channel-id/$STREAM_KEY | jq -r '.channelId') +else + echo "Error: Unsupported APP_NAME. Exiting." + exit 1 +fi + + +if [[ "$APP_NAME" == "live" ]]; then + curl -X DELETE http://192.168.1.9:3000/lives/onair/$STREAM_KEY +elif [[ "$APP_NAME" == "dev" ]]; then + curl -X DELETE http://192.168.1.7:3000/lives/onair/$STREAM_KEY else echo "Error: Unsupported APP_NAME. Exiting." exit 1 @@ -22,4 +34,5 @@ if [[ -d "/lico/storage/$APP_NAME/$CHANNEL_ID" ]]; then rm -rf "/lico/storage/$APP_NAME/$CHANNEL_ID" echo "Deleted directory: /lico/storage/$APP_NAME/$CHANNEL_ID" else - echo "Directory /lico/storage/$APP_NAME/$CHANNEL_ID does not exist, skipping deletion." \ No newline at end of file + echo "Directory /lico/storage/$APP_NAME/$CHANNEL_ID does not exist, skipping deletion." +fi \ No newline at end of file diff --git a/Backend/server/encoding/nginx.conf b/Backend/server/encoding/nginx.conf index dc331d16..2bf7fbca 100644 --- a/Backend/server/encoding/nginx.conf +++ b/Backend/server/encoding/nginx.conf @@ -16,13 +16,16 @@ rtmp { application live { exec_publish /lico/script/stream_process.sh $app $name; + exec_publish_done /lico/script/cleanup.sh $app $name + access_log /var/log/nginx/rtmp_access.log; } application dev { exec_publish /lico/script/stream_process.sh $app $name; + exec_publish_done /lico/script/cleanup.sh $app $name; + access_log /var/log/nginx/rtmp_access.log; } } - -} \ No newline at end of file +} diff --git a/Backend/server/encoding/stream_process.sh b/Backend/server/encoding/stream_process.sh index 31cd75c1..8c91f166 100644 --- a/Backend/server/encoding/stream_process.sh +++ b/Backend/server/encoding/stream_process.sh @@ -1,7 +1,7 @@ #!/bin/bash -#LOG_FILE="/lico/script/logfile.log" -#exec > >(tee -a "$LOG_FILE") 2>&1 +LOG_FILE="/lico/script/logfile.log" +exec > >(tee -a "$LOG_FILE") 2>&1 APP_NAME=$1 STREAM_KEY=$2 @@ -17,25 +17,21 @@ else exit 1 fi - if [[ -z "$CHANNEL_ID" ]]; then echo "Error: CHANNEL_ID is empty. Exiting." exit 1 fi -ffmpeg -rw_timeout 10000000 -r 30 -i "rtmp://192.168.1.6:1935/$APP_NAME/$STREAM_KEY" \ + +ffmpeg -rw_timeout 10000000 -r 30 -i "rtmp://192.168.1.6:1935/$APP_NAME/$STREAM_KEY" -y \ -filter_complex "[0:v]split=3[720p][for480p][for360p];[for480p]scale=854:480[480p];[for360p]scale=640:360[360p]" \ - -map "[720p]" -map 0:a -c:v:0 libx264 -b:v:0 2800k -s:v:0 1280x720 -preset ultrafast -g 30 -tune zerolatency -profile:v:0 baseline -c:a:0 aac -b:a:0 128k \ - -map "[480p]" -map 0:a -c:v:1 libx264 -b:v:1 1200k -preset ultrafast -g 30 -tune zerolatency -profile:v:1 baseline -c:a:1 copy \ - -map "[360p]" -map 0:a -c:v:2 libx264 -b:v:2 600k -preset ultrafast -g 30 -tune zerolatency -profile:v:2 baseline -c:a:2 copy \ + -map "[720p]" -map 0:a? -c:v:0 libx264 -b:v:0 2800k -s:v:0 1280x720 -preset ultrafast -g 30 -tune zerolatency -profile:v:0 baseline -c:a aac -b:a 128k \ + -map "[480p]" -map 0:a? -c:v:1 libx264 -b:v:1 1200k -preset ultrafast -g 30 -tune zerolatency -profile:v:1 baseline -c:a aac -b:a 128k \ + -map "[360p]" -map 0:a? -c:v:2 libx264 -b:v:2 600k -preset ultrafast -g 30 -tune zerolatency -profile:v:2 baseline -c:a aac -b:a 128k \ -keyint_min 30 -hls_time 1 -hls_segment_type fmp4 -hls_flags delete_segments+independent_segments+append_list \ -hls_list_size 3 -hls_delete_threshold 5 \ -hls_segment_filename "/lico/storage/$APP_NAME/$CHANNEL_ID/%v/%03d.m4s" \ -master_pl_name "index.m3u8" \ - -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \ + -var_stream_map "v:2,a:2 v:1,a:1 v:0,a:0" \ -f hls "/lico/storage/$APP_NAME/$CHANNEL_ID/%v/index.m3u8" \ -map 0:v -vf "fps=1/10,scale=426:240" -update 1 "/lico/storage/$APP_NAME/$CHANNEL_ID/thumbnail.jpg" - - - -/lico/script/cleanup.sh "$APP_NAME" "$CHANNEL_ID" \ No newline at end of file diff --git a/Backend/src/auth/guards/jwt-auth.guard.ts b/Backend/src/auth/guards/jwt-auth.guard.ts deleted file mode 100644 index 18588a5c..00000000 --- a/Backend/src/auth/guards/jwt-auth.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} \ No newline at end of file diff --git a/Backend/src/chats/chats.gateway.ts b/Backend/src/chats/chats.gateway.ts deleted file mode 100644 index b493c016..00000000 --- a/Backend/src/chats/chats.gateway.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; -import { JwtService } from '@nestjs/jwt'; -import { - ConnectedSocket, - MessageBody, - OnGatewayConnection, - OnGatewayDisconnect, - SubscribeMessage, - WebSocketGateway, - WebSocketServer, -} from '@nestjs/websockets'; -import Redis from 'ioredis'; -import { Server, Socket } from 'socket.io'; -import { UsersService } from 'src/users/users.service'; -import * as crypto from 'crypto'; -import { UUID } from 'crypto'; -import { UserEntity } from 'src/users/entity/user.entity'; - -@WebSocketGateway({ namespace: '/chats' }) -export class ChatsGateway implements OnGatewayDisconnect, OnGatewayConnection { - @WebSocketServer() - server: Server; - - private readonly OLD_CHATS_MAXIMUM_SIZE = 50; - - constructor( - @InjectRedis() private redisClient: Redis, - private jwtService: JwtService, - private usersService: UsersService, - ) {} - - @SubscribeMessage('join') - async joinChatRoom(@ConnectedSocket() socket: Socket, @MessageBody('channelId') channelId: UUID) { - socket.join(channelId); - socket.data.channelId = channelId; - const redisKey = `${channelId}:chats`; - - this.redisClient.hincrby(`${channelId}:viewers`, socket.data.user.id, 1); - - const oldChats = await this.redisClient.lrange(redisKey, -this.OLD_CHATS_MAXIMUM_SIZE, -1); - socket.emit('chat', oldChats); - } - - @SubscribeMessage('chat') - async publishChat(@ConnectedSocket() socket: Socket, @MessageBody() receivedChat: { message }) { - const { user, channelId } = socket.data; - - if (user instanceof UserEntity) { - const newChat = JSON.stringify({ - content: receivedChat, - nickname: user.nickname, - userId: user.id, - timestamp: new Date(), - }); - - const redisKey = `${channelId}:chats`; - - this.server.to(channelId).emit('chat', [newChat]); - this.redisClient.rpush(redisKey, newChat); - } - } - - async handleConnection(socket: Socket) { - try { - const token = socket.handshake.auth?.token; - - if (token) { - // 토큰이 있는 경우 - const payload = this.jwtService.verify(token); - const { id } = payload.sub; - const user = await this.usersService.findById(id); - - if (user) { - socket.data.user = user; - } - } - - if (!socket.data.user) { - socket.data.user = { id: crypto.createHash('sha256').update(socket.handshake.address).digest('hex') }; - } - } catch (error) { - // 토큰 검증 실패 등의 경우 - socket.data.user = { id: crypto.createHash('sha256').update(socket.handshake.address).digest('hex') }; - } - - socket.emit('auth', { message: 'authorization completed' }); - } - - async handleDisconnect(socket: Socket) { - const { user, channelId } = socket.data; - const redisKey = `${channelId}:viewers`; - - await this.redisClient.hincrby(redisKey, user.id, -1); - const count = await this.redisClient.hget(redisKey, user.id); - - if (parseInt(count) <= 0) { - this.redisClient.hdel(redisKey, user.id); - } - } -} diff --git a/Backend/src/chats/chats.service.ts b/Backend/src/chats/chats.service.ts deleted file mode 100644 index 6039f4c3..00000000 --- a/Backend/src/chats/chats.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; -import { Injectable } from '@nestjs/common'; -import { UUID } from 'crypto'; -import Redis from 'ioredis'; - -@Injectable() -export class ChatsService { - constructor(@InjectRedis() private redisClient: Redis) {} - - async readViewers(channelId: UUID) { - return await this.redisClient.hlen(`${channelId}:viewers`); - } - - async clearChat(channelId: UUID) { - this.redisClient.del(`${channelId}:chats`); - } -} diff --git a/Backend/src/videos/videos.controller.ts b/Backend/src/videos/videos.controller.ts deleted file mode 100644 index bd14e440..00000000 --- a/Backend/src/videos/videos.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Controller } from '@nestjs/common'; - -@Controller('videos') -export class VideosController {} diff --git a/Backend/src/videos/videos.module.ts b/Backend/src/videos/videos.module.ts deleted file mode 100644 index 971b003b..00000000 --- a/Backend/src/videos/videos.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { VideosController } from './videos.controller'; -import { VideosService } from './videos.service'; - -@Module({ - controllers: [VideosController], - providers: [VideosService] -}) -export class VideosModule {} diff --git a/Backend/src/videos/videos.service.spec.ts b/Backend/src/videos/videos.service.spec.ts deleted file mode 100644 index 9b50a009..00000000 --- a/Backend/src/videos/videos.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { VideosService } from './videos.service'; - -describe('VideosService', () => { - let service: VideosService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [VideosService], - }).compile(); - - service = module.get(VideosService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/Backend/src/videos/videos.service.ts b/Backend/src/videos/videos.service.ts deleted file mode 100644 index b1f76a2d..00000000 --- a/Backend/src/videos/videos.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class VideosService {} diff --git a/Backend/tsconfig.json b/Backend/tsconfig.json index 95f5641c..0828aa10 100644 --- a/Backend/tsconfig.json +++ b/Backend/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "paths": {} } -} +} \ No newline at end of file diff --git a/Frontend/src/apis/category.ts b/Frontend/src/apis/category.ts index a9a36cc1..bcbd1cc7 100644 --- a/Frontend/src/apis/category.ts +++ b/Frontend/src/apis/category.ts @@ -1,6 +1,6 @@ import { api } from './axios'; import type { Category } from '@/types/category'; -import type { Live } from '@/types/live'; +import type { Live, LiveParams } from '@/types/live'; export const categoryApi = { getCategories: async () => { @@ -13,8 +13,13 @@ export const categoryApi = { return data; }, - getCategoryLives: async (categoryId: string) => { - const { data } = await api.get(`/categories/${categoryId}/lives`); + getCategoryLives: async (categoryId: string, params: Omit) => { + const { data } = await api.get(`/categories/${categoryId}/lives`, { + params: { + limit: params.limit, + offset: params.offset, + }, + }); return data; }, }; diff --git a/Frontend/src/apis/chat.ts b/Frontend/src/apis/chat.ts new file mode 100644 index 00000000..59a9e5f7 --- /dev/null +++ b/Frontend/src/apis/chat.ts @@ -0,0 +1,16 @@ +import { api } from './axios'; + +interface SendChatRequest { + channelId: string; + message: string; +} + +export const chatApi = { + sendChat: async ({ channelId, message }: SendChatRequest) => { + const { data } = await api.post('/chats', { + channelId, + message, + }); + return data; + }, +}; diff --git a/Frontend/src/apis/live.ts b/Frontend/src/apis/live.ts index ff73c28d..f453cac5 100644 --- a/Frontend/src/apis/live.ts +++ b/Frontend/src/apis/live.ts @@ -1,10 +1,14 @@ import { api } from './axios'; -import type { Live, LiveDetail, UpdateLiveRequest, StreamingKeyResponse, SortType, LiveStatus } from '@/types/live'; +import type { Live, LiveDetail, UpdateLiveRequest, StreamingKeyResponse, LiveStatus, LiveParams } from '@/types/live'; export const liveApi = { - getLives: async (sort: SortType) => { + getLives: async (params: LiveParams) => { const { data } = await api.get('/lives', { - params: { sort }, + params: { + sort: params.sort, + limit: params.limit, + offset: params.offset, + }, }); return data; }, @@ -28,4 +32,8 @@ export const liveApi = { const { data } = await api.get(`/lives/status/${channelId}`); return data; }, + + finishLive: async (streamingKey: string) => { + await api.delete(`/lives/onair/${streamingKey}`); + }, }; diff --git a/Frontend/src/assets/icons/eraserCursor.svg b/Frontend/src/assets/icons/eraserCursor.svg new file mode 100644 index 00000000..75281188 --- /dev/null +++ b/Frontend/src/assets/icons/eraserCursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/src/assets/icons/pencilCursor.svg b/Frontend/src/assets/icons/pencilCursor.svg new file mode 100644 index 00000000..7791c93a --- /dev/null +++ b/Frontend/src/assets/icons/pencilCursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/src/components/chat/ChatHeader.tsx b/Frontend/src/components/chat/ChatHeader.tsx index 0a19127e..a8d1bc78 100644 --- a/Frontend/src/components/chat/ChatHeader.tsx +++ b/Frontend/src/components/chat/ChatHeader.tsx @@ -22,16 +22,16 @@ export default function ChatHeader({ onClose, onSettingsClick }: ChatHeaderProps aria-label="채팅창 닫기" type="button" onClick={onClose} - className="text-lico-gray-2 hover:text-lico-gray-1" + className="rounded-md p-2 text-lico-gray-2 hover:bg-lico-gray-3" > {videoPlayerState && isVerticalMode ? : } -

채팅

+

채팅

diff --git a/Frontend/src/components/chat/ChatInput.tsx b/Frontend/src/components/chat/ChatInput.tsx index eee4ae44..24182d71 100644 --- a/Frontend/src/components/chat/ChatInput.tsx +++ b/Frontend/src/components/chat/ChatInput.tsx @@ -59,7 +59,7 @@ export default function ChatInput({ onSubmit, isLoggedIn = false }: ChatInputPro
{showLoginAlert && ( -
+

채팅을 시작하기 전에 로그인이 필요합니다.
로그인 후 이용해 주세요. diff --git a/Frontend/src/components/chat/ChatMessage.tsx b/Frontend/src/components/chat/ChatMessage.tsx index c5f0f290..79ec20b9 100644 --- a/Frontend/src/components/chat/ChatMessage.tsx +++ b/Frontend/src/components/chat/ChatMessage.tsx @@ -1,30 +1,36 @@ interface ChatMessageProps { - id: number; + userId: number; content: string; nickname: string; timestamp?: string; color?: string; onUserClick: (userId: number, element: HTMLElement) => void; + filteringResult: boolean; } export default function ChatMessage({ - id, + userId, content, nickname, timestamp = '', color = 'text-lico-orange-2', onUserClick, + filteringResult, }: ChatMessageProps) { return ( ); diff --git a/Frontend/src/components/chat/ChatSettingsMenu.tsx b/Frontend/src/components/chat/ChatSettingsMenu.tsx new file mode 100644 index 00000000..08625f91 --- /dev/null +++ b/Frontend/src/components/chat/ChatSettingsMenu.tsx @@ -0,0 +1,54 @@ +import { RiRobot2Line } from 'react-icons/ri'; +import { BsBoxArrowUpRight } from 'react-icons/bs'; +import Toggle from '@components/common/Toggle'; + +interface ChatSettingsMenuProps { + onClose?: () => void; + position?: { + top: string; + right: string; + }; + cleanBotEnabled: boolean; + onCleanBotChange: (enabled: boolean) => void; +} + +function ChatSettingsMenu({ + onClose, + position = { top: '50px', right: '8px' }, + cleanBotEnabled, + onCleanBotChange, +}: ChatSettingsMenuProps) { + return ( + <> + +

+ + ); +} + +export default ChatSettingsMenu; diff --git a/Frontend/src/components/chat/ChatWindow.tsx b/Frontend/src/components/chat/ChatWindow.tsx index e35f9492..df41020c 100644 --- a/Frontend/src/components/chat/ChatWindow.tsx +++ b/Frontend/src/components/chat/ChatWindow.tsx @@ -1,16 +1,18 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { io, Socket } from 'socket.io-client'; import { FaAngleDown } from 'react-icons/fa'; -import ChatHeader from '@components/chat/ChatHeader'; -import ChatInput from '@components/chat/ChatInput'; -import useLayoutStore from '@store/useLayoutStore'; import { getConsistentTextColor } from '@utils/chatUtils'; -import { io, Socket } from 'socket.io-client'; +import { chatApi } from '@apis/chat'; import { useAuthStore } from '@store/useAuthStore'; import { config } from '@config/env'; +import useLayoutStore from '@store/useLayoutStore'; +import ChatHeader from '@components/chat/ChatHeader'; +import ChatInput from '@components/chat/ChatInput'; import ChatProfileModal from '@components/chat/ChatProfileModal'; import PendingMessageNotification from '@components/chat/PendingMessageNotification'; -import type { Message } from '@/types/live'; +import ChatSettingsMenu from '@components/chat/ChatSettingsMenu'; import ChatMessage from './ChatMessage'; +import type { Message } from '@/types/live'; interface ChatWindowProps { onAir: boolean; @@ -23,6 +25,7 @@ interface SelectedMessage { } const MESSAGE_LIMIT = 200; +const CLEAN_BOT_MESSAGE = '클린봇이 삭제한 메세지입니다.'; const updateMessagesWithLimit = (prevMessages: Message[], newMessages: Message[]) => { const updatedMessages = [...prevMessages, ...newMessages]; @@ -34,6 +37,8 @@ export default function ChatWindow({ onAir, id }: ChatWindowProps) { const [showScrollButton, setShowScrollButton] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); const [isScrollPaused, setIsScrollPaused] = useState(false); + const [showChatSettingsMenu, setShowChatSettingsMenu] = useState(false); + const [cleanBotEnabled, setCleanBotEnabled] = useState(false); const [showPendingMessages, setShowPendingMessages] = useState(false); const [pendingMessages, setPendingMessages] = useState([]); const chatRef = useRef(null); @@ -49,9 +54,9 @@ export default function ChatWindow({ onAir, id }: ChatWindowProps) { chatRef.current.scrollTop = chatRef.current.scrollHeight; }; - const handleNewMessage = (content: string) => { + const handleNewMessage = async (content: string) => { setIsScrollPaused(false); - socketRef.current?.emit('chat', content); + await chatApi.sendChat({ channelId: id, message: content }); }; const handleUserClick = (userId: number, messageElement: HTMLElement) => { @@ -78,9 +83,18 @@ export default function ChatWindow({ onAir, id }: ChatWindowProps) { }, 100); }; + const handleCleanBotChange = (enabled: boolean) => { + setCleanBotEnabled(enabled); + }; + const chatHandler = useCallback( (data: string[]) => { - const newMessages = data.map(v => JSON.parse(v)); + const newMessages = data + .map(v => JSON.parse(v)) + .map(msg => ({ + ...msg, + content: msg.filteringResult ? msg.content : CLEAN_BOT_MESSAGE, + })); if (isScrollPaused) { setShowPendingMessages(true); @@ -128,29 +142,19 @@ export default function ChatWindow({ onAir, id }: ChatWindowProps) { if (!onAir) return undefined; if (socketRef.current?.connected) return undefined; - const socket = io(`${config.apiBaseUrl}/chats`, { + const socket = io(`${config.chatUrl}`, { transports: ['websocket'], - ...(isLoggedIn && { - auth: { - token: accessToken, - }, - }), - }); - - socket.on('auth', ({ message }) => { - if (message === 'authorization completed') socket.emit('join', { channelId: id }); }); + socket.emit('join', { channelId: id }); socketRef.current = socket; return () => { - if (!onAir) { - socket.disconnect(); - socket.removeAllListeners(); - socketRef.current = null; - } + socket.disconnect(); + socket.removeAllListeners(); + socketRef.current = null; }; - }, [onAir, accessToken, id, isLoggedIn]); + }, [onAir, id]); useEffect(() => { const socket = socketRef.current; @@ -158,14 +162,45 @@ export default function ChatWindow({ onAir, id }: ChatWindowProps) { socket.off('chat').on('chat', chatHandler); + const handleFilter = (data: { chatId: string; filteringResult: boolean }[]) => { + const filteredMessage = data[0]; + if (!cleanBotEnabled || filteredMessage.filteringResult) return; + + setMessages(prevMessages => { + const messageIndex = prevMessages.findIndex(msg => msg.chatId === filteredMessage.chatId); + + if (messageIndex === -1 || prevMessages[messageIndex].content === CLEAN_BOT_MESSAGE) { + return prevMessages; + } + + const updatedMessages = [...prevMessages]; + updatedMessages[messageIndex] = { + ...updatedMessages[messageIndex], + content: CLEAN_BOT_MESSAGE, + filteringResult: false, + }; + return updatedMessages; + }); + }; + + socket.off('filter').on('filter', handleFilter); + return () => { socket.off('chat'); + socket.off('filter'); }; - }, [chatHandler]); + }, [chatHandler, cleanBotEnabled]); return (
- {}} /> + setShowChatSettingsMenu(!showChatSettingsMenu)} /> + {showChatSettingsMenu && ( + setShowChatSettingsMenu(false)} + cleanBotEnabled={cleanBotEnabled} + onCleanBotChange={handleCleanBotChange} + /> + )}
{messages?.map(message => ( handleUserClick(userId, element)} /> ))} diff --git a/Frontend/src/components/common/Toggle/index.tsx b/Frontend/src/components/common/Toggle/index.tsx new file mode 100644 index 00000000..d76acb68 --- /dev/null +++ b/Frontend/src/components/common/Toggle/index.tsx @@ -0,0 +1,35 @@ +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + className?: string; +} + +function Toggle({ checked, onChange, className = '' }: ToggleProps) { + return ( + + ); +} + +export default Toggle; diff --git a/Frontend/src/config/env.ts b/Frontend/src/config/env.ts index 9f66173c..ac268678 100644 --- a/Frontend/src/config/env.ts +++ b/Frontend/src/config/env.ts @@ -2,6 +2,7 @@ export const config = { apiBaseUrl: import.meta.env.VITE_API_BASE_URL, + chatUrl: import.meta.env.VITE_CHAT_URL, rtmpUrl: import.meta.env.VITE_RTMP_URL, storageUrl: import.meta.env.VITE_STORAGE_URL, isDevelopment: import.meta.env.VITE_NODE_ENV === 'development', @@ -20,6 +21,9 @@ export const config = { redirectUri: `${import.meta.env.VITE_AUTH_REDIRECT_BASE_URL}/auth/github/callback`, }, }, + webrtcUrl: import.meta.env.VITE_WEBRTC_URL, + whipUrl: import.meta.env.VITE_WHIP_URL, + streamUrl: import.meta.env.VITE_STREAM_URL, } as const; export const urls = { diff --git a/Frontend/src/contexts/CanvasContext.tsx b/Frontend/src/contexts/CanvasContext.tsx new file mode 100644 index 00000000..2159e944 --- /dev/null +++ b/Frontend/src/contexts/CanvasContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, useState, ReactNode } from 'react'; +import { CanvasImage, CanvasText } from '@/types/canvas'; + +interface CanvasContextType { + images: CanvasImage[]; + texts: CanvasText[]; + setImages: (images: CanvasImage[]) => void; + setTexts: (texts: CanvasText[]) => void; +} + +const CanvasContext = createContext(undefined); + +export function CanvasProvider({ children }: { children: ReactNode }) { + const [images, setImages] = useState([]); + const [texts, setTexts] = useState([]); + + return {children}; +} + +export function useCanvasContext() { + const context = useContext(CanvasContext); + if (context === undefined) { + throw new Error('useCanvasContext must be used within a CanvasProvider'); + } + return context; +} diff --git a/Frontend/src/hooks/useCanvasElement.ts b/Frontend/src/hooks/canvas/useCanvasElement.ts similarity index 96% rename from Frontend/src/hooks/useCanvasElement.ts rename to Frontend/src/hooks/canvas/useCanvasElement.ts index 84aa5d7f..efdf9018 100644 --- a/Frontend/src/hooks/useCanvasElement.ts +++ b/Frontend/src/hooks/canvas/useCanvasElement.ts @@ -1,21 +1,9 @@ import { useState, useCallback } from 'react'; - -interface Position { - x: number; - y: number; - width: number; - height: number; -} +import { Position, UseCanvasElementProps } from '@/types/canvas'; type ResizeCorner = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; type ResizeHandle = ResizeCorner | null; -interface UseCanvasElementProps { - minSize?: number; - canvasWidth: number; - canvasHeight: number; -} - export const useCanvasElement = ({ minSize = 100, canvasWidth, canvasHeight }: UseCanvasElementProps) => { const [isResizing, setIsResizing] = useState(null); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); diff --git a/Frontend/src/hooks/canvas/useDrawing.ts b/Frontend/src/hooks/canvas/useDrawing.ts new file mode 100644 index 00000000..4fd5e952 --- /dev/null +++ b/Frontend/src/hooks/canvas/useDrawing.ts @@ -0,0 +1,49 @@ +import { useState, useCallback } from 'react'; +import { DrawingPath, Point } from '@/types/canvas'; + +export function useDrawing() { + const [paths, setPaths] = useState([]); + const [currentPath, setCurrentPath] = useState(null); + + const startDrawing = useCallback((point: Point, color: string, width: number, type: 'draw' | 'erase') => { + const newPath: DrawingPath = { + points: [point], + color, + width, + type, + }; + setCurrentPath(newPath); + setPaths(prev => [...prev, newPath]); + }, []); + + const continueDrawing = useCallback( + (point: Point) => { + if (currentPath) { + const updatedPath = { + ...currentPath, + points: [...currentPath.points, point], + }; + setCurrentPath(updatedPath); + setPaths(prev => [...prev.slice(0, -1), updatedPath]); + } + }, + [currentPath], + ); + + const endDrawing = useCallback(() => { + setCurrentPath(null); + }, []); + + const clearDrawings = useCallback(() => { + setPaths([]); + setCurrentPath(null); + }, []); + + return { + paths, + startDrawing, + continueDrawing, + endDrawing, + clearDrawings, + }; +} diff --git a/Frontend/src/hooks/canvas/useImage.ts b/Frontend/src/hooks/canvas/useImage.ts new file mode 100644 index 00000000..49e6314e --- /dev/null +++ b/Frontend/src/hooks/canvas/useImage.ts @@ -0,0 +1,103 @@ +import { Point } from '@/types/canvas'; +import { CanvasImage } from '@/types/canvas'; +import { useCanvasContext } from '@/contexts/CanvasContext'; + +export function useImage() { + const { images, setImages } = useCanvasContext(); + + const calculateImageDimensions = ( + originalWidth: number, + originalHeight: number, + containerWidth: number, + containerHeight: number, + ) => { + const maxWidth = containerWidth * 0.8; + const maxHeight = containerHeight * 0.8; + const aspectRatio = originalWidth / originalHeight; + let width = originalWidth; + let height = originalHeight; + + if (width > maxWidth) { + width = maxWidth; + height = width / aspectRatio; + } + + if (height > maxHeight) { + height = maxHeight; + width = height * aspectRatio; + } + + width = Math.round(width); + height = Math.round(height); + + return { width, height }; + }; + + const addImage = async (file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = e => { + const img = new Image(); + + img.onload = () => { + const container = document.querySelector('.canvas-container'); + if (!container) { + return reject(new Error('Container not found')); + } + + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + const { width, height } = calculateImageDimensions(img.width, img.height, containerWidth, containerHeight); + + const position: Point = { + x: Math.round((containerWidth - width) / 2), + y: Math.round((containerHeight - height) / 2), + }; + + const newImage: CanvasImage = { + id: crypto.randomUUID(), + element: img, + position, + width, + height, + aspectRatio: img.width / img.height, + }; + + setImages([...images, newImage]); + resolve(); + }; + + img.onerror = () => { + reject(new Error('Failed to load image')); + }; + + img.src = e.target?.result as string; + }; + + reader.onerror = () => { + reject(new Error('Failed to read file')); + }; + + reader.readAsDataURL(file); + }); + }; + + const drawImages = (ctx: CanvasRenderingContext2D) => { + if (!ctx) return; + + images.forEach(image => { + if (image && image.element && image.element.complete) { + ctx.save(); + ctx.drawImage(image.element, image.position.x, image.position.y, image.width, image.height); + ctx.restore(); + } + }); + }; + + return { + addImage, + drawImages, + }; +} diff --git a/Frontend/src/hooks/canvas/useText.ts b/Frontend/src/hooks/canvas/useText.ts new file mode 100644 index 00000000..dfd8bb6a --- /dev/null +++ b/Frontend/src/hooks/canvas/useText.ts @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { Point } from '@/types/canvas'; +import { UseTextProps, TextInputState, CanvasText } from '@/types/canvas'; +import { useCanvasContext } from '@/contexts/CanvasContext'; + +export function useText({ color, fontSize }: UseTextProps) { + const { texts, setTexts } = useCanvasContext(); + const [textInput, setTextInput] = useState({ + isVisible: false, + text: '', + position: { x: 0, y: 0 }, + }); + + const startTextInput = (position: Point) => { + setTextInput({ + isVisible: true, + text: '', + position, + }); + }; + + const updateText = (text: string) => { + const singleLineText = text.replace(/\n/g, ' '); + setTextInput(prev => ({ + ...prev, + text: singleLineText, + })); + }; + + const completeText = (ctx: CanvasRenderingContext2D | null) => { + if (!ctx || !textInput.text.trim()) { + setTextInput({ isVisible: false, text: '', position: { x: 0, y: 0 } }); + return; + } + + const newText: CanvasText = { + id: crypto.randomUUID(), + text: textInput.text, + position: textInput.position, + color, + fontSize, + }; + setTexts([...texts, newText]); + + setTextInput({ isVisible: false, text: '', position: { x: 0, y: 0 } }); + }; + + const cancelText = () => { + setTextInput({ isVisible: false, text: '', position: { x: 0, y: 0 } }); + }; + + const drawTexts = (ctx: CanvasRenderingContext2D) => { + texts.forEach(text => { + ctx.save(); + ctx.font = `${text.fontSize}px Arial`; + ctx.fillStyle = text.color; + ctx.textBaseline = 'top'; + ctx.fillText(text.text, text.position.x, text.position.y); + ctx.restore(); + }); + }; + + return { + textInput, + texts, + startTextInput, + updateText, + completeText, + cancelText, + drawTexts, + }; +} diff --git a/Frontend/src/hooks/useCategory.ts b/Frontend/src/hooks/useCategory.ts index 5e130081..d69d3897 100644 --- a/Frontend/src/hooks/useCategory.ts +++ b/Frontend/src/hooks/useCategory.ts @@ -1,9 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { categoryApi } from '@apis/category'; import { AxiosError } from 'axios'; import type { Category } from '@/types/category'; import type { Live } from '@/types/live'; +const ITEMS_PER_PAGE = 20; + export const categoryKeys = { all: ['categories'] as const, detail: (id: string) => ['categories', id] as const, @@ -26,9 +28,18 @@ export const useCategoryDetail = (categoryId: string) => { }; export const useCategoryLives = (categoryId: string) => { - return useQuery({ - queryKey: [...categoryKeys.detail(categoryId), 'lives'], - queryFn: () => categoryApi.getCategoryLives(categoryId), + return useInfiniteQuery({ + queryKey: categoryKeys.lives(categoryId), + queryFn: ({ pageParam = 0 }) => + categoryApi.getCategoryLives(categoryId, { + limit: ITEMS_PER_PAGE, + offset: pageParam as number, + }), + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.length || lastPage.length < 20) return undefined; + return allPages.length * 20; + }, + initialPageParam: 0, enabled: !!categoryId, }); }; diff --git a/Frontend/src/hooks/useLive.ts b/Frontend/src/hooks/useLive.ts index fc55ff29..383e6bc8 100644 --- a/Frontend/src/hooks/useLive.ts +++ b/Frontend/src/hooks/useLive.ts @@ -1,22 +1,27 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; import { liveApi } from '@apis/live'; import { AxiosError } from 'axios'; -import type { Live, LiveDetail, UpdateLiveRequest, SortType, LiveStatus } from '@/types/live'; +import type { Live, LiveDetail, UpdateLiveRequest, LiveStatus, LiveParams } from '@/types/live'; const POLLING_INTERVAL = 60000; export const liveKeys = { all: ['lives'] as const, - sorted: (sort: SortType) => [...liveKeys.all, { sort }] as const, + sorted: (params: LiveParams) => [...liveKeys.all, params] as const, detail: (channelId: string) => [...liveKeys.all, 'detail', channelId] as const, status: (channelId: string) => [...liveKeys.all, 'status', channelId] as const, streamingKey: () => [...liveKeys.all, 'streaming-key'] as const, }; -export const useLives = (sort: SortType) => { - return useQuery({ - queryKey: liveKeys.sorted(sort), - queryFn: () => liveApi.getLives(sort), +export const useLives = (params: Omit) => { + return useInfiniteQuery({ + queryKey: liveKeys.sorted(params), + queryFn: ({ pageParam }) => liveApi.getLives({ ...params, offset: pageParam as number }), + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.length || lastPage.length < 20) return undefined; + return allPages.length * 20; + }, + initialPageParam: 0, }); }; @@ -53,3 +58,9 @@ export const useStreamingKey = (options?: { enabled?: boolean }) => { enabled: options?.enabled, }); }; + +export const useFinishLive = () => { + return useMutation({ + mutationFn: (streamingKey: string) => liveApi.finishLive(streamingKey), + }); +}; diff --git a/Frontend/src/pages/CategoryPage/CategoryDetailPage.tsx b/Frontend/src/pages/CategoryPage/CategoryDetailPage.tsx index 3096a9b9..2b4a6085 100644 --- a/Frontend/src/pages/CategoryPage/CategoryDetailPage.tsx +++ b/Frontend/src/pages/CategoryPage/CategoryDetailPage.tsx @@ -3,18 +3,42 @@ import ChannelGrid from '@components/channel/ChannelGrid'; import { useCategoryLives, useCategoryDetail } from '@hooks/useCategory'; import { formatUnit } from '@utils/format'; import LoadingSpinner from '@components/common/LoadingSpinner'; +import { useRef, useEffect } from 'react'; +import { config } from '@config/env.ts'; + export default function CategoryDetailPage() { const { categoryId } = useParams(); + const observerRef = useRef(null); if (!categoryId) { return
잘못된 접근입니다.
; } const { data: category, isLoading: categoryLoading, error: categoryError } = useCategoryDetail(categoryId); - const { data: lives, isLoading: livesLoading, error: livesError } = useCategoryLives(categoryId); + const { + data, + isLoading: livesLoading, + error: livesError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useCategoryLives(categoryId); + + useEffect(() => { + const observer = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + if (observerRef.current) { + observer.observe(observerRef.current); + } + + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); - console.log('lives data:', lives); // 데이터 구조 확인 if (categoryLoading || livesLoading) { return ; @@ -24,20 +48,22 @@ export default function CategoryDetailPage() { return
데이터를 불러오는데 실패했습니다.
; } - if (!category || !lives) { + if (!category || !data) { return null; } - const channelsData = lives.map(live => ({ + const allLives = data.pages.flatMap(page => page); + + const channelsData = allLives.map(live => ({ id: live.channelId, title: live.livesName, streamerName: live.usersNickname, profileImgUrl: live.usersProfileImage, - viewers: 0, + viewers: live.viewers, category: live.categoriesName, categoryId: live.categoriesId, streamerId: live.streamerId, - thumbnailUrl: '/default-thumbnail.png', + thumbnailUrl: `${config.storageUrl}/${live.channelId}/thumbnail.jpg`, createdAt: new Date().toISOString(), })); @@ -52,12 +78,14 @@ export default function CategoryDetailPage() {

{category.name}

- 시청자 {formatUnit(totalViewers)}명 · 라이브 {lives.length}개 + 시청자 {formatUnit(totalViewers)}명 · 라이브 {allLives.length}개

+ +
); } diff --git a/Frontend/src/pages/FollowingPage/index.tsx b/Frontend/src/pages/FollowingPage/index.tsx index f9c92218..2ecc831a 100644 --- a/Frontend/src/pages/FollowingPage/index.tsx +++ b/Frontend/src/pages/FollowingPage/index.tsx @@ -1,6 +1,7 @@ import ChannelGrid from '@components/channel/ChannelGrid'; +import { config } from '@config/env.ts'; +import { useFollow } from '@hooks/useFollow'; import OfflineGrid from './OfflineGrid'; -import { useFollow } from '@/hooks/useFollow'; export default function FollowingPage() { const { follows, isLoadingFollows } = useFollow(); @@ -13,7 +14,7 @@ export default function FollowingPage() { category: follow.categoriesName, categoryId: follow.categoriesId, profileImgUrl: follow.usersProfileImage, - thumbnailUrl: '/api/placeholder/400/320', + thumbnailUrl: `${config.storageUrl}/${follow.channelId}/thumbnail.jpg`, viewers: 0, isLive: follow.onAir, createdAt: new Date().toISOString(), diff --git a/Frontend/src/pages/LivesPage/index.tsx b/Frontend/src/pages/LivesPage/index.tsx index 77c316e0..8ff6f2e4 100644 --- a/Frontend/src/pages/LivesPage/index.tsx +++ b/Frontend/src/pages/LivesPage/index.tsx @@ -6,11 +6,35 @@ import { useLives } from '@hooks/useLive'; import { useSortStore } from '@store/useSortStore'; import LoadingSpinner from '@components/common/LoadingSpinner'; import { config } from '@config/env.ts'; +import { useEffect, useRef } from 'react'; + +const ITEMS_PER_PAGE = 20; export default function LivesPage() { const { sortType, setSortType } = useSortStore(); + const observerRef = useRef(null); + + const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useLives({ + sort: sortType, + limit: ITEMS_PER_PAGE, + }); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1, rootMargin: '500px' }, + ); + + if (observerRef.current) { + observer.observe(observerRef.current); + } - const { data: lives, isLoading, error } = useLives(sortType); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); if (isLoading) return ( @@ -19,10 +43,10 @@ export default function LivesPage() {
); if (error) return
에러가 발생했습니다
; - if (!lives) return null; + if (!data) return null; return ( -
+
전체 방송
setSortType('random')} + onClick={() => setSortType('recommendation')} />
({ + channels={data.pages.flat().map(live => ({ id: live.channelId, title: live.livesName, streamerName: live.usersNickname, @@ -57,6 +81,8 @@ export default function LivesPage() { createdAt: new Date().toISOString(), }))} /> + +
); } diff --git a/Frontend/src/pages/StudioPage/DrawCanvas.tsx b/Frontend/src/pages/StudioPage/DrawCanvas.tsx new file mode 100644 index 00000000..d6fd9300 --- /dev/null +++ b/Frontend/src/pages/StudioPage/DrawCanvas.tsx @@ -0,0 +1,360 @@ +import { useState, useEffect, forwardRef } from 'react'; +import { useDrawing } from '@hooks/canvas/useDrawing'; +import { useText } from '@hooks/canvas/useText'; +import { useImage } from '@hooks/canvas/useImage'; +import { Point } from '@/types/canvas'; +import pencilCursor from '@assets/icons/pencilCursor.svg?url'; +import eraserCursor from '@assets/icons/eraserCursor.svg?url'; +import { useCanvasContext } from '@/contexts/CanvasContext'; +import { CanvasElementDeleteModal } from './Modals/CanvasElementDeleteModal'; + +interface ContextMenu { + show: boolean; + x: number; + y: number; + type: 'text' | 'image'; + targetId: string; +} + +interface DrawCanvasProps { + drawingState: { + isDrawing: boolean; + isErasing: boolean; + isTexting: boolean; + drawTool: { color: string; width: number }; + eraseTool: { width: number }; + textTool: { color: string; width: number }; + }; +} + +export const DrawCanvas = forwardRef(({ drawingState }, ref) => { + const { paths, startDrawing, continueDrawing, endDrawing } = useDrawing(); + const { textInput, startTextInput, updateText, completeText, cancelText, drawTexts } = useText({ + color: drawingState.textTool.color, + fontSize: drawingState.textTool.width, + }); + const { drawImages } = useImage(); + const { texts, setTexts, images, setImages } = useCanvasContext(); + + const [contextMenu, setContextMenu] = useState({ + show: false, + x: 0, + y: 0, + type: 'text', + targetId: '', + }); + + const getCanvasPoint = (e: React.MouseEvent): Point => { + const canvas = ref as React.RefObject; + if (!canvas.current) return { x: 0, y: 0 }; + + const rect = canvas.current.getBoundingClientRect(); + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + }; + + useEffect(() => { + const handleClick = () => { + setContextMenu(prev => ({ ...prev, show: false })); + }; + + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); + }, []); + + const handleCanvasContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + const point = getCanvasPoint(e); + + const clickedText = texts.find(text => { + const canvas = (ref as React.RefObject).current; + if (!canvas) return false; + + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + + ctx.font = `${text.fontSize}px Arial`; + const metrics = ctx.measureText(text.text); + + return ( + point.x >= text.position.x && + point.x <= text.position.x + metrics.width && + point.y >= text.position.y && + point.y <= text.position.y + text.fontSize + ); + }); + + if (clickedText) { + setContextMenu({ + show: true, + x: e.clientX, + y: e.clientY, + type: 'text', + targetId: clickedText.id, + }); + return; + } + + const clickedImage = images.find(image => { + return ( + point.x >= image.position.x && + point.x <= image.position.x + image.width && + point.y >= image.position.y && + point.y <= image.position.y + image.height + ); + }); + + if (clickedImage) { + setContextMenu({ + show: true, + x: e.clientX, + y: e.clientY, + type: 'image', + targetId: clickedImage.id, + }); + } + }; + + const handleDelete = () => { + if (contextMenu.type === 'text') { + setTexts(texts.filter(text => text.id !== contextMenu.targetId)); + } else { + setImages(images.filter(image => image.id !== contextMenu.targetId)); + } + setContextMenu(prev => ({ ...prev, show: false })); + }; + + useEffect(() => { + const canvas = ref as React.RefObject; + const ctx = canvas.current?.getContext('2d', { alpha: true }); + + if (!canvas.current || !ctx) return; + + const updateCanvas = () => { + const container = canvas.current!.parentElement; + if (container) { + const scale = window.devicePixelRatio; + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + canvas.current!.width = containerWidth * scale; + canvas.current!.height = containerHeight * scale; + canvas.current!.style.width = `${containerWidth}px`; + canvas.current!.style.height = `${containerHeight}px`; + + ctx.scale(scale, scale); + } + + ctx.clearRect( + 0, + 0, + canvas.current!.width / window.devicePixelRatio, + canvas.current!.height / window.devicePixelRatio, + ); + + drawImages(ctx); + + paths.forEach(path => { + if (path.points.length < 2) return; + + ctx.beginPath(); + ctx.moveTo(path.points[0].x, path.points[0].y); + + for (let i = 1; i < path.points.length; i++) { + ctx.lineTo(path.points[i].x, path.points[i].y); + } + + if (path.type === 'erase') { + ctx.globalCompositeOperation = 'destination-out'; + ctx.strokeStyle = 'rgba(0,0,0,1)'; + } else { + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = path.color; + } + + ctx.lineWidth = path.width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + }); + + ctx.globalCompositeOperation = 'source-over'; + drawTexts(ctx); + }; + + updateCanvas(); + + const resizeObserver = new ResizeObserver(() => { + updateCanvas(); + }); + + if (canvas.current.parentElement) { + resizeObserver.observe(canvas.current.parentElement); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [ref, paths, drawTexts, drawImages]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && textInput.isVisible) { + cancelText(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [textInput.isVisible, cancelText]); + + useEffect(() => { + let isMouseDown = false; + let mouseDownTarget: EventTarget | null = null; + + const handleMouseDown = (e: MouseEvent) => { + isMouseDown = true; + mouseDownTarget = e.target; + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!isMouseDown || !textInput.isVisible) { + isMouseDown = false; + mouseDownTarget = null; + return; + } + + const canvas = (ref as React.RefObject).current; + const textInputElement = document.getElementById('text-input'); + + if (e.target === mouseDownTarget) { + const isClickInsideCanvas = canvas?.contains(e.target as Node); + const isClickInsideTextInput = textInputElement?.contains(e.target as Node); + + if (!isClickInsideCanvas && !isClickInsideTextInput) { + if (!textInput.text) { + cancelText(); + } else { + const ctx = canvas?.getContext('2d') || null; + completeText(ctx); + } + } + } + + isMouseDown = false; + mouseDownTarget = null; + }; + + window.addEventListener('mousedown', handleMouseDown); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousedown', handleMouseDown); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [ref, textInput.isVisible, textInput.text, cancelText, completeText]); + + const handleMouseDown = (e: React.MouseEvent) => { + const point = getCanvasPoint(e); + + if (textInput.isVisible) { + cancelText(); + return; + } + + if (drawingState.isTexting) { + startTextInput(point); + return; + } + + if (drawingState.isDrawing || drawingState.isErasing) { + startDrawing( + point, + drawingState.isDrawing ? drawingState.drawTool.color : '#ffffff', + drawingState.isDrawing ? drawingState.drawTool.width : drawingState.eraseTool.width, + drawingState.isErasing ? 'erase' : 'draw', + ); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + const canvas = ref as React.RefObject; + if (!canvas.current) return; + + const point = getCanvasPoint(e); + + if (drawingState.isTexting) { + canvas.current.style.cursor = 'text'; + return; + } + + if (drawingState.isDrawing) { + canvas.current.style.cursor = `url(${pencilCursor}) 0 24, crosshair`; + continueDrawing(point); + return; + } + + if (drawingState.isErasing) { + canvas.current.style.cursor = `url(${eraserCursor}) 8 24, cell`; + continueDrawing(point); + return; + } + + canvas.current.style.cursor = 'default'; + }; + return ( + <> + + {textInput.isVisible && ( +
+ updateText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + const canvas = ref as React.RefObject; + const ctx = canvas.current?.getContext('2d') || null; + completeText(ctx); + } + }} + className="m-0 min-w-[100px] border-none bg-transparent p-0 outline-none" + style={{ + color: drawingState.textTool.color, + fontSize: `${drawingState.textTool.width}px`, + lineHeight: '1', + }} + autoFocus + placeholder="텍스트 입력" + /> +
+ )} + + + ); +}); diff --git a/Frontend/src/pages/StudioPage/Modals/CanvasElementDeleteModal.tsx b/Frontend/src/pages/StudioPage/Modals/CanvasElementDeleteModal.tsx new file mode 100644 index 00000000..1e4a53a7 --- /dev/null +++ b/Frontend/src/pages/StudioPage/Modals/CanvasElementDeleteModal.tsx @@ -0,0 +1,103 @@ +import { useRef, useEffect } from 'react'; + +interface CanvasElementDeleteModalProps { + show: boolean; + x: number; + y: number; + onDelete: () => void; + canvasRef: React.ForwardedRef | React.RefObject; +} + +export function CanvasElementDeleteModal({ show, x, y, onDelete, canvasRef }: CanvasElementDeleteModalProps) { + const modalRef = useRef(null); + + const getCanvasElement = (): HTMLCanvasElement | null => { + if (!canvasRef) return null; + if ('current' in canvasRef) return canvasRef.current; + return null; + }; + + const getModalPosition = () => { + const canvas = getCanvasElement(); + if (!canvas) return { x, y }; + + const canvasRect = canvas.getBoundingClientRect(); + const modalElement = modalRef.current; + if (!modalElement) return { x, y }; + + const modalRect = modalElement.getBoundingClientRect(); + const modalWidth = modalRect.width; + const modalHeight = modalRect.height; + + const gridWidth = canvasRect.width / 4; + const gridHeight = canvasRect.height / 4; + + const relativeX = x - canvasRect.left; + const relativeY = y - canvasRect.top; + + const gridX = Math.floor(relativeX / gridWidth); + const gridY = Math.floor(relativeY / gridHeight); + + let modalX = relativeX; + let modalY = relativeY; + + if (gridX === 3 && gridY === 3) { + modalX -= modalWidth; + modalY -= modalHeight; + } else if (gridX === 3 && gridY >= 0 && gridY <= 2) { + modalX -= modalWidth; + } else if (gridX >= 0 && gridX <= 2 && gridY === 3) { + modalY -= modalHeight; + } else { + } + + modalX = Math.max(0, Math.min(modalX, canvasRect.width - modalWidth)); + modalY = Math.max(0, Math.min(modalY, canvasRect.height - modalHeight)); + + return { x: modalX, y: modalY }; + }; + + useEffect(() => { + if (!show || !modalRef.current) return; + + const updatePosition = () => { + if (!modalRef.current) return; + + requestAnimationFrame(() => { + const { x: modalX, y: modalY } = getModalPosition(); + modalRef.current!.style.left = `${modalX}px`; + modalRef.current!.style.top = `${modalY}px`; + }); + }; + + updatePosition(); + + const canvas = getCanvasElement(); + if (canvas) { + const resizeObserver = new ResizeObserver(updatePosition); + resizeObserver.observe(canvas); + return () => resizeObserver.disconnect(); + } + }, [show, x, y]); + + if (!show) return null; + + return ( + + ); +} diff --git a/Frontend/src/pages/StudioPage/Modals/Palette.tsx b/Frontend/src/pages/StudioPage/Modals/Palette.tsx new file mode 100644 index 00000000..c1640de0 --- /dev/null +++ b/Frontend/src/pages/StudioPage/Modals/Palette.tsx @@ -0,0 +1,95 @@ +import { ChangeEvent } from 'react'; +import { ToolState } from '@/types/canvas'; + +interface ToolSettingProps { + toolState: ToolState; + onStateChange: (state: Partial) => void; + isErasing?: boolean; +} + +export default function Palette({ toolState, onStateChange, isErasing = false }: ToolSettingProps) { + const presetColors = [ + { color: '#FFFFFF', label: '흰색' }, + { color: '#000000', label: '검정' }, + { color: '#F75354', label: '빨강' }, + { color: '#FF6B34', label: '주황' }, + { color: '#027354', label: '초록' }, + { color: '#04458F', label: '파랑' }, + ]; + + const presetSizes = [ + { size: 2, dimension: 4 }, + { size: 5, dimension: 8 }, + { size: 10, dimension: 12 }, + { size: 20, dimension: 16 }, + { size: 50, dimension: 28 }, + ]; + + const handleColorClick = (color: string) => { + onStateChange({ color }); + }; + + const handleSizeClick = (width: number) => { + onStateChange({ width }); + }; + + const handleCustomColorChange = (e: ChangeEvent) => { + onStateChange({ color: e.target.value }); + }; + + return ( +
+ {!isErasing && ( +
+ 색상 +
+ {presetColors.map(({ color, label }) => ( +
+
+ )} +
+ 두께 +
+ {presetSizes.map(({ size, dimension }) => ( + + ))} +
+
+
+ ); +} diff --git a/Frontend/src/pages/StudioPage/Modals/TextSetting.tsx b/Frontend/src/pages/StudioPage/Modals/TextSetting.tsx new file mode 100644 index 00000000..0373420f --- /dev/null +++ b/Frontend/src/pages/StudioPage/Modals/TextSetting.tsx @@ -0,0 +1,98 @@ +import { ChangeEvent } from 'react'; +import { ToolState } from '@/types/canvas'; + +interface ToolSettingProps { + toolState: ToolState; + onStateChange: (state: Partial) => void; +} + +export default function TextSetting({ toolState, onStateChange }: ToolSettingProps) { + const presetColors = [ + { color: '#FFFFFF', label: '흰색' }, + { color: '#000000', label: '검정' }, + { color: '#F75354', label: '빨강' }, + { color: '#FF6B34', label: '주황' }, + { color: '#027354', label: '초록' }, + { color: '#04458F', label: '파랑' }, + ]; + + const handleColorClick = (color: string) => { + onStateChange({ color }); + }; + + const handleCustomColorChange = (e: ChangeEvent) => { + onStateChange({ color: e.target.value }); + }; + + const handleFontSizeChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === '') { + onStateChange({ width: 0 }); + return; + } + + const size = parseInt(value); + if (!isNaN(size)) { + const clampedSize = Math.min(Math.max(0, size), 100); + onStateChange({ width: clampedSize }); + } + }; + + const handleFontSizeBlur = (e: React.FocusEvent) => { + const value = e.target.value; + + if (value === '' || parseInt(value) < 1) { + onStateChange({ width: 1 }); + return; + } + + const size = parseInt(value); + const clampedSize = Math.min(Math.max(1, size), 100); + onStateChange({ width: clampedSize }); + }; + + return ( +
+
+ 색상 +
+ {presetColors.map(({ color, label }) => ( +
+
+
+ 글자 크기 +
+ + px +
+
+
+ ); +} diff --git a/Frontend/src/pages/StudioPage/StreamCanvas.tsx b/Frontend/src/pages/StudioPage/StreamCanvas.tsx new file mode 100644 index 00000000..7b48d854 --- /dev/null +++ b/Frontend/src/pages/StudioPage/StreamCanvas.tsx @@ -0,0 +1,117 @@ +import { forwardRef, useEffect, useRef } from 'react'; +import { Position } from '@/types/canvas'; + +interface StreamCanvasProps { + screenStream: MediaStream | null; + mediaStream: MediaStream | null; + screenPosition: Position; + camPosition: Position; + getIsCamFlipped: () => boolean; +} + +export const StreamCanvas = forwardRef( + ({ screenStream, mediaStream, camPosition, getIsCamFlipped }, ref) => { + const screenVideoRef = useRef(null); + const mediaVideoRef = useRef(null); + const animationFrameRef = useRef(); + + useEffect(() => { + if (screenVideoRef.current && screenStream) { + screenVideoRef.current.srcObject = screenStream; + } + }, [screenStream]); + + useEffect(() => { + if (mediaVideoRef.current && mediaStream) { + mediaVideoRef.current.srcObject = mediaStream; + } + }, [mediaStream]); + + useEffect(() => { + const canvas = ref as React.RefObject; + const ctx = canvas.current?.getContext('2d', { alpha: false }); + const screenVideo = screenVideoRef.current; + const mediaVideo = mediaVideoRef.current; + + if (!canvas.current || !ctx) return; + + ctx.imageSmoothingEnabled = false; + + const updateCanvas = () => { + const container = canvas.current!.parentElement; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const containerWidth = containerRect.width; + const containerHeight = containerRect.height; + + canvas.current!.style.width = `${containerWidth}px`; + canvas.current!.style.height = `${containerHeight}px`; + + const scale = window.devicePixelRatio; + canvas.current!.width = Math.floor(containerWidth * scale); + canvas.current!.height = Math.floor(containerHeight * scale); + + ctx.scale(scale, scale); + + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, containerWidth, containerHeight); + + if (screenVideo && screenStream && screenVideo.videoWidth > 0) { + const videoRatio = screenVideo.videoWidth / screenVideo.videoHeight; + let renderWidth = containerWidth; + let renderHeight = containerWidth / videoRatio; + + if (renderHeight > containerHeight) { + renderHeight = containerHeight; + renderWidth = containerHeight * videoRatio; + } + + const x = (containerWidth - renderWidth) / 2; + const y = (containerHeight - renderHeight) / 2; + + ctx.drawImage(screenVideo, x, y, renderWidth, renderHeight); + } + + if (mediaVideo && mediaStream && mediaStream.getVideoTracks().length > 0) { + ctx.save(); + if (getIsCamFlipped()) { + ctx.translate(camPosition.x + camPosition.width, camPosition.y); + ctx.scale(-1, 1); + ctx.drawImage(mediaVideo, 0, 0, camPosition.width, camPosition.height); + } else { + ctx.drawImage(mediaVideo, camPosition.x, camPosition.y, camPosition.width, camPosition.height); + } + ctx.restore(); + } + + animationFrameRef.current = requestAnimationFrame(updateCanvas); + }; + + if (screenVideo) { + screenVideo.onloadedmetadata = updateCanvas; + } + if (mediaVideo) { + mediaVideo.onloadedmetadata = updateCanvas; + } + + animationFrameRef.current = requestAnimationFrame(updateCanvas); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [ref, screenStream, mediaStream, camPosition, getIsCamFlipped]); + + return ( + <> + +