├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── chore.md │ ├── config.md │ ├── docs.md │ ├── feature.md │ ├── refactor.md │ └── test.md ├── pull_request_template.md └── workflows │ ├── chats-deploy.yml │ ├── develop-api-deploy.yml │ └── main-api-deploy.yml ├── Backend ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── apps │ ├── api │ │ ├── src │ │ │ ├── app.controller.spec.ts │ │ │ ├── app.controller.ts │ │ │ ├── app.module.ts │ │ │ ├── app.service.ts │ │ │ ├── auth │ │ │ │ ├── auth.controller.spec.ts │ │ │ │ ├── auth.controller.ts │ │ │ │ ├── auth.module.ts │ │ │ │ ├── auth.service.spec.ts │ │ │ │ ├── auth.service.ts │ │ │ │ ├── guards │ │ │ │ │ └── jwt-auth.guard.ts │ │ │ │ └── strategies │ │ │ │ │ ├── github.strategy.ts │ │ │ │ │ ├── google.strategy.ts │ │ │ │ │ ├── jwt.strategy.ts │ │ │ │ │ ├── lico.strategy.ts │ │ │ │ │ ├── naver.strategy.ts │ │ │ │ │ └── nickname-data.ts │ │ │ ├── categories │ │ │ │ ├── categories.controller.spec.ts │ │ │ │ ├── categories.controller.ts │ │ │ │ ├── categories.module.ts │ │ │ │ ├── categories.service.spec.ts │ │ │ │ ├── categories.service.ts │ │ │ │ ├── dto │ │ │ │ │ ├── categories.dto.ts │ │ │ │ │ └── category.dto.ts │ │ │ │ ├── entity │ │ │ │ │ └── category.entity.ts │ │ │ │ └── error │ │ │ │ │ └── error.message.enum.ts │ │ │ ├── chats │ │ │ │ ├── chats.controller.spec.ts │ │ │ │ ├── chats.controller.ts │ │ │ │ ├── chats.module.ts │ │ │ │ ├── chats.service.spec.ts │ │ │ │ ├── chats.service.ts │ │ │ │ └── dto │ │ │ │ │ └── send.chat.dto.ts │ │ │ ├── common │ │ │ │ ├── filters │ │ │ │ │ └── http-exception.filter.ts │ │ │ │ ├── interceptors │ │ │ │ │ ├── logging.interceptor.ts │ │ │ │ │ └── monitoring.interceptor.ts │ │ │ │ ├── middleware │ │ │ │ │ └── request-time.middleware.ts │ │ │ │ └── services │ │ │ │ │ └── cloud-insight.service.ts │ │ │ ├── config │ │ │ │ ├── logger.config.ts │ │ │ │ ├── mysql.config.ts │ │ │ │ ├── redis.config.ts │ │ │ │ ├── sqlite.config.ts │ │ │ │ └── typeorm.config.ts │ │ │ ├── follow │ │ │ │ ├── follow.controller.spec.ts │ │ │ │ ├── follow.controller.ts │ │ │ │ ├── follow.module.ts │ │ │ │ ├── follow.service.spec.ts │ │ │ │ └── follow.service.ts │ │ │ ├── lives │ │ │ │ ├── dto │ │ │ │ │ ├── live.dto.ts │ │ │ │ │ ├── lives.dto.ts │ │ │ │ │ ├── status.dto.ts │ │ │ │ │ └── update.live.dto.ts │ │ │ │ ├── entity │ │ │ │ │ └── live.entity.ts │ │ │ │ ├── error │ │ │ │ │ └── error.message.enum.ts │ │ │ │ ├── lives.controller.spec.ts │ │ │ │ ├── lives.controller.ts │ │ │ │ ├── lives.module.ts │ │ │ │ ├── lives.service.spec.ts │ │ │ │ └── lives.service.ts │ │ │ ├── main.ts │ │ │ └── users │ │ │ │ ├── dto │ │ │ │ ├── create.user.dto.ts │ │ │ │ └── update.user.dto.ts │ │ │ │ ├── entity │ │ │ │ └── user.entity.ts │ │ │ │ ├── users.controller.spec.ts │ │ │ │ ├── users.controller.ts │ │ │ │ ├── users.module.ts │ │ │ │ ├── users.service.spec.ts │ │ │ │ └── users.service.ts │ │ ├── test │ │ │ ├── app.e2e-spec.ts │ │ │ └── jest-e2e.json │ │ └── tsconfig.app.json │ └── chats │ │ ├── src │ │ ├── chats.controller.ts │ │ ├── chats.gateway.spec.ts │ │ ├── chats.gateway.ts │ │ ├── chats.module.ts │ │ ├── config │ │ │ └── redis.config.ts │ │ ├── dto │ │ │ └── chat.dto.ts │ │ └── main.ts │ │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ │ └── tsconfig.app.json ├── eslint.config.js ├── nest-cli.json ├── package-lock.json ├── package.json ├── server │ ├── api │ │ └── deploy.sh │ ├── chats │ │ └── deploy.sh │ ├── encoding │ │ ├── cleanup.sh │ │ ├── nginx.conf │ │ └── stream_process.sh │ └── ingest(srs) │ │ ├── lico.conf │ │ └── nginx.conf ├── tsconfig.build.json └── tsconfig.json ├── Frontend ├── .eslintrc.cjs ├── .gitignore ├── .netlifyignore ├── .prettierrc ├── index.html ├── lib │ ├── buffer │ │ ├── core │ │ │ └── buffer.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── types.ts │ │ └── utils │ │ │ └── constants.ts │ ├── controller │ │ └── HLSController.ts │ ├── manifest │ │ ├── __test__ │ │ │ ├── parser.fixtures.ts │ │ │ └── parser.test.ts │ │ ├── core │ │ │ ├── loader.ts │ │ │ ├── parser.ts │ │ │ └── validator.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── types.ts │ │ └── utils │ │ │ ├── constants.ts │ │ │ └── utils.ts │ └── segment │ │ ├── core │ │ └── loader.ts │ │ ├── index.ts │ │ ├── types │ │ └── types.ts │ │ └── utils │ │ ├── constants.ts │ │ └── utils.ts ├── netlify.toml ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── apple-touch-icon.png │ ├── favicon.ico │ └── og-image.png ├── src │ ├── App.tsx │ ├── apis │ │ ├── auth.ts │ │ ├── axios.ts │ │ ├── category.ts │ │ ├── chat.ts │ │ ├── follow.ts │ │ ├── live.ts │ │ └── user.ts │ ├── assets │ │ ├── fonts │ │ │ ├── Pretendard-Bold.woff2 │ │ │ └── Pretendard-Medium.woff2 │ │ ├── icons │ │ │ ├── eraserCursor.svg │ │ │ ├── pencilCursor.svg │ │ │ └── socialLoginIcons.tsx │ │ └── images │ │ │ └── offline.gif │ ├── components │ │ ├── VideoPlayer │ │ │ ├── Control │ │ │ │ ├── SettingsControl.tsx │ │ │ │ ├── VolumeControl.tsx │ │ │ │ └── index.tsx │ │ │ ├── OfflinePlayer.tsx │ │ │ └── index.tsx │ │ ├── category │ │ │ ├── CategoryCard │ │ │ │ └── index.tsx │ │ │ └── CategoryGrid │ │ │ │ └── index.tsx │ │ ├── channel │ │ │ ├── ChannelCard │ │ │ │ ├── ChannelInfo.tsx │ │ │ │ ├── ChannelThumbnail.tsx │ │ │ │ ├── HoverPreviewPlayer.tsx │ │ │ │ ├── ThumbnailSkeleton.tsx │ │ │ │ └── index.tsx │ │ │ └── ChannelGrid │ │ │ │ └── index.tsx │ │ ├── chat │ │ │ ├── ChatHeader.tsx │ │ │ ├── ChatInput.tsx │ │ │ ├── ChatMessage.tsx │ │ │ ├── ChatProfileModal.tsx │ │ │ ├── ChatSettingsMenu.tsx │ │ │ ├── ChatWindow.tsx │ │ │ └── PendingMessageNotification.tsx │ │ ├── common │ │ │ ├── Badges │ │ │ │ ├── Badge.tsx │ │ │ │ └── CategoryBadge.tsx │ │ │ ├── Buttons │ │ │ │ ├── ChatOpenButton.tsx │ │ │ │ ├── FollowButton.tsx │ │ │ │ └── SortButton.tsx │ │ │ ├── Dropdown │ │ │ │ └── index.tsx │ │ │ ├── LoadingSpinner │ │ │ │ └── index.tsx │ │ │ ├── Modals │ │ │ │ └── LoginConfirmModal.tsx │ │ │ ├── SearchInput │ │ │ │ └── index.tsx │ │ │ ├── Toast │ │ │ │ └── index.tsx │ │ │ └── Toggle │ │ │ │ └── index.tsx │ │ ├── error │ │ │ └── NotFound.tsx │ │ └── layout │ │ │ ├── NavItem.tsx │ │ │ └── Navbar.tsx │ ├── config │ │ ├── env.ts │ │ └── queryClient.ts │ ├── constants │ │ ├── categories.ts │ │ └── chat │ │ │ ├── color.ts │ │ │ └── input.ts │ ├── hooks │ │ ├── canvas │ │ │ ├── useCanvasElement.ts │ │ │ ├── useDrawing.ts │ │ │ ├── useImage.ts │ │ │ └── useText.ts │ │ ├── chat │ │ │ ├── useChatMessages.ts │ │ │ ├── useChatScroll.ts │ │ │ └── useChatSocket.ts │ │ ├── useAuth.ts │ │ ├── useCategory.ts │ │ ├── useCheckStream.ts │ │ ├── useClickOutside.ts │ │ ├── useDebounce.ts │ │ ├── useDelayedLoading.ts │ │ ├── useFollow.ts │ │ ├── useHls.ts │ │ ├── useLive.ts │ │ ├── useMediaQuery.ts │ │ ├── useSearch.ts │ │ └── useUser.ts │ ├── layouts │ │ └── Layout.tsx │ ├── main.tsx │ ├── pages │ │ ├── CategoryPage │ │ │ ├── CategoryDetailPage.tsx │ │ │ └── index.tsx │ │ ├── ChatPopupPage │ │ │ └── index.tsx │ │ ├── FollowingPage │ │ │ ├── OfflineCard.tsx │ │ │ ├── OfflineGrid.tsx │ │ │ └── index.tsx │ │ ├── HomePage │ │ │ └── index.tsx │ │ ├── LivePage │ │ │ ├── LiveInfo.tsx │ │ │ ├── StreamerInfo.tsx │ │ │ ├── StreamingTimer.tsx │ │ │ └── index.tsx │ │ ├── LivesPage │ │ │ └── index.tsx │ │ ├── LoginPage │ │ │ ├── LoginCallback.tsx │ │ │ └── index.tsx │ │ ├── MyPage │ │ │ └── index.tsx │ │ └── StudioPage │ │ │ ├── Canvas │ │ │ ├── DrawCanvas.tsx │ │ │ ├── ImageTextCanvas.tsx │ │ │ ├── InteractionCanvas.tsx │ │ │ └── StreamCanvas.tsx │ │ │ ├── ControlButton.tsx │ │ │ ├── Modals │ │ │ ├── CamMicSetting.tsx │ │ │ ├── CanvasElementDeleteModal.tsx │ │ │ ├── Palette.tsx │ │ │ ├── StreamGuide.tsx │ │ │ └── TextSetting.tsx │ │ │ ├── StreamContainer.tsx │ │ │ ├── StreamInfo.tsx │ │ │ ├── StreamSettings.tsx │ │ │ ├── WebRTCStream.ts │ │ │ ├── WebStreamControls.tsx │ │ │ └── index.tsx │ ├── routes │ │ ├── ProtectedRoute.tsx │ │ └── index.tsx │ ├── store │ │ ├── useAuthStore.ts │ │ ├── useSortStore.ts │ │ ├── useStudioStore.ts │ │ └── useViewMode.ts │ ├── styles │ │ └── index.css │ ├── types │ │ ├── auth.ts │ │ ├── canvas.ts │ │ ├── category.ts │ │ ├── hlsQuality.ts │ │ ├── live.ts │ │ └── user.ts │ ├── utils │ │ ├── chatUtils.ts │ │ └── format.tsx │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── README.md /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: :Bug 3 | about: 버그 4 | title: "[Bug] 버그 제목" 5 | labels: "\U0001F41BFix\U0001F41B" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🐞 버그 설명 11 | 12 | ## 🛠️ 수정 사항 13 | 14 | 15 | ## 🖥️ 환경 정보 16 | 17 | ## 📸 참고 자료 (선택) 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/chore.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Chore 3 | about: 기타 작업 및 설정 4 | title: "[Chore] 작업 제목" 5 | labels: "\U0001F69AChore\U0001F69A" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📝 작업 설명 11 | 12 | 13 | ## ✅ 작업 사항 14 | 15 | 16 | - [ ] 의존성 업데이트 17 | - [ ] 빌드 설정 수정 18 | - [ ] 스크립트 추가 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Config 3 | about: 환경 설정 및 라이브러리 관리 4 | title: "[Config] 설정 제목" 5 | labels: "\U0001F527Config\U0001F527" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## ⚙️ 설정 설명 11 | 12 | 13 | ## ❓ 변경 이유 14 | 15 | 16 | ## ✅ 변경 사항 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Docs 3 | about: 문서 작성 및 수정 4 | title: "[Docs] 문서 제목" 5 | labels: "\U0001F4DDDocs\U0001F4DD" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📚 문서 설명 11 | 12 | 13 | ## ✅ 문서 사항 14 | 15 | 16 | - [ ] 설치 가이드 작성 17 | - [ ] API 문서 업데이트 18 | - [ ] 사용 예제 추가 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: 새로운 기능 개발 4 | title: "[Feature] 기능 제목" 5 | labels: "✨Feature✨" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🤔 기능 설명 11 | 12 | 13 | ## ✅ 구현 사항 14 | 15 | 16 | - [ ] 기능 요구사항 1 17 | - [ ] 기능 요구사항 2 18 | - [ ] 기능 요구사항 3 19 | 20 | ## 📸 참고 자료 (선택) 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactor 3 | about: 코드 리팩토링 4 | title: "[Refactor] 리팩토링 제목" 5 | labels: "♻️Refactor♻️" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🛠️ 리팩토링 설명 11 | 12 | 13 | ## ✅ 리팩토링 사항 14 | 15 | 16 | - [ ] 코드 구조 개선 17 | - [ ] 중복 코드 제거 18 | - [ ] 성능 최적화 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | about: 테스트 코드 작성 및 수정 4 | title: "[Test] 테스트 제목" 5 | labels: "✅Test✅" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🧪 테스트 설명 11 | 12 | 13 | ## ✅ 테스트 사항 14 | 15 | 16 | - [ ] 기능 1 테스트 17 | - [ ] 엣지 케이스 A 18 | - [ ] 엣지 케이스 B 19 | - [ ] 기능 2 테스트 20 | - [ ] 엣지 케이스 C 21 | - [ ] 엣지 케이스 D 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📝 PR 설명 2 | 3 | 4 | ## ✅ 주요 변경 사항 5 | 6 | 7 | - [ ] 기능 1 추가/수정 8 | - [ ] 버그 수정 9 | - [ ] 문서 업데이트 10 | 11 | ## 📸 스크린샷 (선택) 12 | 13 | 14 | ## 🔗 관련 이슈 15 | 16 | 17 | - closes #이슈번호 18 | - resolves #이슈번호 19 | 20 | ## 🛠️ 추가 작업 (선택) 21 | 22 | 23 | - [ ] 테스트 추가 24 | - [ ] 코드 최적화 25 | -------------------------------------------------------------------------------- /.github/workflows/chats-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Chats-Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | paths: 8 | - "Backend/apps/chats/src/**" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: 코드 체크아웃 16 | uses: actions/checkout@v4 17 | 18 | - name: SSH 에이전트 설정 19 | uses: webfactory/ssh-agent@v0.5.3 20 | with: 21 | ssh-private-key: ${{ secrets.API_SERVER_SSH }} 22 | 23 | - name: 서버 배포 24 | id: deploy 25 | continue-on-error: true 26 | run: | 27 | set -o pipefail 28 | ssh -o StrictHostKeyChecking=no ${{ secrets.API_USER_NAME }}@${{ secrets.API_SERVER_ADDRESS }} << 'EOF' 29 | chmod +x /lico-chats/Backend/server/chats/deploy.sh || exit 2 30 | /lico-chats/Backend/server/chats/deploy.sh develop 31 | EOF 32 | ssh_exit_code=$? 33 | echo "DEPLOY_RESULT=$ssh_exit_code" >> $GITHUB_ENV 34 | exit "$ssh_exit_code" 35 | 36 | - name: 결과 전송 37 | run: | 38 | curl -X POST -H "Content-Type: application/json" -d '{ 39 | "server": "chats", 40 | "result": "${{ env.DEPLOY_RESULT }}" 41 | }' ${{ secrets.WEBHOOK_URL }} 42 | -------------------------------------------------------------------------------- /.github/workflows/develop-api-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Develop API-Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | paths: 8 | - "Backend/apps/api/src/**" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: 코드 체크아웃 16 | uses: actions/checkout@v4 17 | 18 | - name: SSH 에이전트 설정 19 | uses: webfactory/ssh-agent@v0.5.3 20 | with: 21 | ssh-private-key: ${{ secrets.DEV_API_SERVER_SSH }} 22 | 23 | - name: 서버 배포 24 | id: deploy 25 | continue-on-error: true 26 | run: | 27 | set -o pipefail 28 | ssh -o StrictHostKeyChecking=no ${{ secrets.DEV_API_USER_NAME }}@${{ secrets.DEV_API_SERVER_ADDRESS }} << 'EOF' 29 | chmod +x /lico/Backend/server/api/deploy.sh || exit 2 30 | /lico/Backend/server/api/deploy.sh develop 31 | EOF 32 | ssh_exit_code=$? 33 | echo "DEPLOY_RESULT=$ssh_exit_code" >> $GITHUB_ENV 34 | exit "$ssh_exit_code" 35 | 36 | - name: 결과 전송 37 | run: | 38 | curl -X POST -H "Content-Type: application/json" -d '{ 39 | "server": "develop", 40 | "result": "${{ env.DEPLOY_RESULT }}" 41 | }' ${{ secrets.WEBHOOK_URL }} 42 | -------------------------------------------------------------------------------- /.github/workflows/main-api-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Main API-Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "Backend/apps/api/src/**" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: 코드 체크아웃 16 | uses: actions/checkout@v4 17 | 18 | - name: SSH 에이전트 설정 19 | uses: webfactory/ssh-agent@v0.5.3 20 | with: 21 | ssh-private-key: ${{ secrets.API_SERVER_SSH }} 22 | 23 | - name: 서버 배포 24 | id: deploy 25 | continue-on-error: true 26 | run: | 27 | set -o pipefail 28 | ssh -o StrictHostKeyChecking=no ${{ secrets.API_USER_NAME }}@${{ secrets.API_SERVER_ADDRESS }} << 'EOF' 29 | chmod +x /lico/Backend/server/api/deploy.sh || exit 2 30 | /lico/Backend/server/api/deploy.sh main 31 | EOF 32 | ssh_exit_code=$? 33 | echo "DEPLOY_RESULT=$ssh_exit_code" >> $GITHUB_ENV 34 | exit "$ssh_exit_code" 35 | 36 | - name: 결과 전송 37 | run: | 38 | curl -X POST -H "Content-Type: application/json" -d '{ 39 | "server": "main", 40 | "result": "${{ env.DEPLOY_RESULT }}" 41 | }' ${{ secrets.WEBHOOK_URL }} 42 | -------------------------------------------------------------------------------- /Backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'airbnb-base', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended' 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | ecmaVersion: 2020, 11 | sourceType: 'module' 12 | }, 13 | plugins: ['@typescript-eslint', 'prettier'], 14 | rules: { 15 | 'prettier/prettier': 'error', 16 | 'import/prefer-default-export': 'off', 17 | '@typescript-eslint/explicit-module-boundary-types': 'off', 18 | 'class-methods-use-this': 'off' 19 | }, 20 | env: { 21 | node: true, 22 | es2021: true 23 | }, 24 | ignorePatterns: ['dist'] 25 | }; 26 | -------------------------------------------------------------------------------- /Backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /Backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "printWidth": 120, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "auto", 10 | "bracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /Backend/apps/api/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /Backend/apps/api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Backend/apps/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { AuthModule } from './auth/auth.module'; 5 | import { UsersModule } from './users/users.module'; 6 | import { CategoriesModule } from './categories/categories.module'; 7 | import { ConfigModule, ConfigService } from '@nestjs/config'; 8 | import { TypeOrmModule } from '@nestjs/typeorm'; 9 | import { typeOrmConfig } from './config/typeorm.config'; 10 | import { LivesModule } from './lives/lives.module'; 11 | import { ChatsModule } from './chats/chats.module'; 12 | import { FollowModule } from './follow/follow.module'; 13 | import sqliteConfig from './config/sqlite.config'; 14 | import mysqlConfig from './config/mysql.config'; 15 | import { RedisModule } from '@nestjs-modules/ioredis'; 16 | import { redisConfig } from './config/redis.config'; 17 | import { WinstonModule } from 'nest-winston'; 18 | import { winstonConfig } from './config/logger.config'; 19 | import { APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; 20 | import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; 21 | import { HttpExceptionFilter } from './common/filters/http-exception.filter'; 22 | import { MonitoringInterceptor } from './common/interceptors/monitoring.interceptor'; 23 | import { CloudInsightService } from './common/services/cloud-insight.service'; 24 | import { RequestTimeMiddleware } from './common/middleware/request-time.middleware'; 25 | 26 | @Module({ 27 | imports: [ 28 | ConfigModule.forRoot({ 29 | isGlobal: true, 30 | load: [mysqlConfig, sqliteConfig], 31 | }), 32 | TypeOrmModule.forRootAsync({ 33 | imports: [ConfigModule], 34 | inject: [ConfigService], 35 | useFactory: async (configService: ConfigService) => typeOrmConfig(configService), 36 | }), 37 | RedisModule.forRootAsync({ 38 | imports: [ConfigModule], 39 | inject: [ConfigService], 40 | useFactory: async (configService: ConfigService) => redisConfig(configService), 41 | }), 42 | WinstonModule.forRoot(winstonConfig), 43 | AuthModule, 44 | UsersModule, 45 | CategoriesModule, 46 | LivesModule, 47 | ChatsModule, 48 | FollowModule, 49 | ], 50 | controllers: [AppController], 51 | providers: [ 52 | AppService, 53 | CloudInsightService, 54 | { 55 | provide: APP_INTERCEPTOR, 56 | useClass: MonitoringInterceptor, 57 | }, 58 | { 59 | provide: APP_INTERCEPTOR, 60 | useClass: LoggingInterceptor, 61 | }, 62 | { 63 | provide: APP_FILTER, 64 | useClass: HttpExceptionFilter, 65 | }, 66 | ], 67 | }) 68 | export class AppModule implements NestModule { 69 | configure(consumer: MiddlewareConsumer) { 70 | consumer.apply(RequestTimeMiddleware).forRoutes('*'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Backend/apps/api/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { AuthController } from './auth.controller'; 6 | import { AuthService } from './auth.service'; 7 | import { UsersModule } from '../users/users.module'; 8 | import { GoogleStrategy } from './strategies/google.strategy'; 9 | import { GithubStrategy } from './strategies/github.strategy'; 10 | import { NaverStrategy } from './strategies/naver.strategy'; 11 | import { ConfigService } from '@nestjs/config'; 12 | import { JwtStrategy } from './strategies/jwt.strategy'; 13 | import { LicoStrategy } from './strategies/lico.strategy'; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule, 18 | PassportModule, 19 | UsersModule, 20 | JwtModule.registerAsync({ 21 | imports: [ConfigModule], 22 | useFactory: async (configService: ConfigService) => ({ 23 | secret: configService.get('JWT_SECRET'), 24 | signOptions: { expiresIn: '1h' }, 25 | }), 26 | inject: [ConfigService], 27 | }), 28 | ], 29 | controllers: [AuthController], 30 | providers: [ 31 | AuthService, 32 | GoogleStrategy, 33 | GithubStrategy, 34 | NaverStrategy, 35 | JwtStrategy, 36 | LicoStrategy, 37 | ], 38 | exports: [AuthService], 39 | }) 40 | export class AuthModule {} 41 | -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') { 6 | private readonly logger = new Logger(JwtAuthGuard.name); 7 | 8 | handleRequest(err: any, user: any, info: any, context: ExecutionContext) { 9 | if (err || !user) { 10 | const request = context.switchToHttp().getRequest(); 11 | this.logger.warn(`Unauthorized access attempt: ${request.method} ${request.url}`); 12 | throw err || new UnauthorizedException('Unauthorized'); 13 | } 14 | return user; 15 | } 16 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/strategies/github.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy, Profile } from 'passport-github2'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { AuthService } from '../auth.service'; 6 | 7 | @Injectable() 8 | export class GithubStrategy extends PassportStrategy(Strategy, 'github') { 9 | constructor( 10 | private configService: ConfigService, 11 | private authService: AuthService, 12 | ) { 13 | super({ 14 | clientID: configService.get('GITHUB_CLIENT_ID'), 15 | clientSecret: configService.get('GITHUB_CLIENT_SECRET'), 16 | callbackURL: configService.get('GITHUB_REDIRECT_URI'), 17 | scope: ['read:user', 'user:email'], 18 | }); 19 | } 20 | 21 | async validate(accessToken: string, refreshToken: string, profile: Profile, done: Function) { 22 | try { 23 | const { id: oauthUid, username, displayName, photos, emails } = profile; 24 | 25 | // GitHub 사용자 데이터 구성 26 | const userData = { 27 | oauthUid, 28 | provider: 'github' as 'github', 29 | nickname: displayName || username || `User${oauthUid.substring(0, 8)}`, 30 | profileImage: photos?.[0]?.value || null, 31 | email: emails?.[0]?.value || null, 32 | }; 33 | 34 | done(null, userData); 35 | } catch (error) { 36 | done(error, false); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/strategies/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-google-oauth20'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { AuthService } from '../auth.service'; 6 | 7 | @Injectable() 8 | export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { 9 | constructor( 10 | private configService: ConfigService, 11 | private authService: AuthService, 12 | ) { 13 | super({ 14 | clientID: configService.get('GOOGLE_CLIENT_ID'), 15 | clientSecret: configService.get('GOOGLE_CLIENT_SECRET'), 16 | callbackURL: configService.get('GOOGLE_REDIRECT_URI'), 17 | scope: ['email', 'profile', 'openid'], 18 | }); 19 | } 20 | 21 | async validate( 22 | accessToken: string, 23 | refreshToken: string, 24 | profile: any, 25 | done: Function 26 | ): Promise { 27 | try { 28 | const { id: oauthUid, displayName, emails, photos } = profile; 29 | 30 | // Google 사용자 데이터 구성 31 | const userData = { 32 | oauthUid, 33 | provider: 'google' as 'google', 34 | nickname: displayName || emails?.[0]?.value?.split('@')[0] || `User${oauthUid.substring(0, 8)}`, 35 | profileImage: photos?.[0]?.value || null, 36 | email: emails?.[0]?.value || null, 37 | }; 38 | 39 | done(null, userData); 40 | } catch (err) { 41 | console.error('Google Strategy Error:', err); 42 | done(err, false); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy, ExtractJwt } from 'passport-jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { UsersService } from '../../users/users.service'; 6 | import { UserEntity } from '../../users/entity/user.entity'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | private configService: ConfigService, 12 | private usersService: UsersService, 13 | ) { 14 | super({ 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ignoreExpiration: false, 17 | secretOrKey: configService.get('JWT_SECRET'), 18 | }); 19 | } 20 | 21 | async validate(payload: any): Promise { 22 | const { id, provider } = payload.sub; 23 | 24 | const user = await this.usersService.findById(id); 25 | 26 | if (user && user.oauthPlatform === provider) { 27 | return user; 28 | } 29 | return null; 30 | } 31 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/strategies/lico.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-custom'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import * as crypto from 'crypto'; 5 | import { adjectives, nouns } from './nickname-data' 6 | 7 | @Injectable() 8 | export class LicoStrategy extends PassportStrategy(Strategy, 'lico') { 9 | async validate(req: Request, done: Function) { 10 | try { 11 | const oauthUid = crypto.randomBytes(16).toString('hex'); 12 | 13 | // 랜덤 닉네임 생성 14 | const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)]; 15 | const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; 16 | const nickname = `${randomAdjective} ${randomNoun}`; 17 | 18 | const profileNumber = Math.floor(Math.random() * 31) 19 | 20 | const userData = { 21 | oauthUid, 22 | provider: 'lico' as 'lico', 23 | nickname, 24 | profileImage: `https://kr.object.ncloudstorage.com/lico.image/default-profile-image/lico_${profileNumber}.png`, 25 | email: null, 26 | }; 27 | 28 | done(null, userData); 29 | } catch (error) { 30 | done(error, false); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/strategies/naver.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-naver'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { AuthService } from '../auth.service'; 6 | 7 | @Injectable() 8 | export class NaverStrategy extends PassportStrategy(Strategy, 'naver') { 9 | constructor( 10 | private configService: ConfigService, 11 | private authService: AuthService, 12 | ) { 13 | super({ 14 | clientID: configService.get('NAVER_CLIENT_ID'), 15 | clientSecret: configService.get('NAVER_CLIENT_SECRET'), 16 | callbackURL: configService.get('NAVER_REDIRECT_URI'), 17 | svcType: 0, 18 | }); 19 | } 20 | 21 | async validate(accessToken: string, refreshToken: string, profile: any, done: Function): Promise { 22 | try { 23 | const { id: oauthUid, emails, displayName, _json } = profile; 24 | 25 | // Naver 사용자 데이터 구성 26 | const userData = { 27 | oauthUid, 28 | provider: 'naver' as 'naver', 29 | nickname: displayName || _json.nickname || `User${oauthUid.substring(0, 8)}`, 30 | profileImage: _json.profile_image || null, 31 | email: emails?.[0]?.value || null, 32 | }; 33 | 34 | done(null, userData); 35 | } catch (err) { 36 | console.error('Naver Strategy Error:', err); 37 | done(err, false); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Backend/apps/api/src/auth/strategies/nickname-data.ts: -------------------------------------------------------------------------------- 1 | export const adjectives = [ 2 | '용감한', '배고픈', '행복한', '슬픈', '귀여운', 3 | '똑똑한', '멋진', '예쁜', '강한', '부드러운', 4 | '신나는', '즐거운', '차가운', '뜨거운', '재미있는', 5 | '친절한', '성실한', '조용한', '시끄러운', '빠른', 6 | '느린', '젊은', '늙은', '건강한', '아픈', 7 | '화난', '놀란', '긴장한', '자신감있는', '호기심많은', 8 | '사랑스러운', '섹시한', '차분한', '활발한', '용의주도한', 9 | '검소한', '풍요로운', '예의바른', '거친', '부지런한', 10 | '게으른', '독특한', '평범한', '엄격한', '유연한', 11 | '진지한', '명랑한', '냉정한', '따뜻한', '낙천적인', 12 | ]; 13 | 14 | export const nouns = [ 15 | '사자', '호랑이', '토끼', '코끼리', '독수리', 16 | '고래', '감자', '토마토', '사과', '바나나', 17 | '강아지', '고양이', '여우', '늑대', '곰', 18 | '펭귄', '기린', '원숭이', '돼지', '닭', 19 | '양', '염소', '소', '말', '다람쥐', 20 | '독사', '표범', '하마', '코뿔소', '캥거루', 21 | '수달', '돌고래', '새우', '게', '불가사리', 22 | '달팽이', '나비', '벌', '개미', '거미', 23 | '두꺼비', '개구리', '뱀', '도마뱀', '악어', 24 | '코알라', '판다', '사슴', '너구리', '오소리', 25 | ]; 26 | -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/categories.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common'; 2 | import { CategoriesService } from './categories.service'; 3 | import { CategoriesDto } from './dto/categories.dto'; 4 | import { CategoryDto } from './dto/category.dto'; 5 | import { LivesService } from './../lives/lives.service'; 6 | 7 | @Controller('categories') 8 | export class CategoriesController { 9 | constructor( 10 | private readonly categoriesService: CategoriesService, 11 | private readonly livesService: LivesService, 12 | ) {} 13 | 14 | @Get() 15 | async getCategories(): Promise { 16 | return await this.categoriesService.readCategories(); 17 | } 18 | 19 | @Get('/:categoriesId') 20 | async getCategory(@Param('categoriesId', ParseIntPipe) categoriesId: number): Promise { 21 | return await this.categoriesService.readCategory(categoriesId); 22 | } 23 | 24 | @Get('/:categoriesId/lives') 25 | async getOnAirLivesByCategory( 26 | @Param('categoriesId', ParseIntPipe) categoriesId: number, 27 | @Query('sort') sort: 'latest' | 'viewers' | 'recommendation' = 'latest', 28 | @Query('limit') limit: number = 20, 29 | @Query('offset') offset: number = 0, 30 | ) { 31 | return await this.livesService.readLives({ sort, limit, offset, categoriesId, onAir: true }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/categories.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CategoriesController } from './categories.controller'; 3 | import { CategoriesService } from './categories.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { CategoryEntity } from './entity/category.entity'; 6 | import { LivesModule } from '../lives/lives.module'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([CategoryEntity]), LivesModule], 10 | controllers: [CategoriesController], 11 | providers: [CategoriesService], 12 | }) 13 | export class CategoriesModule {} 14 | -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/categories.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { CategoryEntity } from './entity/category.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { CategoriesDto } from './dto/categories.dto'; 6 | import { CategoryDto } from './dto/category.dto'; 7 | import { ErrorMessage } from './error/error.message.enum'; 8 | import { LivesService } from '../lives/lives.service'; 9 | 10 | @Injectable() 11 | export class CategoriesService { 12 | constructor( 13 | @InjectRepository(CategoryEntity) private categoriesRepository: Repository, 14 | private livesService: LivesService, 15 | ) {} 16 | 17 | // 모든 카테고리 18 | async readCategories(): Promise { 19 | const categories = await this.categoriesRepository.find(); 20 | const stats = await this.livesService.getCategoryStats(); 21 | 22 | // 카테고리 ID를 키로 하는 맵 생성 23 | const statsMap = new Map(); 24 | stats.forEach((stat) => { 25 | statsMap.set(stat.categoriesId, { 26 | liveCount: stat.liveCount, 27 | viewerCount: stat.viewerCount, 28 | }); 29 | }); 30 | 31 | // 각 카테고리에 통계 정보 매핑 32 | return categories.map((category) => { 33 | const stat = statsMap.get(category.id) || { liveCount: 0, viewerCount: 0 }; 34 | return { 35 | id: category.id, 36 | name: category.name, 37 | image: category.image, 38 | liveCount: stat.liveCount, 39 | viewerCount: stat.viewerCount, 40 | }; 41 | }); 42 | } 43 | 44 | 45 | // 특정 카테고리 정보와 통계 캐싱 46 | async readCategory(id: number): Promise { 47 | const category = await this.categoriesRepository.findOne({ where: { id } }); 48 | 49 | if (!category) { 50 | throw new NotFoundException(ErrorMessage.CATEGORY_NOT_FOUND); 51 | } 52 | 53 | // 라이브 수와 시청자 수 가져오기 54 | const { liveCount, viewerCount } = await this.livesService.getCategoryStatsById(id); 55 | 56 | return { 57 | name: category.name, 58 | image: category.image, 59 | liveCount, 60 | viewerCount, 61 | }; 62 | } 63 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/dto/categories.dto.ts: -------------------------------------------------------------------------------- 1 | export class CategoriesDto { 2 | readonly id: number; 3 | readonly name: string; 4 | readonly image: string; 5 | readonly liveCount: number; 6 | readonly viewerCount: number; 7 | } 8 | -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/dto/category.dto.ts: -------------------------------------------------------------------------------- 1 | export class CategoryDto { 2 | readonly name: string; 3 | readonly image: string; 4 | readonly liveCount: number; 5 | readonly viewerCount: number; 6 | } 7 | -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/entity/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { LiveEntity } from '../../lives/entity/live.entity'; 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | DeleteDateColumn, 9 | OneToMany, 10 | } from 'typeorm'; 11 | 12 | @Entity('categories') 13 | export class CategoryEntity { 14 | @PrimaryGeneratedColumn({ name: 'categories_id' }) 15 | id: number; 16 | 17 | @Column({ type: 'varchar', length: 50, name: 'categories_name' }) 18 | name: string; 19 | 20 | @Column({ type: 'text', name: 'categories_image', nullable: true }) 21 | image: string | null; 22 | 23 | @CreateDateColumn({ name: 'created_at' }) 24 | createdAt: Date; 25 | 26 | @UpdateDateColumn({ name: 'updated_at', nullable: true }) 27 | updatedAt: Date; 28 | 29 | @DeleteDateColumn({ name: 'deleted_at', nullable: true }) 30 | deletedAt: Date | null; 31 | 32 | @OneToMany(() => LiveEntity, live => live.category) 33 | lives: LiveEntity[]; 34 | } 35 | -------------------------------------------------------------------------------- /Backend/apps/api/src/categories/error/error.message.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorMessage { 2 | CATEGORY_NOT_FOUND = '존재하지 않는 카테고리입니다.', 3 | } 4 | -------------------------------------------------------------------------------- /Backend/apps/api/src/chats/chats.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatsController } from './chats.controller'; 3 | 4 | describe('ChatsController', () => { 5 | let controller: ChatsController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ChatsController], 10 | }).compile(); 11 | 12 | controller = module.get(ChatsController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /Backend/apps/api/src/chats/chats.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Req, UseGuards, ValidationPipe } from '@nestjs/common'; 2 | import { SendChatDto } from './dto/send.chat.dto'; 3 | import { ChatsService } from './chats.service'; 4 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 5 | import { UserEntity } from '../users/entity/user.entity'; 6 | 7 | @Controller('chats') 8 | export class ChatsController { 9 | constructor(private readonly chatsService: ChatsService) {} 10 | 11 | @Post() 12 | @UseGuards(JwtAuthGuard) 13 | async sendChat(@Body(ValidationPipe) sendChatDto: SendChatDto, @Req() req: Request & { user: UserEntity }) { 14 | this.chatsService.ingestChat({ ...sendChatDto, userId: req.user.id, nickname: req.user.nickname }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Backend/apps/api/src/chats/chats.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { ChatsService } from './chats.service'; 4 | import { RedisModule } from '@nestjs-modules/ioredis'; 5 | import { JwtModule } from '@nestjs/jwt'; 6 | import { ChatsController } from './chats.controller'; 7 | import { UsersModule } from '../users/users.module'; 8 | import { HttpModule } from '@nestjs/axios'; 9 | 10 | @Module({ 11 | imports: [ 12 | RedisModule, 13 | UsersModule, 14 | JwtModule.registerAsync({ 15 | imports: [ConfigModule], 16 | useFactory: async (configService: ConfigService) => ({ 17 | secret: configService.get('JWT_SECRET'), 18 | signOptions: { expiresIn: '1h' }, 19 | }), 20 | inject: [ConfigService], 21 | }), 22 | HttpModule, 23 | ], 24 | providers: [ChatsService], 25 | exports: [ChatsService], 26 | controllers: [ChatsController], 27 | }) 28 | export class ChatsModule {} 29 | -------------------------------------------------------------------------------- /Backend/apps/api/src/chats/chats.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatsService } from './chats.service'; 3 | 4 | describe('ChatsService', () => { 5 | let service: ChatsService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ChatsService], 10 | }).compile(); 11 | 12 | service = module.get(ChatsService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /Backend/apps/api/src/chats/dto/send.chat.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsUUID, IsString } from 'class-validator'; 2 | import { UUID } from 'crypto'; 3 | export class SendChatDto { 4 | @IsUUID() 5 | @IsNotEmpty() 6 | channelId: UUID; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /Backend/apps/api/src/common/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | Inject, 8 | } from '@nestjs/common'; 9 | import { Request, Response } from 'express'; 10 | import { Logger } from 'winston'; 11 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 12 | import { CloudInsightService } from '../services/cloud-insight.service'; 13 | 14 | @Catch() 15 | export class HttpExceptionFilter implements ExceptionFilter { 16 | constructor( 17 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 18 | private readonly cloudInsightService: CloudInsightService, // 추가 19 | ) {} 20 | 21 | async catch(exception: unknown, host: ArgumentsHost) { 22 | const ctx = host.switchToHttp(); 23 | 24 | const request = ctx.getRequest(); 25 | const response = ctx.getResponse(); 26 | 27 | const status = 28 | exception instanceof HttpException 29 | ? exception.getStatus() 30 | : HttpStatus.INTERNAL_SERVER_ERROR; 31 | 32 | const message = 33 | exception instanceof HttpException 34 | ? exception.getResponse() 35 | : exception; 36 | 37 | this.logger.error( 38 | `HTTP Status: ${status} Error Message: ${JSON.stringify(message)}`, 39 | ); 40 | 41 | // 요청 시작 시간을 가져오기 위해 미들웨어에서 설정한 startTime 사용 42 | const responseTime = Date.now() - (request['startTime'] || Date.now()); 43 | 44 | // Cloud Insight로 전송할 데이터 구성 45 | const data = { 46 | endpoint: request.originalUrl, 47 | method: request.method, 48 | status_code: status, 49 | response_time: responseTime, 50 | request_count: 1, 51 | error_count: 1, 52 | }; 53 | 54 | // 비동기로 데이터 전송 55 | await this.cloudInsightService.sendData(data).catch((error) => { 56 | // 에러 핸들링은 서비스 내에서 수행 57 | }); 58 | 59 | response.status(status).json({ 60 | statusCode: status, 61 | timestamp: new Date().toISOString(), 62 | path: request.url, 63 | }); 64 | } 65 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/common/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | Inject, 7 | } from '@nestjs/common'; 8 | import { Observable } from 'rxjs'; 9 | import { tap } from 'rxjs/operators'; 10 | import { Request, Response } from 'express'; 11 | import { Logger } from 'winston'; 12 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 13 | 14 | @Injectable() 15 | export class LoggingInterceptor implements NestInterceptor { 16 | constructor( 17 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 18 | ) {} 19 | 20 | intercept(context: ExecutionContext, next: CallHandler): Observable { 21 | const now = Date.now(); 22 | 23 | const ctx = context.switchToHttp(); 24 | const request = ctx.getRequest(); 25 | const response = ctx.getResponse(); 26 | 27 | const { method, originalUrl } = request; 28 | const headers = { ...request.headers }; 29 | delete headers['authorization']; // Authorization 헤더 제외 - 보안상 30 | 31 | return next.handle().pipe( 32 | tap(() => { 33 | const statusCode = response.statusCode; 34 | const contentLength = response.get('content-length') || '0'; 35 | 36 | this.logger.info( 37 | `${method} ${originalUrl} ${statusCode} ${contentLength} - ${ 38 | Date.now() - now 39 | }ms`, 40 | { headers }, // 제외한 헤더를 로그에 포함 41 | ); 42 | }), 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Backend/apps/api/src/common/interceptors/monitoring.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { tap } from 'rxjs/operators'; 9 | import { CloudInsightService } from '../services/cloud-insight.service'; 10 | import { Request, Response } from 'express'; 11 | 12 | @Injectable() 13 | export class MonitoringInterceptor implements NestInterceptor { 14 | constructor(private readonly cloudInsightService: CloudInsightService) {} 15 | 16 | intercept(context: ExecutionContext, next: CallHandler): Observable { 17 | const ctx = context.switchToHttp(); 18 | const request = ctx.getRequest(); 19 | 20 | const { method, originalUrl } = request; 21 | 22 | return next.handle().pipe( 23 | tap(() => { 24 | const response = ctx.getResponse(); 25 | 26 | // 정상 응답 처리 27 | if (response.statusCode < 400) { 28 | const responseTime = Date.now() - request['startTime']; 29 | 30 | const data = { 31 | endpoint: originalUrl, 32 | method: method, 33 | status_code: response.statusCode, 34 | response_time: responseTime, 35 | request_count: 1, 36 | error_count: 0, 37 | }; 38 | 39 | // 비동기로 데이터 전송 40 | this.cloudInsightService.sendData(data).catch((error) => { 41 | // 에러 핸들링은 서비스 내에서 수행 42 | }); 43 | } 44 | }), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Backend/apps/api/src/common/middleware/request-time.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | 4 | @Injectable() 5 | export class RequestTimeMiddleware implements NestMiddleware { 6 | use(req: Request, res: Response, next: NextFunction) { 7 | req['startTime'] = Date.now(); 8 | next(); 9 | } 10 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/common/services/cloud-insight.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import axios from 'axios'; 3 | import * as crypto from 'crypto'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 6 | import { Logger } from 'winston'; 7 | 8 | @Injectable() 9 | export class CloudInsightService { 10 | private readonly useMetrics: boolean; 11 | private readonly cwKey: string; 12 | private readonly accessKey: string; 13 | private readonly secretKey: string; 14 | private readonly instanceId: string; 15 | 16 | constructor( 17 | private readonly configService: ConfigService, 18 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 19 | ) { 20 | this.useMetrics = this.configService.get('METRICS_ENABLED') === 'true'; 21 | this.cwKey = this.configService.get('NCLOUD_CW_KEY'); 22 | this.accessKey = this.configService.get('NCLOUD_ACCESS_KEY'); 23 | this.secretKey = this.configService.get('NCLOUD_SECRET_KEY'); 24 | this.instanceId = this.configService.get('NCLOUD_INSIGHT_INSTANCEID'); 25 | } 26 | 27 | async sendData(data: any): Promise { 28 | if (!this.useMetrics) { 29 | // 로컬 환경에서는 데이터 전송하지 않음 30 | return; 31 | } 32 | 33 | const timestamp = Date.now().toString(); 34 | const method = 'POST'; 35 | const url = '/cw_collector/real/data'; 36 | 37 | const message = `${method} ${url}\n${timestamp}\n${this.accessKey}`; 38 | const signature = crypto 39 | .createHmac('sha256', this.secretKey) 40 | .update(message) 41 | .digest('base64'); 42 | 43 | const headers = { 44 | 'Content-Type': 'application/json', 45 | 'x-ncp-apigw-signature-v2': signature, 46 | 'x-ncp-apigw-timestamp': timestamp, 47 | 'x-ncp-iam-access-key': this.accessKey, 48 | }; 49 | 50 | const body = { 51 | cw_key: this.cwKey, 52 | data: {instanceId : this.instanceId, ...data}, 53 | }; 54 | 55 | try { 56 | await axios.post(`https://cw.apigw.ntruss.com${url}`, body, { headers }); 57 | } catch (error) { 58 | this.logger.error('Cloud Insight 데이터 전송 실패:', error); 59 | // 필요 시 재시도 로직 또는 에러 핸들링 추가 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/config/logger.config.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | import 'winston-daily-rotate-file'; 3 | 4 | const logLevel = 'warn'; 5 | const logDir = './logs'; 6 | 7 | export const winstonConfig: winston.LoggerOptions = { 8 | level: logLevel, 9 | format: winston.format.combine( 10 | winston.format.timestamp(), 11 | winston.format.printf(({ timestamp, level, message }) => { 12 | return `${timestamp} [${level.toUpperCase()}]: ${message}`; 13 | }), 14 | ), 15 | transports: [ 16 | new winston.transports.Console(), 17 | new winston.transports.DailyRotateFile({ 18 | dirname: logDir, 19 | filename: 'application-%DATE%.log', 20 | datePattern: 'YYYY-MM-DD', 21 | zippedArchive: true, 22 | maxSize: '20m', 23 | maxFiles: '14d', 24 | }), 25 | ], 26 | }; -------------------------------------------------------------------------------- /Backend/apps/api/src/config/mysql.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('mysql', () => ({ 4 | host: process.env.DB_HOST, 5 | port: process.env.DB_PORT, 6 | username: process.env.DB_USER, 7 | password: process.env.DB_PASSWORD, 8 | database: process.env.DB_NAME, 9 | })); 10 | -------------------------------------------------------------------------------- /Backend/apps/api/src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { RedisModuleOptions } from '@nestjs-modules/ioredis'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | export const redisConfig = (configService: ConfigService): RedisModuleOptions => ({ 5 | type: 'single', 6 | url: configService.get('REDIS_URL'), 7 | options: { 8 | password: configService.get('REDIS_PASSWORD'), 9 | retryStrategy: times => configService.get('REDIS_RETRY_MILLISECONDS'), 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /Backend/apps/api/src/config/sqlite.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('sqlite', () => ({ 4 | database: ':memory:', 5 | })); 6 | -------------------------------------------------------------------------------- /Backend/apps/api/src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { UserEntity } from '../users/entity/user.entity'; 4 | import { CategoryEntity } from '../categories/entity/category.entity'; 5 | import { LiveEntity } from '../lives/entity/live.entity'; 6 | 7 | export const typeOrmConfig = (configService: ConfigService): TypeOrmModuleOptions => { 8 | const dbType = configService.get('DB_TYPE'); 9 | const dbConfig = configService.get(dbType); 10 | 11 | return { 12 | type: dbType, 13 | ...dbConfig, 14 | entities: [UserEntity, CategoryEntity, LiveEntity], 15 | synchronize: configService.get('DB_SYNCHRONIZE'), 16 | logging: configService.get('DB_LOGGING'), 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /Backend/apps/api/src/follow/follow.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Delete, 6 | UseGuards, 7 | Req, 8 | Param, 9 | HttpCode, 10 | ParseIntPipe, 11 | } from '@nestjs/common'; 12 | import { FollowService } from './follow.service'; 13 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 14 | import { Request } from 'express'; 15 | import { UserEntity } from '../users/entity/user.entity'; 16 | 17 | @Controller('follow') 18 | export class FollowController { 19 | constructor(private readonly followService: FollowService) {} 20 | 21 | // 팔로우한 스트리머 목록 조회 22 | @Get() 23 | @UseGuards(JwtAuthGuard) 24 | async getFollowing(@Req() req: Request & { user: UserEntity }) { 25 | const userId = req.user.id; 26 | return this.followService.getFollowingStreamers(userId); 27 | } 28 | 29 | // 스트리머 팔로우 30 | @Post(':streamerId') 31 | @UseGuards(JwtAuthGuard) 32 | @HttpCode(201) 33 | async followStreamer( 34 | @Req() req: Request & { user: UserEntity }, 35 | @Param('streamerId', ParseIntPipe) streamerId: number, 36 | ) { 37 | const userId = req.user.id; 38 | await this.followService.followStreamer(userId, streamerId); 39 | return { message: '팔로우 성공' }; 40 | } 41 | 42 | // 스트리머 언팔로우 43 | @Delete(':streamerId') 44 | @UseGuards(JwtAuthGuard) 45 | async unfollowStreamer( 46 | @Req() req: Request & { user: UserEntity }, 47 | @Param('streamerId', ParseIntPipe) streamerId: number, 48 | ) { 49 | const userId = req.user.id; 50 | await this.followService.unfollowStreamer(userId, streamerId); 51 | return { message: '언팔로우 성공' }; 52 | } 53 | 54 | @Get('count/:streamerId') 55 | async getFollowerCount( 56 | @Param('streamerId', ParseIntPipe) streamerId: number, 57 | ) { 58 | const followerCount = await this.followService.getFollowerCount(streamerId); 59 | return { streamerId, followerCount }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Backend/apps/api/src/follow/follow.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FollowService } from './follow.service'; 3 | import { FollowController } from './follow.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { UserEntity } from '../users/entity/user.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([UserEntity])], 9 | providers: [FollowService], 10 | controllers: [FollowController], 11 | }) 12 | export class FollowModule {} 13 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/dto/live.dto.ts: -------------------------------------------------------------------------------- 1 | export class LiveDto { 2 | readonly livesName: string | null; 3 | readonly livesDescription: string | null; 4 | readonly startedAt: Date; 5 | readonly usersNickname: string; 6 | readonly usersProfileImage: string; 7 | readonly categoriesId: number | null; 8 | readonly categoriesName: string | null; 9 | readonly onAir: boolean | null; 10 | readonly viewers: number; 11 | readonly streamerId: number; 12 | } 13 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/dto/lives.dto.ts: -------------------------------------------------------------------------------- 1 | export class LivesDto { 2 | readonly channelId: string; 3 | readonly livesName: string | null; 4 | readonly usersNickname: string; 5 | readonly usersProfileImage: string; 6 | readonly categoriesId: number | null; 7 | readonly categoriesName: string | null; 8 | readonly onAir: boolean | null; 9 | readonly viewers: number; 10 | readonly streamerId: number; 11 | } 12 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/dto/status.dto.ts: -------------------------------------------------------------------------------- 1 | export class StatusDto { 2 | readonly livesName: string | null; 3 | readonly livesDescription: string | null; 4 | readonly categoriesId: number | null; 5 | readonly categoriesName: string | null; 6 | readonly onAir: boolean | null; 7 | readonly viewers: number; 8 | } 9 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/dto/update.live.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString, MaxLength, Min } from 'class-validator'; 2 | 3 | export class UpdateLiveDto { 4 | @IsString() 5 | @MaxLength(50) 6 | readonly name: string; 7 | 8 | @IsString() 9 | @MaxLength(50) 10 | readonly description: string; 11 | 12 | @IsNumber() 13 | @Min(1) 14 | readonly categoriesId: number; 15 | } 16 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/error/error.message.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorMessage { 2 | LIVE_NOT_FOUND = '존재하지 않는 채널입니다.', 3 | LIVE_ALREADY_STARTED = '이미 방송이 진행중입니다.', 4 | } 5 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/lives.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { LivesController } from './lives.controller'; 3 | import { LivesService } from './lives.service'; 4 | import { LivesDto } from './dto/lives.dto'; 5 | 6 | describe('LivesController', () => { 7 | let controller: LivesController; 8 | let service: LivesService; 9 | 10 | const mockLives = [ 11 | { 12 | channelId: 'abc123', 13 | livesName: 'Awesome Live Stream', 14 | usersNickname: 'JohnDoe', 15 | usersProfileImage: 'https://example.com/profile.jpg', 16 | categoriesId: 1, 17 | categoriesName: 'Gaming', 18 | onAir: true, 19 | }, 20 | { 21 | channelId: 'def456', 22 | livesName: 'Music Jam Session', 23 | usersNickname: 'JaneDoe', 24 | usersProfileImage: 'https://example.com/jane-profile.jpg', 25 | categoriesId: 2, 26 | categoriesName: 'Music', 27 | onAir: true, 28 | }, 29 | ]; 30 | 31 | const mockLivesService = { 32 | readLives: jest.fn(), 33 | readLive: jest.fn(), 34 | }; 35 | 36 | beforeEach(async () => { 37 | const module: TestingModule = await Test.createTestingModule({ 38 | controllers: [LivesController], 39 | providers: [ 40 | { 41 | provide: LivesService, 42 | useValue: mockLivesService, 43 | }, 44 | ], 45 | }).compile(); 46 | 47 | controller = module.get(LivesController); 48 | service = module.get(LivesService); 49 | }); 50 | 51 | afterEach(() => { 52 | jest.clearAllMocks(); 53 | }); 54 | 55 | describe('getOnAirLives', () => { 56 | it('라이브 컨트롤러가 방송중인 라이브 목록을 반환합니다.', async () => { 57 | // Given 58 | mockLivesService.readLives.mockResolvedValue(mockLives); 59 | 60 | // When 61 | const lives = await controller.getOnAirLives(); 62 | 63 | // Then 64 | expect(service.readLives).toHaveBeenCalledTimes(1); 65 | expect(service.readLives).toHaveBeenCalledWith({ onAir: true }); 66 | expect(lives).toEqual(mockLives); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/lives.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Param, 8 | Patch, 9 | Post, 10 | Req, 11 | UseGuards, 12 | ValidationPipe, 13 | Query, 14 | } from '@nestjs/common'; 15 | import { LivesService } from './lives.service'; 16 | import { LivesDto } from './dto/lives.dto'; 17 | import { LiveDto } from './dto/live.dto'; 18 | import { UpdateLiveDto } from './dto/update.live.dto'; 19 | import { UUID } from 'crypto'; 20 | import { StatusDto } from './dto/status.dto'; 21 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 22 | import { UserEntity } from '../users/entity/user.entity'; 23 | 24 | @Controller('lives') 25 | export class LivesController { 26 | constructor(private readonly livesService: LivesService) {} 27 | 28 | @Get() // 새로운 live 요청 29 | async getOnAirLives( 30 | @Query('sort') sort: 'latest' | 'viewers' | 'recommendation' = 'latest', 31 | @Query('limit') limit = 20, 32 | @Query('offset') offset = 0, 33 | ): Promise { 34 | return await this.livesService.readLives({ sort, limit, offset }); 35 | } 36 | 37 | @Get('/streaming-key') 38 | @UseGuards(JwtAuthGuard) 39 | async getStreamingKey(@Req() req: Request & { user: UserEntity }) { 40 | return req.user.live.streamingKey; 41 | } 42 | 43 | @Get('/:channelId') 44 | async getLive(@Param('channelId') channelId: UUID): Promise { 45 | return await this.livesService.readLive(channelId); 46 | } 47 | 48 | @Patch('/:channelId') 49 | @UseGuards(JwtAuthGuard) 50 | @HttpCode(204) 51 | async setLive( 52 | @Param('channelId') channelId: UUID, 53 | @Body(ValidationPipe) updateLiveDto: UpdateLiveDto, 54 | @Req() req: Request & { user: UserEntity }, 55 | ) { 56 | await this.livesService.updateLive({ channelId, updateLiveDto, userId: req.user.id }); 57 | } 58 | 59 | @Get('/channel-id/:streamingKey') 60 | async getChannelId(@Param('streamingKey') streamingKey: UUID) { 61 | return await this.livesService.readChannelId(streamingKey); 62 | } 63 | 64 | @Post('/onair/:streamingKey') 65 | @HttpCode(200) 66 | async startLive(@Param('streamingKey') streamingKey: UUID) { 67 | await this.livesService.startLive(streamingKey); 68 | return { code: 0 }; 69 | } 70 | 71 | @Delete('/onair/:streamingKey') 72 | @HttpCode(202) 73 | async endLive(@Param('streamingKey') streamingKey: UUID) { 74 | this.livesService.endLive(streamingKey); 75 | } 76 | 77 | @Get('/onair/:streamingKey') 78 | async getOnAir(@Param('streamingKey') streamingKey: UUID) { 79 | return await this.livesService.readOnAir(streamingKey); 80 | } 81 | 82 | @Get('/status/:channelId') 83 | async getStatus(@Param('channelId') channelId: UUID): Promise { 84 | return await this.livesService.readStatus(channelId); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Backend/apps/api/src/lives/lives.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LivesService } from './lives.service'; 3 | import { LivesController } from './lives.controller'; 4 | import { LiveEntity } from './entity/live.entity'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { RedisModule } from '@nestjs-modules/ioredis'; 7 | import { ChatsModule } from '../chats/chats.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([LiveEntity]), ChatsModule, RedisModule], 11 | providers: [LivesService], 12 | controllers: [LivesController], 13 | exports: [LivesService], 14 | }) 15 | export class LivesModule {} 16 | -------------------------------------------------------------------------------- /Backend/apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import * as cookieParser from 'cookie-parser'; 5 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | 10 | // ConfigService 가져오기 11 | const configService = app.get(ConfigService); 12 | 13 | // Winston 로거를 애플리케이션의 로거로 설정 14 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); 15 | 16 | // PORT 설정 17 | const port = configService.get('PORT') || 3000; 18 | 19 | app.use(cookieParser()); // cookie-parser 미들웨어 사용 20 | app.enableCors({ 21 | // CORS 설정 22 | origin: configService.get('CORS')?.split(',') || '*', 23 | credentials: true, 24 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], 25 | allowedHeaders: ['Content-Type', 'Authorization'], 26 | exposedHeaders: ['Authorization'], 27 | }); 28 | 29 | await app.listen(port); 30 | console.log(`lico is running on: ${await app.getUrl()}`); 31 | } 32 | bootstrap(); 33 | -------------------------------------------------------------------------------- /Backend/apps/api/src/users/dto/create.user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsEnum, IsOptional, IsUrl } from 'class-validator'; 2 | export class CreateUserDto { 3 | @IsString() 4 | oauthUid: string; 5 | 6 | @IsEnum(['naver', 'github', 'google', 'lico']) 7 | oauthPlatform: 'naver' | 'github' | 'google'| 'lico'; 8 | 9 | @IsString() 10 | @IsOptional() 11 | nickname?: string; 12 | 13 | @IsUrl() 14 | @IsOptional() 15 | profileImage?: string | null; 16 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/users/dto/update.user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString, IsUrl } from 'class-validator'; 2 | 3 | export class UpdateUserDto { 4 | @IsOptional() 5 | @IsString() 6 | nickname?: string; 7 | 8 | @IsOptional() 9 | @IsUrl() 10 | profileImage?: string | null; 11 | } 12 | -------------------------------------------------------------------------------- /Backend/apps/api/src/users/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { LiveEntity } from '../../lives/entity/live.entity'; 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | Column, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | DeleteDateColumn, 9 | OneToOne, 10 | JoinColumn, 11 | ManyToMany, 12 | JoinTable, 13 | } from 'typeorm'; 14 | 15 | @Entity('users') 16 | export class UserEntity { 17 | @PrimaryGeneratedColumn({ name: 'users_id' }) 18 | id: number; 19 | 20 | @Column({ name: 'oauth_uid', type: 'varchar', length: 50 }) 21 | oauthUid: string; 22 | 23 | @Column({ 24 | name: 'oauth_platform', 25 | type: 'enum', 26 | enum: ['naver', 'github', 'google', 'lico'], 27 | nullable: false, 28 | }) 29 | oauthPlatform: 'naver' | 'github' | 'google'| 'lico'; 30 | 31 | @Column({ name: 'users_nickname', type: 'varchar', length: 50 }) 32 | nickname: string; 33 | 34 | @Column({ name: 'users_profile_image', type: 'text', nullable: true }) 35 | profileImage: string | null; 36 | 37 | @CreateDateColumn({ name: 'created_at' }) 38 | createdAt: Date; 39 | 40 | @UpdateDateColumn({ name: 'updated_at', nullable: true }) 41 | updatedAt: Date | null; 42 | 43 | @DeleteDateColumn({ name: 'deleted_at', nullable: true }) 44 | deletedAt: Date | null; 45 | 46 | @OneToOne(() => LiveEntity) 47 | @JoinColumn({ name: 'lives_id' }) 48 | live: LiveEntity; 49 | 50 | @ManyToMany(() => UserEntity, (user) => user.followers) 51 | @JoinTable({ 52 | name: 'follows', 53 | joinColumn: { 54 | name: 'follower_id', 55 | referencedColumnName: 'id', 56 | }, 57 | inverseJoinColumn: { 58 | name: 'streamer_id', 59 | referencedColumnName: 'id', 60 | }, 61 | }) 62 | following: UserEntity[]; 63 | 64 | @ManyToMany(() => UserEntity, (user) => user.following) 65 | followers: UserEntity[]; 66 | } -------------------------------------------------------------------------------- /Backend/apps/api/src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Put, 5 | UseGuards, 6 | UseInterceptors, 7 | Req, 8 | Param, 9 | Body, 10 | UploadedFile, 11 | ForbiddenException, 12 | NotFoundException, 13 | ParseIntPipe, 14 | BadRequestException, 15 | } from '@nestjs/common'; 16 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 17 | import { Request } from 'express'; 18 | import { UserEntity } from './entity/user.entity'; 19 | import { UsersService } from './users.service'; 20 | import { UpdateUserDto } from './dto/update.user.dto'; 21 | import { FileInterceptor } from '@nestjs/platform-express'; 22 | import * as multer from 'multer'; 23 | 24 | @Controller('users') 25 | export class UsersController { 26 | constructor(private readonly usersService: UsersService) {} 27 | 28 | // 사용자 프로필 조회 29 | @Get('/:userId') 30 | async getProfile(@Param('userId', ParseIntPipe) userId: number) { 31 | const user = await this.usersService.findById(userId); 32 | if (!user) { 33 | throw new NotFoundException('사용자를 찾을 수 없습니다.'); 34 | } 35 | 36 | return { 37 | users_id: user.id, 38 | nickname: user.nickname, 39 | profile_image: user.profileImage, 40 | created_at: user.createdAt, 41 | }; 42 | } 43 | 44 | // 사용자 프로필 업데이트 45 | @Put('/:userId') 46 | @UseGuards(JwtAuthGuard) 47 | @UseInterceptors( 48 | FileInterceptor('profile_image', { 49 | storage: multer.memoryStorage(), 50 | limits: { fileSize: 5 * 1024 * 1024 }, // 최대 5MB 51 | fileFilter: (req, file, cb) => { 52 | if (file.mimetype.match(/\/(jpg|jpeg|png)$/)) { 53 | cb(null, true); 54 | } else { 55 | cb(new BadRequestException('지원되지 않는 이미지 형식입니다.'), false); 56 | } 57 | }, 58 | }), 59 | ) 60 | async updateProfile( 61 | @Param('userId', ParseIntPipe) userId: number, 62 | @Req() req: Request & { user: UserEntity }, 63 | @Body() updateUserDto: UpdateUserDto, 64 | @UploadedFile() file?: Express.Multer.File, 65 | ) { 66 | if (req.user.id !== userId) { 67 | throw new ForbiddenException('본인만 프로필을 수정할 수 있습니다.'); 68 | } 69 | 70 | const updatedUser = await this.usersService.updateUser(userId, updateUserDto, file); 71 | 72 | return { 73 | users_id: updatedUser.id, 74 | nickname: updatedUser.nickname, 75 | profile_image: updatedUser.profileImage, 76 | created_at: updatedUser.createdAt, 77 | }; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Backend/apps/api/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from './users.service'; 4 | import { UserEntity } from './entity/user.entity'; 5 | import { LiveEntity } from '../lives/entity/live.entity'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([UserEntity, LiveEntity])], 10 | controllers: [UsersController], 11 | providers: [UsersService], 12 | exports: [UsersService, TypeOrmModule], // TypeOrmModule도 export 13 | }) 14 | export class UsersModule {} 15 | -------------------------------------------------------------------------------- /Backend/apps/api/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /Backend/apps/api/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Backend/apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/api" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /Backend/apps/chats/src/chats.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class ChatsController { 5 | constructor() {} 6 | 7 | @Get() 8 | healthCheck() {} 9 | } 10 | -------------------------------------------------------------------------------- /Backend/apps/chats/src/chats.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChatsGateway } from './chats.gateway'; 3 | 4 | describe('ChatsGateway', () => { 5 | let gateway: ChatsGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ChatsGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(ChatsGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /Backend/apps/chats/src/chats.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { ChatsGateway } from './chats.gateway'; 4 | import { RedisModule } from '@nestjs-modules/ioredis'; 5 | import { ChatsController } from './chats.controller'; 6 | import { redisConfig } from './config/redis.config'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot({ 11 | isGlobal: true, 12 | }), 13 | RedisModule.forRootAsync({ 14 | imports: [ConfigModule], 15 | inject: [ConfigService], 16 | useFactory: async (configService: ConfigService) => redisConfig(configService), 17 | }), 18 | ], 19 | providers: [ChatsGateway], 20 | controllers: [ChatsController], 21 | }) 22 | export class ChatsModule {} 23 | -------------------------------------------------------------------------------- /Backend/apps/chats/src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { RedisModuleOptions } from '@nestjs-modules/ioredis'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | export const redisConfig = (configService: ConfigService): RedisModuleOptions => ({ 5 | type: 'single', 6 | url: configService.get('REDIS_URL'), 7 | options: { 8 | password: configService.get('REDIS_PASSWORD'), 9 | retryStrategy: times => configService.get('REDIS_RETRY_MILLISECONDS'), 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /Backend/apps/chats/src/dto/chat.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsNumber, IsDate, IsBoolean } from 'class-validator'; 2 | export class ChatDto { 3 | @IsString() 4 | @IsNotEmpty() 5 | content: string; 6 | 7 | @IsNumber() 8 | @IsNotEmpty() 9 | userId: number; 10 | 11 | @IsString() 12 | @IsNotEmpty() 13 | nickname: string; 14 | 15 | @IsDate() 16 | timestamp: Date; 17 | 18 | @IsBoolean() 19 | fillteringResult: boolean; 20 | } 21 | -------------------------------------------------------------------------------- /Backend/apps/chats/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ChatsModule } from './chats.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(ChatsModule); 7 | 8 | const configService = app.get(ConfigService); 9 | 10 | app.enableCors({ 11 | // CORS 설정 12 | origin: configService.get('CORS')?.split(',') || '*', 13 | methods: ['GET'], 14 | }); 15 | 16 | let port = configService.get('CHATS_PORT') || 9000; 17 | const maxPort = configService.get('CHATS_MAX_PORT') || 10000; 18 | 19 | while (port <= maxPort) { 20 | try { 21 | await app.listen(port); 22 | console.log(`chats is running on: ${await app.getUrl()}`); 23 | break; // 포트를 성공적으로 사용하면 루프 종료 24 | } catch (error) { 25 | if (error.code === 'EADDRINUSE') { 26 | console.warn(`Port ${port} is already in use. Trying next port...`); 27 | port++; // 포트 번호 증가 28 | } else { 29 | console.error('Failed to start the server:', error); 30 | process.exit(1); // 다른 오류 발생 시 종료 31 | } 32 | } 33 | } 34 | 35 | if (port > maxPort) { 36 | console.error('포트 범위를 확인해 주세요'); 37 | process.exit(1); 38 | } 39 | } 40 | bootstrap(); 41 | -------------------------------------------------------------------------------- /Backend/apps/chats/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { ChatsModule } from './../src/chats.module'; 5 | 6 | describe('ChatsController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [ChatsModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /Backend/apps/chats/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Backend/apps/chats/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/chats" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /Backend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import reactHooks from 'eslint-plugin-react-hooks'; 3 | import reactRefresh from 'eslint-plugin-react-refresh'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: ['dist'], 9 | }, 10 | { 11 | extends: [ 12 | 'eslint:recommended', 13 | ...tseslint.configs.recommended, 14 | 'airbnb', 15 | 'airbnb/hooks', 16 | 'plugin:@typescript-eslint/recommended', 17 | 'plugin:react/recommended', 18 | 'plugin:react/jsx-runtime', 19 | 'plugin:prettier/recommended', 20 | ], 21 | files: ['**/*.{ts,tsx}'], 22 | languageOptions: { 23 | ecmaVersion: 2020, 24 | globals: { 25 | ...globals.browser, 26 | React: true, 27 | }, 28 | parser: '@typescript-eslint/parser', 29 | parserOptions: { 30 | ecmaFeatures: { 31 | jsx: true, 32 | }, 33 | ecmaVersion: 2020, 34 | sourceType: 'module', 35 | }, 36 | }, 37 | plugins: { 38 | '@typescript-eslint': tseslint.plugin, 39 | 'react-hooks': reactHooks, 40 | 'react-refresh': reactRefresh, 41 | prettier: require('eslint-plugin-prettier'), 42 | }, 43 | rules: { 44 | ...reactHooks.configs.recommended.rules, 45 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 46 | 'prettier/prettier': 'error', 47 | 'react/react-in-jsx-scope': 'off', 48 | 'react/prop-types': 'off', 49 | '@typescript-eslint/explicit-module-boundary-types': 'off', 50 | }, 51 | settings: { 52 | react: { 53 | version: 'detect', 54 | }, 55 | }, 56 | }, 57 | ); 58 | -------------------------------------------------------------------------------- /Backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "apps/api/src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "webpack": true, 8 | "tsConfigPath": "apps/api/tsconfig.app.json" 9 | }, 10 | "monorepo": true, 11 | "root": "apps/api", 12 | "projects": { 13 | "backend": { 14 | "type": "application", 15 | "root": "apps/api", 16 | "entryFile": "main", 17 | "sourceRoot": "apps/api/src", 18 | "compilerOptions": { 19 | "tsConfigPath": "apps/api/tsconfig.app.json" 20 | } 21 | }, 22 | "chats": { 23 | "type": "application", 24 | "root": "apps/chats", 25 | "entryFile": "main", 26 | "sourceRoot": "apps/chats/src", 27 | "compilerOptions": { 28 | "tsConfigPath": "apps/chats/tsconfig.app.json" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Backend/server/api/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # 에러 발생 시 즉시 스크립트 종료 3 | 4 | echo "배포 스크립트 시작" 5 | BRANCH_NAME=$1 6 | echo "배포 브랜치: $BRANCH_NAME" 7 | 8 | if [ -z "$BRANCH_NAME" ]; then 9 | echo "Error: Branch name is required." 10 | exit 4 11 | fi 12 | 13 | cd /lico/Backend 14 | 15 | # Git 작업 16 | git fetch origin 17 | git checkout "$BRANCH_NAME" 18 | git reset --hard 19 | git pull origin "$BRANCH_NAME" 20 | 21 | # 의존성 설치 및 빌드 22 | npm install || exit 5 23 | npm run build api || exit 6 24 | 25 | # 서버 재시작 26 | forever stop dist/apps/api/main.js || true # 기존 프로세스가 없어도 오류 발생 방지 27 | forever start dist/apps/api/main.js 28 | 29 | echo "배포 성공" 30 | exit 0 31 | -------------------------------------------------------------------------------- /Backend/server/chats/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # 에러 발생 시 즉시 스크립트 종료 3 | 4 | echo "배포 스크립트 시작" 5 | BRANCH_NAME=$1 6 | echo "배포 브랜치: $BRANCH_NAME" 7 | 8 | if [ -z "$BRANCH_NAME" ]; then 9 | echo "Error: Branch name is required." 10 | exit 4 11 | fi 12 | 13 | cd /lico-chats/Backend 14 | 15 | # Git 작업 16 | git fetch origin 17 | git checkout "$BRANCH_NAME" 18 | git reset --hard 19 | git pull origin "$BRANCH_NAME" 20 | 21 | # 의존성 설치 및 빌드 22 | npm install || exit 5 23 | npm run build chats || exit 6 24 | 25 | # 서버 재시작 26 | forever stop dist/apps/chats/main.js || true # 기존 프로세스가 없어도 오류 발생 방지 27 | forever stop dist/apps/chats/main.js || true # 기존 프로세스가 없어도 오류 발생 방지 28 | forever start dist/apps/chats/main.js 29 | forever start dist/apps/chats/main.js 30 | 31 | echo "배포 성공" 32 | exit 0 -------------------------------------------------------------------------------- /Backend/server/encoding/cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | LOG_FILE="/lico/script/cleanup.log" 4 | exec > >(tee -a "$LOG_FILE") 2>&1 5 | 6 | 7 | APP_NAME=$1 8 | STREAM_KEY=$2 9 | 10 | sleep 5; # 송출 종료 5초 후 방종 처리 11 | 12 | echo "Cleanup FFmpeg script for APP_NAME: $APP_NAME, STREAM_KEY: $STREAM_KEY" 13 | # API 요청으로 채널 아이디 획득 14 | if [[ "$APP_NAME" == "live" ]]; then 15 | CHANNEL_ID=$(curl -s http://192.168.1.9:3000/lives/channel-id/$STREAM_KEY | jq -r '.channelId') 16 | elif [[ "$APP_NAME" == "dev" ]]; then 17 | CHANNEL_ID=$(curl -s http://192.168.1.7:3000/lives/channel-id/$STREAM_KEY | jq -r '.channelId') 18 | else 19 | echo "Error: Unsupported APP_NAME. Exiting." 20 | exit 1 21 | fi 22 | 23 | 24 | if [[ "$APP_NAME" == "live" ]]; then 25 | curl -X DELETE http://192.168.1.9:3000/lives/onair/$STREAM_KEY 26 | elif [[ "$APP_NAME" == "dev" ]]; then 27 | curl -X DELETE http://192.168.1.7:3000/lives/onair/$STREAM_KEY 28 | else 29 | echo "Error: Unsupported APP_NAME. Exiting." 30 | exit 1 31 | fi 32 | 33 | if [[ -d "/lico/storage/$APP_NAME/$CHANNEL_ID" ]]; then 34 | rm -rf "/lico/storage/$APP_NAME/$CHANNEL_ID" 35 | echo "Deleted directory: /lico/storage/$APP_NAME/$CHANNEL_ID" 36 | else 37 | echo "Directory /lico/storage/$APP_NAME/$CHANNEL_ID does not exist, skipping deletion." 38 | fi -------------------------------------------------------------------------------- /Backend/server/encoding/nginx.conf: -------------------------------------------------------------------------------- 1 | user root; 2 | worker_processes auto; 3 | pid /run/nginx.pid; 4 | include /etc/nginx/modules-enabled/*.conf; 5 | 6 | error_log /var/log/nginx/rtmp_error.log; 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | rtmp { 13 | server { 14 | listen 1935; 15 | 16 | application live { 17 | exec_publish /lico/script/stream_process.sh $app $name; 18 | 19 | exec_publish_done /lico/script/cleanup.sh $app $name 20 | 21 | access_log /var/log/nginx/rtmp_access.log; 22 | } 23 | application dev { 24 | exec_publish /lico/script/stream_process.sh $app $name; 25 | 26 | exec_publish_done /lico/script/cleanup.sh $app $name; 27 | 28 | access_log /var/log/nginx/rtmp_access.log; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Backend/server/encoding/stream_process.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | LOG_FILE="/lico/script/logfile.log" 4 | exec > >(tee -a "$LOG_FILE") 2>&1 5 | 6 | APP_NAME=$1 7 | STREAM_KEY=$2 8 | 9 | echo "Starting FFmpeg script for APP_NAME: $APP_NAME, STREAM_KEY: $STREAM_KEY" 10 | 11 | if [[ "$APP_NAME" == "live" ]]; then 12 | CHANNEL_ID=$(curl -s http://192.168.1.9:3000/lives/channel-id/$STREAM_KEY | jq -r '.channelId') 13 | elif [[ "$APP_NAME" == "dev" ]]; then 14 | CHANNEL_ID=$(curl -s http://192.168.1.7:3000/lives/channel-id/$STREAM_KEY | jq -r '.channelId') 15 | else 16 | echo "Error: Unsupported APP_NAME. Exiting." 17 | exit 1 18 | fi 19 | 20 | if [[ -z "$CHANNEL_ID" ]]; then 21 | echo "Error: CHANNEL_ID is empty. Exiting." 22 | exit 1 23 | fi 24 | 25 | 26 | ffmpeg -rw_timeout 20000000 -r 30 -i "rtmp://192.168.1.6:1935/$APP_NAME/$STREAM_KEY" -y \ 27 | -filter_complex "[0:v]split=3[720p][for480p][for360p];[for480p]scale=854:480[480p];[for360p]scale=640:360[360p]" \ 28 | -map "[720p]" -map 0:a? -c:v:0 libx264 -b:v:0 2800k -s:v:0 1280x720 -preset ultrafast -g 90 -tune zerolatency -profile:v:0 baseline -c:a:0 aac -b:a:0 128k \ 29 | -map "[480p]" -map 0:a? -c:v:1 libx264 -b:v:1 1200k -preset ultrafast -g 90 -tune zerolatency -profile:v:1 baseline -c:a:1 copy \ 30 | -map "[360p]" -map 0:a? -c:v:2 libx264 -b:v:2 600k -preset ultrafast -g 90 -tune zerolatency -profile:v:2 baseline -c:a:2 copy \ 31 | -keyint_min 90 -hls_segment_type fmp4 -hls_flags independent_segments+append_list \ 32 | -hls_list_size 2 \ 33 | -hls_segment_filename "/lico/rclone/$APP_NAME/$CHANNEL_ID/%v/%03d.m4s" \ 34 | -master_pl_name "index.m3u8" \ 35 | -var_stream_map "v:2,a:2 v:1,a:1 v:0,a:0" \ 36 | -f hls "/lico/rclone/$APP_NAME/$CHANNEL_ID/%v/index.m3u8" \ 37 | -map 0:v -vf "fps=1/10,scale=426:240" -update 1 "/lico/rclone/$APP_NAME/$CHANNEL_ID/thumbnail.jpg" 38 | 39 | -------------------------------------------------------------------------------- /Backend/server/ingest(srs)/lico.conf: -------------------------------------------------------------------------------- 1 | listen 1935; 2 | max_connections 1000; 3 | daemon on; 4 | 5 | http_api { 6 | enabled on; 7 | listen 1985; 8 | } 9 | stats { 10 | network 0; 11 | } 12 | 13 | rtc_server { 14 | enabled on; 15 | listen 8000; # UDP port 16 | } 17 | 18 | 19 | vhost __defaultVhost__ { 20 | # HTTP Hooks 설정 21 | http_hooks { 22 | enabled on; 23 | # 내부 nginx로 요청을 보내 nginx에서 바디에 담긴 app 정보를 이용해 요청을 보내는 api 서버에 분기를 준다. 24 | on_publish http://localhost:3000/api/validate/publish; 25 | } 26 | 27 | # RTMP 포워딩 설정 28 | forward { 29 | enabled on; 30 | # 원본 스트림의 app과 stream name을 유지하면서 포워딩 31 | destination 192.168.2.7:1935/[app]/[stream]; 32 | } 33 | 34 | # WebRTC 설정 35 | rtc { 36 | enabled on; 37 | rtc_to_rtmp on; 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /Backend/server/ingest(srs)/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes auto; 3 | pid /run/nginx.pid; 4 | include /etc/nginx/modules-enabled/*.conf; 5 | 6 | events { 7 | worker_connections 768; 8 | # multi_accept on; 9 | } 10 | 11 | http { 12 | include mime.types; 13 | default_type application/octet-stream; lua_shared_dict app_cache 10m; 14 | 15 | lua_need_request_body on; 16 | 17 | access_log /var/log/nginx/proxy_access.log; 18 | error_log /var/log/nginx/proxy_error.log; 19 | 20 | server { 21 | listen 3000; 22 | 23 | location /api/validate/publish { 24 | # Lua를 이용해 본문을 읽고 app과 stream 값을 기반으로 라우팅 25 | content_by_lua_block { 26 | local cjson = require("cjson") 27 | local http = require("resty.http") 28 | 29 | -- 요청 본문 읽기 30 | ngx.req.read_body() 31 | local body = ngx.req.get_body_data() 32 | 33 | -- JSON 본문 파싱 34 | local success, data = pcall(cjson.decode, body) 35 | local app = data.app 36 | local stream = data.stream 37 | 38 | -- app 값에 따라 대상 서버 결정 39 | local upstream 40 | if app == "live" then 41 | upstream = "http://192.168.1.9:3000/lives/onair" 42 | elseif app == "dev" then upstream = "http://192.168.1.7:3000/lives/onair" 43 | else 44 | ngx.status = ngx.HTTP_BAD_REQUEST 45 | ngx.say("Invalid app") 46 | return 47 | end 48 | 49 | -- stream 값이 있는지 확인 50 | if not stream then 51 | ngx.status = ngx.HTTP_BAD_REQUEST 52 | ngx.say("Stream parameter is required") 53 | return 54 | end 55 | 56 | -- 동적 엔드포인트 설정 57 | local target_url = upstream .. "/" .. stream 58 | 59 | ngx.log(ngx.ERR, "Target URL :",target_url) 60 | 61 | local httpc = http.new() 62 | 63 | local res,err = httpc:request_uri(target_url, { 64 | method = "POST", 65 | body = body, 66 | headers = { 67 | ["Content-Type"] = "application/json",}}) 68 | 69 | 70 | ngx.status = res.status 71 | 72 | ngx.log(ngx.ERR, "body", res.body) 73 | ngx.say(res.body) 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /Backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /Backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "paths": {} 21 | } 22 | } -------------------------------------------------------------------------------- /Frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | 'airbnb', 9 | 'airbnb-typescript', 10 | 'airbnb/hooks', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:prettier/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | sourceType: 'module', 18 | project: ['./tsconfig.app.json', './tsconfig.node.json'], 19 | tsconfigRootDir: __dirname, 20 | }, 21 | plugins: ['@typescript-eslint', 'prettier'], 22 | rules: { 23 | 'react/react-in-jsx-scope': 'off', 24 | 'import/no-extraneous-dependencies': 'off', 25 | 'react/jsx-props-no-spreading': 'off', 26 | 'react/require-default-props': 'off', 27 | }, 28 | ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts', 'tailwind.config.js', 'postcss.config.js'], 29 | }; 30 | -------------------------------------------------------------------------------- /Frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env 27 | .env.development 28 | .env.production 29 | build -------------------------------------------------------------------------------- /Frontend/.netlifyignore: -------------------------------------------------------------------------------- 1 | /dist 2 | 3 | /src/** 4 | /public 5 | /.vscode 6 | /.idea 7 | 8 | .gitignore 9 | .prettierrc 10 | .eslintrc 11 | vite.config.js 12 | postcss.config.js 13 | tailwind.config.js 14 | tsconfig.* 15 | 16 | /node_modules 17 | package.json 18 | package-lock.json 19 | yarn.lock 20 | 21 | .env* 22 | 23 | *.log 24 | .DS_Store 25 | 26 | !dist/** -------------------------------------------------------------------------------- /Frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "printWidth": 120, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "auto", 10 | "bracketSameLine": false, 11 | "jsxSingleQuote": false, 12 | "plugins": ["prettier-plugin-tailwindcss"] 13 | } 14 | -------------------------------------------------------------------------------- /Frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LICO 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Frontend/lib/buffer/index.ts: -------------------------------------------------------------------------------- 1 | export { initializeBuffer, appendMediaSegment } from './core/buffer'; 2 | -------------------------------------------------------------------------------- /Frontend/lib/buffer/types/types.ts: -------------------------------------------------------------------------------- 1 | import { ContainerFormat } from '../../manifest'; 2 | 3 | export type BufferInitializationConfig = { 4 | mimeType: string; 5 | containerFormat: ContainerFormat; 6 | initSegmentUrl?: string; 7 | }; 8 | 9 | export type BufferAppendOptions = { 10 | sequence: number; 11 | duration: number; 12 | }; 13 | 14 | export type InitializationSegment = { 15 | data: ArrayBuffer; 16 | }; 17 | 18 | export type MediaSegment = { 19 | data: ArrayBuffer; 20 | duration: number; 21 | sequence: number; 22 | }; 23 | -------------------------------------------------------------------------------- /Frontend/lib/buffer/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const BufferErrors = { 2 | INVALID_STATE: 'MediaSource is not in open state', 3 | QUOTA_EXCEEDED: 'Buffer quota exceeded. Consider removing old segments', 4 | INIT_SEGMENT_REQUIRED: 'Initialization segment URL is required for fMP4 format', 5 | UPDATE_IN_PROGRESS: 'Buffer update already in progress', 6 | APPEND_ERROR: 'Error occurred while appending to buffer', 7 | } as const; 8 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/__test__/parser.fixtures.ts: -------------------------------------------------------------------------------- 1 | export const ParserTestData = { 2 | MasterManifest: { 3 | Basic: `#EXTM3U 4 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2" 5 | video_720p.m3u8 6 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,RESOLUTION=1920x1080,CODECS="avc1.4d401f,mp4a.40.2" 7 | video_1080p.m3u8`, 8 | SingleVariant: `#EXTM3U 9 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2" 10 | video_720p.m3u8`, 11 | }, 12 | MediaManifest: { 13 | VodTs: `#EXTM3U 14 | #EXT-X-VERSION:3 15 | #EXT-X-PLAYLIST-TYPE:VOD 16 | #EXT-X-TARGETDURATION:10 17 | #EXT-X-MEDIA-SEQUENCE:0 18 | #EXTINF:9.009, 19 | segment1.ts 20 | #EXTINF:8.008, 21 | segment2.ts 22 | #EXT-X-ENDLIST`, 23 | VodFmp4: `#EXTM3U 24 | #EXT-X-VERSION:3 25 | #EXT-X-PLAYLIST-TYPE:VOD 26 | #EXT-X-TARGETDURATION:10 27 | #EXT-X-MAP:URI="init.mp4" 28 | #EXTINF:9.009, 29 | segment1.m4s 30 | #EXT-X-ENDLIST`, 31 | Live: `#EXTM3U 32 | #EXT-X-VERSION:3 33 | #EXT-X-TARGETDURATION:6 34 | #EXT-X-MEDIA-SEQUENCE:123 35 | #EXTINF:5.005, 36 | segment123.ts 37 | #EXTINF:6.006, 38 | segment124.ts`, 39 | }, 40 | } as const; 41 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/__test__/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest'; 2 | import { parseMasterManifest, parseMediaManifest } from '../core/parser'; 3 | import { ParserTestData } from './parser.fixtures'; 4 | import { ContainerFormat } from '../types/types'; 5 | 6 | describe('HLS Manifest Parser', () => { 7 | const baseUrl = 'http://example.com/'; 8 | 9 | describe('Master Manifest Parsing', () => { 10 | test('should correctly parse master manifest with multiple variants', () => { 11 | const result = parseMasterManifest(ParserTestData.MasterManifest.Basic, baseUrl); 12 | 13 | expect(result.variants).toHaveLength(2); 14 | expect(result.variants[0]).toEqual({ 15 | bandwidth: 1280000, 16 | resolution: '1280x720', 17 | codecs: 'avc1.4d401f,mp4a.40.2', 18 | url: 'http://example.com/video_720p.m3u8', 19 | }); 20 | }); 21 | 22 | test('should correctly parse master manifest with single variant', () => { 23 | const result = parseMasterManifest(ParserTestData.MasterManifest.SingleVariant, baseUrl); 24 | 25 | expect(result.variants).toHaveLength(1); 26 | expect(result.variants[0].bandwidth).toBe(1280000); 27 | }); 28 | }); 29 | 30 | describe('Media Manifest Parsing', () => { 31 | test('should correctly parse VOD TS manifest', () => { 32 | const result = parseMediaManifest(ParserTestData.MediaManifest.VodTs, baseUrl); 33 | 34 | expect(result).toEqual({ 35 | version: 3, 36 | playlistType: 'VOD', 37 | targetDuration: 10, 38 | mediaSequence: 0, 39 | endList: true, 40 | containerFormat: ContainerFormat.MPEG_TS, 41 | segments: [ 42 | { 43 | duration: 9.009, 44 | uri: 'http://example.com/segment1.ts', 45 | sequence: 0, 46 | }, 47 | { 48 | duration: 8.008, 49 | uri: 'http://example.com/segment2.ts', 50 | sequence: 1, 51 | }, 52 | ], 53 | }); 54 | }); 55 | 56 | test('should correctly parse VOD fMP4 manifest with initialization segment', () => { 57 | const result = parseMediaManifest(ParserTestData.MediaManifest.VodFmp4, baseUrl); 58 | 59 | expect(result.containerFormat).toBe(ContainerFormat.FRAGMENTED_MP4); 60 | expect(result.initializationSegment).toEqual({ 61 | uri: 'http://example.com/init.mp4', 62 | }); 63 | }); 64 | 65 | test('should correctly parse live manifest', () => { 66 | const result = parseMediaManifest(ParserTestData.MediaManifest.Live, baseUrl); 67 | 68 | expect(result.playlistType).toBeUndefined(); 69 | expect(result.mediaSequence).toBe(123); 70 | expect(result.endList).toBe(false); 71 | expect(result.segments[0].sequence).toBe(123); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/core/loader.ts: -------------------------------------------------------------------------------- 1 | import { MasterManifest, MediaManifest } from '../types/types'; 2 | import { LoaderErrors } from '../utils/constants'; 3 | import { createBaseUrl } from '../utils/utils'; 4 | import { validateMasterManifest, validateMediaManifest } from './validator'; 5 | import { parseMasterManifest, parseMediaManifest } from './parser'; 6 | 7 | export async function loadMasterManifest(manifestUrl: string): Promise { 8 | const baseUrl = createBaseUrl(manifestUrl); 9 | const manifest = await fetchManifest(manifestUrl); 10 | validateMasterManifest(manifest); 11 | return parseMasterManifest(manifest, baseUrl); 12 | } 13 | 14 | export async function loadMediaManifest(manifestUrl: string): Promise { 15 | const baseUrl = createBaseUrl(manifestUrl); 16 | const manifest = await fetchManifest(manifestUrl); 17 | validateMediaManifest(manifest); 18 | return parseMediaManifest(manifest, baseUrl); 19 | } 20 | 21 | async function fetchManifest(url: string): Promise { 22 | try { 23 | const response = await fetch(url); 24 | if (!response.ok) { 25 | throw new Error(LoaderErrors.HTTP_ERROR(response.status, response.statusText)); 26 | } 27 | return await response.text(); 28 | } catch (error) { 29 | if (error instanceof TypeError) { 30 | throw new Error(LoaderErrors.NETWORK_ERROR); 31 | } 32 | throw error; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/core/validator.ts: -------------------------------------------------------------------------------- 1 | import { CommonPatterns, MasterManifestPatterns, MediaManifestPatterns, ValidatorMessages } from '../utils/constants'; 2 | import { PlaylistType } from '../types/types'; 3 | 4 | export function validateMasterManifest(manifest: string): void { 5 | if (!CommonPatterns.EXTM3U.test(manifest)) { 6 | throw new Error(ValidatorMessages.Errors.INVALID_MANIFEST); 7 | } 8 | 9 | if (!MasterManifestPatterns.STREAM_INF.test(manifest)) { 10 | throw new Error(ValidatorMessages.Errors.NO_VARIANTS); 11 | } 12 | } 13 | 14 | export function validateMediaManifest(manifest: string): void { 15 | if (!CommonPatterns.EXTM3U.test(manifest)) { 16 | throw new Error(ValidatorMessages.Errors.INVALID_MANIFEST); 17 | } 18 | 19 | if (!MediaManifestPatterns.SEGMENTS.test(manifest)) { 20 | throw new Error(ValidatorMessages.Errors.NO_SEGMENTS); 21 | } 22 | 23 | if (!MediaManifestPatterns.TARGET_DURATION.test(manifest)) { 24 | console.warn(ValidatorMessages.Warnings.MISSING_TARGET_DURATION); 25 | } 26 | 27 | if (!MediaManifestPatterns.VERSION.test(manifest)) { 28 | console.warn(ValidatorMessages.Warnings.MISSING_VERSION); 29 | } 30 | 31 | if (!MediaManifestPatterns.MEDIA_SEQUENCE.test(manifest)) { 32 | console.warn(ValidatorMessages.Warnings.MISSING_MEDIA_SEQUENCE); 33 | } 34 | 35 | const playlistTypeMatch = MediaManifestPatterns.PLAYLIST_TYPE.exec(manifest); 36 | if (playlistTypeMatch && playlistTypeMatch[1] === PlaylistType.VOD && !MediaManifestPatterns.ENDLIST.test(manifest)) { 37 | console.warn(ValidatorMessages.Warnings.VOD_MISSING_ENDLIST); 38 | } 39 | 40 | if (MediaManifestPatterns.MAP.test(manifest)) { 41 | if (!MediaManifestPatterns.MAP.exec(manifest)?.[1]) { 42 | console.warn(ValidatorMessages.Warnings.FMP4_MISSING_INIT); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/index.ts: -------------------------------------------------------------------------------- 1 | export type { MasterManifest, MediaManifest, StreamVariant, Segment, InitSegment, PlaylistType } from './types/types'; 2 | 3 | export { loadMasterManifest, loadMediaManifest } from './core/loader'; 4 | 5 | export { ContainerFormat } from './types/types'; 6 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/types/types.ts: -------------------------------------------------------------------------------- 1 | export type MasterManifest = { 2 | variants: StreamVariant[]; 3 | }; 4 | 5 | export type StreamVariant = { 6 | bandwidth: number; 7 | resolution?: string; 8 | codecs: string; 9 | url: string; 10 | }; 11 | 12 | export type MediaManifest = { 13 | version: number; 14 | segments: Segment[]; 15 | targetDuration: number; 16 | mediaSequence: number; 17 | endList: boolean; 18 | containerFormat: ContainerFormat; 19 | playlistType?: string; 20 | initializationSegment?: InitSegment; 21 | }; 22 | 23 | export type Segment = { 24 | duration: number; 25 | uri: string; 26 | sequence: number; 27 | }; 28 | 29 | export type InitSegment = { 30 | uri: string; 31 | }; 32 | 33 | export enum ContainerFormat { 34 | MPEG_TS = 'ts', 35 | FRAGMENTED_MP4 = 'm4s', 36 | } 37 | 38 | export enum PlaylistType { 39 | VOD = 'VOD', 40 | EVENT = 'EVENT', 41 | } 42 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const CommonPatterns = { 2 | EXTM3U: /#EXTM3U/, 3 | } as const; 4 | 5 | export const MasterManifestPatterns = { 6 | STREAM_INF: /#EXT-X-STREAM-INF:([^\n]*)[\r\n]+([^\r\n]+)/g, 7 | ATTRIBUTES: /([A-Z0-9-]+)=(?:"([^"]*)"|([^,]*))(?:,|$)/g, 8 | } as const; 9 | 10 | export const MediaManifestPatterns = { 11 | VERSION: /#EXT-X-VERSION:(\d+)/, 12 | PLAYLIST_TYPE: /#EXT-X-PLAYLIST-TYPE:(VOD|EVENT)/, 13 | TARGET_DURATION: /#EXT-X-TARGETDURATION:(\d+)/, 14 | MEDIA_SEQUENCE: /#EXT-X-MEDIA-SEQUENCE:(\d+)/, 15 | SEGMENTS: /#EXTINF:([\d.]+),?\s*\n+([^\n]+)/g, 16 | MAP: /#EXT-X-MAP:URI="([^"]+)"/, 17 | ENDLIST: /#EXT-X-ENDLIST/, 18 | } as const; 19 | 20 | export const ManifestAttributes = { 21 | Master: { 22 | BANDWIDTH: 'BANDWIDTH', 23 | RESOLUTION: 'RESOLUTION', 24 | CODECS: 'CODECS', 25 | }, 26 | } as const; 27 | 28 | export const ValidatorMessages = { 29 | Errors: { 30 | INVALID_MANIFEST: 'Invalid manifest: Missing #EXTM3U tag', 31 | NO_VARIANTS: 'Invalid master manifest: No stream variants found', 32 | NO_SEGMENTS: 'Invalid media manifest: No segments found', 33 | }, 34 | Warnings: { 35 | MISSING_TARGET_DURATION: 'Required tag #EXT-X-TARGETDURATION is missing. This may cause playback issues.', 36 | MISSING_VERSION: 'Tag #EXT-X-VERSION is missing. Using default version 3.', 37 | MISSING_MEDIA_SEQUENCE: 'Tag #EXT-X-MEDIA-SEQUENCE is missing. Using default value 0.', 38 | VOD_MISSING_ENDLIST: 'VOD manifest is missing #EXT-X-ENDLIST tag. This may cause unexpected behavior.', 39 | FMP4_MISSING_INIT: 'Fragmented MP4 manifest is missing initialization segment URI.', 40 | }, 41 | } as const; 42 | 43 | export const LoaderErrors = { 44 | HTTP_ERROR: (status: number, statusText: string) => `Failed to load manifest: ${status} ${statusText}`, 45 | NETWORK_ERROR: 'Network error occurred while loading manifest', 46 | } as const; 47 | -------------------------------------------------------------------------------- /Frontend/lib/manifest/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function createBaseUrl(manifestUrl: string): string { 2 | const urlObject = new URL(manifestUrl); 3 | const urlParts = urlObject.pathname.split('/'); 4 | urlParts.pop(); 5 | return `${urlObject.protocol}//${urlObject.host}${urlParts.join('/')}/`; 6 | } 7 | 8 | export function resolveUrl(uri: string, baseUrl: string): string { 9 | return new URL(uri, baseUrl).toString(); 10 | } 11 | -------------------------------------------------------------------------------- /Frontend/lib/segment/core/loader.ts: -------------------------------------------------------------------------------- 1 | import { SegmentLoadOptions, SegmentLoadResult } from '../types/types'; 2 | import { SegmentDefaults, SegmentErrors } from '../utils/constants'; 3 | import { timeoutPromise } from '../utils/utils'; 4 | 5 | export async function loadSegment( 6 | url: string, 7 | sequence: number, 8 | duration: number, 9 | options: SegmentLoadOptions = {} 10 | ): Promise { 11 | const { timeout = SegmentDefaults.TIMEOUT, retryCount = SegmentDefaults.RETRY_COUNT } = options; 12 | 13 | const timeoutErrorMessage = SegmentErrors.TIMEOUT_ERROR(timeout); 14 | 15 | let lastError: Error | null = null; 16 | 17 | for (let attempt = 0; attempt < retryCount; attempt++) { 18 | try { 19 | const response = await Promise.race([fetch(url), timeoutPromise(timeout, timeoutErrorMessage)]); 20 | 21 | if (!response.ok) { 22 | throw new Error(SegmentErrors.LOAD_ERROR(response.status, response.statusText)); 23 | } 24 | 25 | if (response.status === 404) { 26 | throw new Error(SegmentErrors.NOT_FOUND_ERROR); 27 | } 28 | 29 | const data = await response.arrayBuffer(); 30 | return { data, sequence, duration }; 31 | } catch (error) { 32 | lastError = error as Error; 33 | if (attempt < retryCount - 1) { 34 | await new Promise(resolve => setTimeout(resolve, SegmentDefaults.RETRY_DELAY)); 35 | } 36 | } 37 | } 38 | 39 | throw lastError; 40 | } 41 | -------------------------------------------------------------------------------- /Frontend/lib/segment/index.ts: -------------------------------------------------------------------------------- 1 | export { loadSegment } from './core/loader'; 2 | -------------------------------------------------------------------------------- /Frontend/lib/segment/types/types.ts: -------------------------------------------------------------------------------- 1 | export type SegmentLoadOptions = { 2 | timeout?: number; // 세그먼트 로드 타임아웃 시간 3 | retryCount?: number; // 재시도 횟수 4 | }; 5 | 6 | export type SegmentLoadResult = { 7 | data: ArrayBuffer; // 세그먼트 데이터 8 | duration: number; // 세그먼트 지속 시간 9 | sequence: number; // 세그먼트 시퀀스 번호 10 | }; 11 | -------------------------------------------------------------------------------- /Frontend/lib/segment/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const SegmentDefaults = { 2 | TIMEOUT: 10000, 3 | RETRY_COUNT: 3, 4 | RETRY_DELAY: 1000, 5 | } as const; 6 | 7 | export const SegmentErrors = { 8 | LOAD_ERROR: (status: number, statusText: string) => `Failed to load segment: ${status} ${statusText}`, 9 | TIMEOUT_ERROR: (timeout: number) => `Segment load timed out after ${timeout}ms`, 10 | NETWORK_ERROR: 'Network error occurred while loading segment', 11 | NOT_FOUND_ERROR: 'Segment not found (404 error)', 12 | } as const; 13 | -------------------------------------------------------------------------------- /Frontend/lib/segment/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const timeoutPromise = (ms: number, errorMessage: string) => 2 | new Promise((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)); 3 | -------------------------------------------------------------------------------- /Frontend/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "dist" 4 | [[redirects]] 5 | from = "/*" 6 | to = "/index.html" 7 | status = 200 -------------------------------------------------------------------------------- /Frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx}\"", 10 | "lint": "eslint . --ext .ts,.tsx --fix", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@tanstack/react-query": "^5.59.20", 15 | "@tanstack/react-query-devtools": "^5.59.20", 16 | "axios": "^1.7.7", 17 | "hls.js": "^1.5.17", 18 | "lodash": "^4.17.21", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "react-icons": "^5.3.0", 22 | "react-rnd": "^10.4.13", 23 | "react-router-dom": "^6.27.0", 24 | "rnd": "^1.0.10", 25 | "socket.io-client": "^4.8.1", 26 | "tailwind-scrollbar-hide": "^1.1.7", 27 | "zustand": "^5.0.1" 28 | }, 29 | "devDependencies": { 30 | "@types/lodash": "^4.17.14", 31 | "@types/node": "^22.9.0", 32 | "@types/react": "^18.3.12", 33 | "@types/react-dom": "^18.3.1", 34 | "@typescript-eslint/eslint-plugin": "^7.18.0", 35 | "@typescript-eslint/parser": "^7.18.0", 36 | "@vitejs/plugin-basic-ssl": "^1.1.0", 37 | "@vitejs/plugin-react": "^4.3.3", 38 | "autoprefixer": "^10.4.20", 39 | "eslint": "^8.57.1", 40 | "eslint-config-airbnb": "^19.0.4", 41 | "eslint-config-airbnb-typescript": "^18.0.0", 42 | "eslint-config-prettier": "^9.1.0", 43 | "eslint-import-resolver-typescript": "^3.6.3", 44 | "eslint-plugin-import": "^2.31.0", 45 | "eslint-plugin-jsx-a11y": "^6.10.2", 46 | "eslint-plugin-prettier": "^5.2.1", 47 | "eslint-plugin-react": "^7.37.2", 48 | "eslint-plugin-react-hooks": "^4.6.2", 49 | "postcss": "^8.4.47", 50 | "prettier": "^3.3.3", 51 | "tailwindcss": "^3.4.14", 52 | "terser": "^5.36.0", 53 | "typescript": "~5.6.2", 54 | "typescript-eslint": "^8.11.0", 55 | "vite": "^5.4.10" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /Frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web26-LICO/1f111bc41979094c15975286486fb8a5db029a5d/Frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /Frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web26-LICO/1f111bc41979094c15975286486fb8a5db029a5d/Frontend/public/favicon.ico -------------------------------------------------------------------------------- /Frontend/public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web26-LICO/1f111bc41979094c15975286486fb8a5db029a5d/Frontend/public/og-image.png -------------------------------------------------------------------------------- /Frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router } from 'react-router-dom'; 2 | import { QueryClientProvider } from '@tanstack/react-query'; 3 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 4 | import AppRoutes from '@routes/index'; 5 | import { queryClient } from '@/config/queryClient'; 6 | import { useLocation } from 'react-router-dom'; 7 | import { useEffect } from 'react'; 8 | import { useStudioStore } from '@store/useStudioStore'; 9 | 10 | function AppContent() { 11 | const location = useLocation(); 12 | const cleanup = useStudioStore(state => state.cleanup); 13 | 14 | useEffect(() => { 15 | const isLeavingStudioPage = !location.pathname.includes('/studio'); 16 | 17 | if (isLeavingStudioPage) { 18 | return () => cleanup(); 19 | } 20 | }, [location.pathname, cleanup]); 21 | 22 | return ; 23 | } 24 | 25 | export default function App() { 26 | return ( 27 | 28 | 29 | 30 | 31 | {import.meta.env.DEV && } 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /Frontend/src/apis/auth.ts: -------------------------------------------------------------------------------- 1 | import { api } from './axios'; 2 | import { AuthResponse, RefreshTokenResponse, AuthCallbackParams } from '@/types/auth'; 3 | 4 | export const authApi = { 5 | async logout() { 6 | const response = await api.get('/auth/logout'); 7 | return response.data; 8 | }, 9 | 10 | async refreshToken(): Promise { 11 | const response = await api.post('/auth/refresh'); 12 | return response.data; 13 | }, 14 | 15 | async handleCallback(params: AuthCallbackParams): Promise { 16 | const { provider, code, state } = params; 17 | const response = await api.get(`/auth/${provider}/callback`, { 18 | params: { code, state }, 19 | }); 20 | return response.data; 21 | }, 22 | 23 | async guestLogin(): Promise { 24 | const response = await api.get('/auth/lico/guest'); 25 | return response.data; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /Frontend/src/apis/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { config } from '@/config/env'; 3 | import { useAuthStore } from '@/store/useAuthStore'; 4 | import { RefreshTokenResponse } from '@/types/auth'; 5 | 6 | export const api = axios.create({ 7 | baseURL: config.apiBaseUrl, 8 | timeout: 5000, 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | }, 12 | withCredentials: true, 13 | }); 14 | 15 | api.interceptors.request.use(config => { 16 | const accessToken = useAuthStore.getState().accessToken; 17 | if (accessToken) { 18 | config.headers.Authorization = `Bearer ${accessToken}`; 19 | } 20 | return config; 21 | }); 22 | 23 | api.interceptors.response.use( 24 | response => response, 25 | async error => { 26 | const originalRequest = error.config; 27 | 28 | if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/refresh')) { 29 | originalRequest._retry = true; 30 | 31 | try { 32 | const refreshResponse = await api.post('/auth/refresh'); 33 | const { accessToken, user } = refreshResponse.data; 34 | 35 | useAuthStore.getState().setAuth({ accessToken, user }); 36 | originalRequest.headers.Authorization = `Bearer ${accessToken}`; 37 | 38 | return api(originalRequest); 39 | } catch (refreshError) { 40 | useAuthStore.getState().clearAuth(); 41 | window.location.href = '/login'; 42 | return Promise.reject(refreshError); 43 | } 44 | } 45 | 46 | return Promise.reject(error); 47 | }, 48 | ); 49 | -------------------------------------------------------------------------------- /Frontend/src/apis/category.ts: -------------------------------------------------------------------------------- 1 | import { api } from './axios'; 2 | import type { Category } from '@/types/category'; 3 | import type { Live, LiveParams } from '@/types/live'; 4 | 5 | export const categoryApi = { 6 | getCategories: async () => { 7 | const { data } = await api.get('/categories'); 8 | return data; 9 | }, 10 | 11 | getCategoryById: async (categoryId: string) => { 12 | const { data } = await api.get(`/categories/${categoryId}`); 13 | return data; 14 | }, 15 | 16 | getCategoryLives: async (categoryId: string, params: Omit) => { 17 | const { data } = await api.get(`/categories/${categoryId}/lives`, { 18 | params: { 19 | limit: params.limit, 20 | offset: params.offset, 21 | }, 22 | }); 23 | return data; 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /Frontend/src/apis/chat.ts: -------------------------------------------------------------------------------- 1 | import { api } from './axios'; 2 | 3 | interface SendChatRequest { 4 | channelId: string; 5 | message: string; 6 | } 7 | 8 | export const chatApi = { 9 | sendChat: async ({ channelId, message }: SendChatRequest) => { 10 | const { data } = await api.post('/chats', { 11 | channelId, 12 | message, 13 | }); 14 | return data; 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /Frontend/src/apis/follow.ts: -------------------------------------------------------------------------------- 1 | import { api } from './axios'; 2 | import type { Live } from '@/types/live'; 3 | 4 | export const followApi = { 5 | getFollow: async () => { 6 | const { data } = await api.get('/follow'); 7 | return data; 8 | }, 9 | 10 | follow: async (streamerId: number) => { 11 | const { data } = await api.post(`/follow/${streamerId}`); 12 | return data; 13 | }, 14 | 15 | unfollow: async (streamerId: number) => { 16 | const { data } = await api.delete(`/follow/${streamerId}`); 17 | return data; 18 | }, 19 | 20 | getFollowerCount: async (streamerId: number) => { 21 | const { data } = await api.get(`/follow/count/${streamerId}`); 22 | return data; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /Frontend/src/apis/live.ts: -------------------------------------------------------------------------------- 1 | import { api } from './axios'; 2 | import type { Live, LiveDetail, UpdateLiveRequest, StreamingKeyResponse, LiveStatus, LiveParams } from '@/types/live'; 3 | 4 | export const liveApi = { 5 | getLives: async (params: LiveParams) => { 6 | const { data } = await api.get('/lives', { 7 | params: { 8 | sort: params.sort, 9 | limit: params.limit, 10 | offset: params.offset, 11 | }, 12 | }); 13 | return data; 14 | }, 15 | 16 | getLiveByChannelId: async (channelId: string) => { 17 | const { data } = await api.get(`/lives/${channelId}`); 18 | return data; 19 | }, 20 | 21 | updateLive: async (channelId: string, updateData: UpdateLiveRequest) => { 22 | const { data } = await api.patch(`/lives/${channelId}`, updateData); 23 | return data; 24 | }, 25 | 26 | getStreamingKey: async () => { 27 | const { data } = await api.get('/lives/streaming-key'); 28 | return data; 29 | }, 30 | 31 | getLiveStatus: async (channelId: string) => { 32 | const { data } = await api.get(`/lives/status/${channelId}`); 33 | return data; 34 | }, 35 | 36 | finishLive: async (streamingKey: string) => { 37 | await api.delete(`/lives/onair/${streamingKey}`); 38 | }, 39 | 40 | isOnAir: async (streamingKey: string) => { 41 | const { data } = await api.get(`/lives/onair/${streamingKey}`); 42 | return data; 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /Frontend/src/apis/user.ts: -------------------------------------------------------------------------------- 1 | import { api } from './axios'; 2 | import type { UserProfileResponse } from '@/types/user'; 3 | 4 | export const userApi = { 5 | updateProfile: async (userId: number, formData: FormData) => { 6 | const { data } = await api.put(`/users/${userId}`, formData, { 7 | headers: { 8 | 'Content-Type': 'multipart/form-data', 9 | }, 10 | }); 11 | return data; 12 | }, 13 | 14 | getUserProfile: async (userId: number) => { 15 | const { data } = await api.get(`/users/${userId}`); 16 | return data; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /Frontend/src/assets/fonts/Pretendard-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web26-LICO/1f111bc41979094c15975286486fb8a5db029a5d/Frontend/src/assets/fonts/Pretendard-Bold.woff2 -------------------------------------------------------------------------------- /Frontend/src/assets/fonts/Pretendard-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web26-LICO/1f111bc41979094c15975286486fb8a5db029a5d/Frontend/src/assets/fonts/Pretendard-Medium.woff2 -------------------------------------------------------------------------------- /Frontend/src/assets/icons/eraserCursor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Frontend/src/assets/icons/pencilCursor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Frontend/src/assets/images/offline.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web26-LICO/1f111bc41979094c15975286486fb8a5db029a5d/Frontend/src/assets/images/offline.gif -------------------------------------------------------------------------------- /Frontend/src/components/VideoPlayer/Control/VolumeControl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import { LuVolumeX, LuVolume2 } from 'react-icons/lu'; 3 | 4 | interface VolumeControlProps { 5 | volume: number; 6 | isMuted: boolean; 7 | onVolumeChange: (volume: number) => void; 8 | onMuteToggle: () => void; 9 | onShowControls: () => void; 10 | iconSize: number; 11 | } 12 | 13 | export default function VolumeControl({ 14 | volume, 15 | isMuted, 16 | onVolumeChange, 17 | onMuteToggle, 18 | onShowControls, 19 | iconSize, 20 | }: VolumeControlProps) { 21 | const [showVolumeSlider, setShowVolumeSlider] = useState(false); 22 | const volumeControlRef = useRef(null); 23 | 24 | const handleVolume = (e: React.ChangeEvent) => { 25 | const newVolume = parseFloat(e.target.value) / 10; 26 | onVolumeChange(newVolume); 27 | }; 28 | 29 | return ( 30 |
{ 34 | setShowVolumeSlider(true); 35 | onShowControls(); 36 | }} 37 | > 38 | 47 |
54 | 75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /Frontend/src/components/VideoPlayer/OfflinePlayer.tsx: -------------------------------------------------------------------------------- 1 | import offline from '@assets/images/offline.gif'; 2 | 3 | export default function OfflinePlayer() { 4 | return ( 5 |
6 | {offline} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /Frontend/src/components/category/CategoryCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { FaCircle } from 'react-icons/fa6'; 3 | import { formatUnit } from '@utils/format'; 4 | 5 | export interface CategoryCardProps { 6 | id: number; 7 | name: string; 8 | image: string; 9 | totalViewers: number; 10 | totalLives: number; 11 | } 12 | 13 | export default function CategoryCard({ id, name, image, totalViewers, totalLives }: CategoryCardProps) { 14 | const navigate = useNavigate(); 15 | 16 | const handleClick = () => { 17 | navigate(`/category/${id}`); 18 | }; 19 | 20 | return ( 21 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /Frontend/src/components/category/CategoryGrid/index.tsx: -------------------------------------------------------------------------------- 1 | import CategoryCard, { CategoryCardProps } from '@components/category/CategoryCard'; 2 | 3 | type CategoryGridProps = { 4 | categories: CategoryCardProps[]; 5 | }; 6 | 7 | export default function CategoryGrid({ categories }: CategoryGridProps) { 8 | return ( 9 |
10 |
    11 | {categories.map(category => ( 12 |
  • 13 | 14 |
  • 15 | ))} 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /Frontend/src/components/channel/ChannelCard/ChannelInfo.tsx: -------------------------------------------------------------------------------- 1 | import CategoryBadge from '@components/common/Badges/CategoryBadge'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | interface ChannelInfoProps { 5 | id: string; 6 | title: string; 7 | streamerName: string; 8 | category: string; 9 | categoryId: number; 10 | profileImgUrl: string; 11 | } 12 | 13 | export default function ChannelInfo({ 14 | id, 15 | title, 16 | streamerName, 17 | category, 18 | categoryId, 19 | profileImgUrl, 20 | }: ChannelInfoProps) { 21 | return ( 22 |
23 | 24 | {streamerName} 25 | 26 |
27 | 28 |

{title}

29 |

{streamerName}

30 | 31 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /Frontend/src/components/channel/ChannelCard/ChannelThumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Badge from '@components/common/Badges/Badge'; 2 | import { formatNumber } from '@utils/format'; 3 | import { useState, useEffect } from 'react'; 4 | import ThumbnailSkeleton from '@components/channel/ChannelCard/ThumbnailSkeleton'; 5 | 6 | interface ChannelThumbnailProps { 7 | title: string; 8 | thumbnailUrl: string; 9 | viewers: number; 10 | } 11 | 12 | const maxRetries = 5; 13 | const retryDelay = 2000; 14 | 15 | function ChannelThumbnail({ title, thumbnailUrl, viewers }: ChannelThumbnailProps) { 16 | const [isLoading, setIsLoading] = useState(true); // 초기값을 true로 변경 17 | const [imgError, setImgError] = useState(false); 18 | const [retryCount, setRetryCount] = useState(0); 19 | 20 | useEffect(() => { 21 | let timeoutId: NodeJS.Timeout; 22 | 23 | if (imgError && retryCount < maxRetries) { 24 | setIsLoading(true); 25 | timeoutId = setTimeout(() => { 26 | setImgError(false); 27 | setRetryCount(prev => prev + 1); 28 | }, retryDelay); 29 | } 30 | 31 | return () => { 32 | if (timeoutId) { 33 | clearTimeout(timeoutId); 34 | } 35 | }; 36 | }, [imgError, retryCount]); 37 | 38 | const handleImageLoad = () => { 39 | setIsLoading(false); 40 | setImgError(false); 41 | }; 42 | 43 | const handleImageError = () => { 44 | setImgError(true); 45 | if (retryCount >= maxRetries) { 46 | setIsLoading(false); 47 | } 48 | }; 49 | 50 | return ( 51 |
52 | {isLoading && } 53 | {title} 61 | {!isLoading && !imgError && ( 62 |
63 | 64 | 68 |
69 | )} 70 | {imgError && retryCount >= maxRetries && ( 71 |
72 |

이미지를 불러올 수 없습니다

73 |
74 | )} 75 |
76 | ); 77 | } 78 | 79 | export default ChannelThumbnail; 80 | -------------------------------------------------------------------------------- /Frontend/src/components/channel/ChannelCard/HoverPreviewPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import { config } from '@config/env.ts'; 3 | import { HLSController } from '../../../../lib/controller/HLSController'; 4 | 5 | interface HoverPreviewPlayerProps { 6 | channelId: string; 7 | } 8 | 9 | export default function HoverPreviewPlayer({ channelId }: HoverPreviewPlayerProps) { 10 | const videoRef = useRef(null); 11 | const playerRef = useRef(null); 12 | const streamUrl = `${config.storageUrl}/${channelId}/index.m3u8`; 13 | 14 | useEffect(() => { 15 | const videoElement = videoRef.current; 16 | if (!videoElement) return; 17 | 18 | playerRef.current = new HLSController({ 19 | videoElement, 20 | liveRefreshInterval: 3000, 21 | }); 22 | 23 | const initStream = async () => { 24 | try { 25 | await playerRef.current?.loadStream(streamUrl); 26 | } catch (error) { 27 | console.error('Failed to initialize stream:', error); 28 | } 29 | }; 30 | 31 | initStream(); 32 | 33 | return () => { 34 | if (playerRef.current) { 35 | playerRef.current.destroy(); 36 | playerRef.current = null; 37 | } 38 | }; 39 | }, [streamUrl, channelId]); 40 | 41 | return