├── .editorconfig
├── .gitattributes
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── 기능-구현.md
│ └── 문제-사항-보고.md
├── pull_request_template.md
└── workflows
│ ├── docker-image.yml
│ └── scripts
│ └── deploy.sh
├── .gitignore
├── .prettierrc
├── .yarn
└── releases
│ └── yarn-4.5.1.cjs
├── .yarnrc.yml
├── README.md
├── client
├── .gitignore
├── .storybook
│ ├── main.ts
│ └── preview.ts
├── Dockerfile
├── README.md
├── index.html
├── package.json
├── postcss.config.js
├── public
│ ├── favicon.ico
│ ├── mockServiceWorker.js
│ └── vite.svg
├── src
│ ├── App.tsx
│ ├── GlobalBoundary.tsx
│ ├── Layout.tsx
│ ├── NetworkBoundary.tsx
│ ├── app
│ │ └── router
│ │ │ ├── ProtectedRoute.tsx
│ │ │ ├── Router.tsx
│ │ │ ├── config.ts
│ │ │ ├── index.ts
│ │ │ └── routes.tsx
│ ├── assets
│ │ ├── default-banner.webp
│ │ ├── github-logo.png
│ │ ├── logo-album-cover.png
│ │ ├── logo-album-cover.webp
│ │ └── react.svg
│ ├── entities
│ │ ├── album
│ │ │ └── types.ts
│ │ ├── albumRegister
│ │ │ └── types.ts
│ │ ├── comment
│ │ │ └── types.ts
│ │ ├── message
│ │ │ └── types.ts
│ │ ├── room
│ │ │ └── types.ts
│ │ └── songDetail
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ ├── LyricsPanel.css
│ │ │ ├── LyricsPanel.tsx
│ │ │ ├── PlaylistPanel.css
│ │ │ ├── PlaylistPanel.tsx
│ │ │ ├── PlaylistPanelCredit.tsx
│ │ │ └── PlaylistPanelSong.tsx
│ ├── features
│ │ ├── albumRegister
│ │ │ ├── index.ts
│ │ │ ├── model
│ │ │ │ └── useAlbumForm.ts
│ │ │ └── ui
│ │ │ │ ├── AlbumForm.tsx
│ │ │ │ └── SongForm.tsx
│ │ ├── albumStreaming
│ │ │ ├── hook
│ │ │ │ ├── constants.ts
│ │ │ │ └── useStreamingPlayer.ts
│ │ │ ├── index.ts
│ │ │ └── model
│ │ │ │ └── types.ts
│ │ ├── chattingInput
│ │ │ ├── index.ts
│ │ │ ├── model
│ │ │ │ └── sendMessage.ts
│ │ │ └── ui
│ │ │ │ └── ChatInput.tsx
│ │ ├── commentInput
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ │ └── CommentInput.tsx
│ │ ├── songDetail
│ │ │ ├── index.ts
│ │ │ ├── model
│ │ │ │ └── types.ts
│ │ │ └── ui
│ │ │ │ ├── CategoryButton.tsx
│ │ │ │ ├── SongDetail.tsx
│ │ │ │ ├── SongDetailContent.tsx
│ │ │ │ └── SongDetailHeader.tsx
│ │ └── useCounter
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ └── UserCounter.tsx
│ ├── index.css
│ ├── main.tsx
│ ├── pages
│ │ ├── AdminLoginPage
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ │ └── AdminLoginPage.tsx
│ │ ├── AdminPage
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ │ └── AdminPage.tsx
│ │ ├── AlbumPage
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ │ └── AlbumPage.tsx
│ │ ├── MainPage
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ │ └── MainPage.tsx
│ │ ├── StreamingErrorPage
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ │ └── StreamingErrorPage.tsx
│ │ └── StreamingPage
│ │ │ ├── index.ts
│ │ │ └── ui
│ │ │ ├── Notice.tsx
│ │ │ ├── Standby.tsx
│ │ │ └── StreamingPage.tsx
│ ├── reset.css
│ ├── shared
│ │ ├── api
│ │ │ ├── adminAPI.ts
│ │ │ ├── errorMessage.ts
│ │ │ ├── publicAPI.ts
│ │ │ └── socket.ts
│ │ ├── hook
│ │ │ ├── useSocketEvents.ts
│ │ │ └── useStreamingRoom.ts
│ │ ├── icon
│ │ │ ├── ChevronDown.tsx
│ │ │ ├── InearLogo.tsx
│ │ │ ├── Person.tsx
│ │ │ ├── PlayIcon.tsx
│ │ │ ├── SendIcon.tsx
│ │ │ ├── Volume.tsx
│ │ │ └── VolumeMuted.tsx
│ │ ├── lottie
│ │ │ └── music.json
│ │ ├── store
│ │ │ ├── state
│ │ │ │ ├── chatState.ts
│ │ │ │ ├── socketState.ts
│ │ │ │ └── voteState.ts
│ │ │ ├── useChatMessageStore.ts
│ │ │ ├── useSocketStore.ts
│ │ │ ├── useVoteStore.ts
│ │ │ └── utils
│ │ │ │ └── socketEvents.ts
│ │ ├── ui
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Timer.tsx
│ │ │ └── index.ts
│ │ └── util
│ │ │ └── timeUtils.ts
│ ├── vite-env.d.ts
│ └── widgets
│ │ ├── albums
│ │ ├── index.ts
│ │ └── ui
│ │ │ ├── AlbumArtist.tsx
│ │ │ ├── AlbumCard.tsx
│ │ │ ├── AlbumList.tsx
│ │ │ ├── Comment.tsx
│ │ │ ├── CommentList.tsx
│ │ │ ├── Playlist.tsx
│ │ │ ├── Scrollbar.css
│ │ │ └── TrackItem.tsx
│ │ ├── banner
│ │ ├── index.ts
│ │ └── ui
│ │ │ ├── Banner.css
│ │ │ ├── Banner.tsx
│ │ │ └── BannerSlide.tsx
│ │ ├── chatting
│ │ ├── hook
│ │ │ └── useChatMessage.ts
│ │ ├── index.ts
│ │ ├── ui
│ │ │ ├── Chatting.css
│ │ │ ├── Chatting.tsx
│ │ │ ├── ChattingContainer.tsx
│ │ │ ├── Message.tsx
│ │ │ └── MessageList.tsx
│ │ └── useChatMessage.ts
│ │ ├── sidebar
│ │ ├── hook
│ │ │ └── useSidebarAlbum.ts
│ │ ├── index.ts
│ │ └── ui
│ │ │ ├── Credit.tsx
│ │ │ ├── RoomList.tsx
│ │ │ ├── RoomListItem.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── StreamingList.css
│ │ ├── streaming
│ │ ├── index.ts
│ │ └── ui
│ │ │ ├── AlbumBackground.tsx
│ │ │ ├── AlbumInfo.tsx
│ │ │ ├── AudioController.tsx
│ │ │ ├── Streaming.tsx
│ │ │ └── Volume.css
│ │ └── vote
│ │ ├── index.ts
│ │ ├── ui
│ │ ├── ScrollBar.css
│ │ └── Vote.tsx
│ │ └── useVote.ts
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── docker-compose.yml
├── nginx
├── Dockerfile
├── conf.d
│ └── default.conf
└── nginx.conf
├── package.json
├── server
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarn
│ └── install-state.gz
├── Dockerfile
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── public
│ ├── hls-test.html
│ └── socket-test.html
├── socket-test.yml
├── src
│ ├── admin
│ │ ├── admin.controller.ts
│ │ ├── admin.guard.ts
│ │ ├── admin.module.ts
│ │ ├── admin.redis.repository.ts
│ │ ├── admin.service.ts
│ │ ├── admin.transaction.service.ts
│ │ └── dto
│ │ │ ├── album.dto.ts
│ │ │ └── song.dto.ts
│ ├── album
│ │ ├── album.controller.ts
│ │ ├── album.entity.ts
│ │ ├── album.module.ts
│ │ ├── album.redis.repository.ts
│ │ ├── album.repository.ts
│ │ ├── album.service.ts
│ │ └── dto
│ │ │ ├── album-detail-response.dto.ts
│ │ │ ├── album-response.dto.ts
│ │ │ ├── ended-album-response.dto.ts
│ │ │ ├── main-banner-response.dto.ts
│ │ │ └── side-bar-response.dto.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── comment
│ │ ├── comment.controller.ts
│ │ ├── comment.entity.ts
│ │ ├── comment.module.ts
│ │ ├── comment.repository.ts
│ │ ├── comment.service.ts
│ │ └── dto
│ │ │ └── album-comment-response.dto.ts
│ ├── common
│ │ ├── common.module.ts
│ │ ├── constants
│ │ │ └── repository.constant.ts
│ │ ├── exceptions
│ │ │ ├── base.exception.ts
│ │ │ ├── domain
│ │ │ │ ├── album
│ │ │ │ │ ├── album-creation-fail.exception.ts
│ │ │ │ │ ├── album-not-found-by-timestamp.exception.ts
│ │ │ │ │ └── album-not-found.exception.ts
│ │ │ │ ├── room
│ │ │ │ │ ├── room-inactive.exception.ts
│ │ │ │ │ ├── room-is-full.exception.ts
│ │ │ │ │ ├── room-not-found.exception.ts
│ │ │ │ │ ├── user-not-in-room.exception.ts
│ │ │ │ │ └── user-room-info-not-found.exception.ts
│ │ │ │ ├── song
│ │ │ │ │ └── missing-song-files.exception.ts
│ │ │ │ └── vote
│ │ │ │ │ └── already-vote-this-room.exception.ts
│ │ │ ├── global-exception.filter.ts
│ │ │ └── ws-exception.filter.ts
│ │ ├── logger
│ │ │ ├── logger-context.middleware.ts
│ │ │ └── logger.config.ts
│ │ ├── randomname
│ │ │ └── random-name.util.ts
│ │ ├── redis
│ │ │ ├── redis.module.ts
│ │ │ └── redis.provider.ts
│ │ ├── s3Cache
│ │ │ └── s3Cache.service.ts
│ │ └── scheduler
│ │ │ └── scheduler.service.ts
│ ├── emoji
│ │ ├── dto
│ │ │ └── emoji-request.dto.ts
│ │ ├── emoji.controller.ts
│ │ ├── emoji.module.ts
│ │ └── emoji.service.ts
│ ├── main.ts
│ ├── music
│ │ ├── music.controller.ts
│ │ ├── music.module.ts
│ │ ├── music.processor.ts
│ │ ├── music.repository.ts
│ │ ├── music.service.ts
│ │ └── parser
│ │ │ └── m3u8-parser.ts
│ ├── room
│ │ ├── room.constant.ts
│ │ ├── room.controller.ts
│ │ ├── room.entity.ts
│ │ ├── room.gateway.ts
│ │ ├── room.module.ts
│ │ ├── room.repository.ts
│ │ └── room.service.ts
│ └── song
│ │ ├── dto
│ │ ├── song-response.dto.ts
│ │ └── song-save.dto.ts
│ │ ├── song.entity.ts
│ │ ├── song.module.ts
│ │ └── song.repository.ts
├── test
│ ├── admin.integration.spec.ts
│ ├── config
│ │ └── typeorm.config.ts
│ ├── jest-e2e.json
│ └── mock
│ │ └── album.mock.ts
├── tsconfig.build.json
└── tsconfig.json
├── tsconfig.json
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js,json,yml}]
8 | charset = utf-8
9 | indent_style = space
10 | indent_size = 2
11 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/** linguist-vendored
2 | /.yarn/releases/* binary
3 | /.yarn/plugins/**/* binary
4 | /.pnp.* binary linguist-generated
5 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @rdyjun @Kontae @yoonseo-han @chaeryeon823
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/기능-구현.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 기능 구현
3 | about: 구현한(할) 기능에 대한 이슈
4 | title: "[BE/FE] 구현"
5 | labels: "\U0001F680 feature"
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## ✨ 기능에 대한 기대
11 |
12 | ## 🔗 관련 링크
13 |
14 | ## 📋 구현/작업 목록
15 |
16 | ## 📆 구현 계획
17 |
18 | ## ✅ 결과
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/문제-사항-보고.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 문제 사항 보고
3 | about: 발생한 문제에 대한 정리
4 | title: "[BUG] 문제"
5 | labels: "\U0001F47D bug"
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 🐞 문제 사항
11 |
12 | ## 🔗 원인
13 |
14 | ## 📋 해결 과정
15 |
16 | ## ✅ 결과
17 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## 📋개요
2 |
3 | ## 🕰️예상 리뷰시간
4 |
5 | ## 📢상세내용
6 |
7 | ## 💥특이사항
--------------------------------------------------------------------------------
/.github/workflows/scripts/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # nginx 설정 업데이트 및 리로드 함수
4 | update_and_reload_nginx() {
5 | local target=$1
6 | echo "Updating nginx configuration for $target environment..."
7 |
8 | # 임시 설정 파일 생성
9 | cat > nginx/conf.d/default.conf << EOF
10 | upstream backend {
11 | server server-$target:3000;
12 | }
13 |
14 | server {
15 | listen 80;
16 | server_name localhost;
17 |
18 | location / {
19 | proxy_pass http://client:5173;
20 | proxy_http_version 1.1;
21 | proxy_set_header Upgrade \$http_upgrade;
22 | proxy_set_header Connection 'upgrade';
23 | proxy_set_header Host \$host;
24 | }
25 |
26 | location /api {
27 | proxy_pass http://backend;
28 | proxy_http_version 1.1;
29 | proxy_set_header Host \$host;
30 | proxy_set_header Upgrade \$http_upgrade;
31 | proxy_set_header Connection 'upgrade';
32 | }
33 | }
34 | EOF
35 |
36 | # nginx 컨테이너 재시작
37 | echo "Restarting nginx to apply new configuration..."
38 | docker restart nginx
39 |
40 | # 설정이 제대로 적용되었는지 확인
41 | sleep 2
42 | docker exec nginx nginx -t
43 | }
44 |
45 | # Blue에서 Green으로 전환
46 | switch_to_green() {
47 | echo "Deploying Green environment..."
48 |
49 | cd
50 | cd web18-inear
51 |
52 | # Green 환경 시작
53 | docker compose -f docker-compose-green.yml up -d
54 |
55 | # nginx 설정 업데이트 및 리로드
56 | update_and_reload_nginx "green"
57 |
58 | echo "Stopping Blue server..."
59 | if docker ps -q --filter name=server-blue > /dev/null; then
60 | docker compose -f docker-compose-blue.yml stop server-blue
61 | docker compose -f docker-compose-blue.yml rm -f server-blue
62 | fi
63 | }
64 | # Green에서 Blue로 전환
65 | switch_to_blue() {
66 | echo "Deploying Blue environment..."
67 |
68 | cd
69 | cd web18-inear
70 |
71 | # Blue 환경 시작
72 | docker compose -f docker-compose-blue.yml up -d
73 |
74 | # nginx 설정 업데이트 및 리로드
75 | update_and_reload_nginx "blue"
76 |
77 | echo "Stopping Green server..."
78 | if docker ps -q --filter name=server-green > /dev/null; then
79 | docker compose -f docker-compose-green.yml stop server-green
80 | docker compose -f docker-compose-green.yml rm -f server-green
81 | fi
82 | }
83 |
84 | # 현재 실행 중인 환경 확인 및 전환
85 | if [ -n "$(docker ps -q --filter name=blue)" ]; then
86 | switch_to_green
87 | echo "switch_to_green 실행"
88 | else
89 | switch_to_blue
90 | echo "switch_to_blue 실행"
91 | fi
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | server/node_modules
14 |
15 | eslint.config.js
16 |
17 | server/.yarn
18 |
19 | server/music*
20 |
21 | package-lock.json
22 |
23 | # Runtime data
24 | pids
25 | *.pid
26 | *.seed
27 | *.pid.lock
28 |
29 | # Directory for instrumented libs generated by jscoverage/JSCover
30 | lib-cov
31 |
32 | # Coverage directory used by tools like istanbul
33 | coverage
34 | *.lcov
35 |
36 | # nyc test coverage
37 | .nyc_output
38 |
39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
40 | .grunt
41 |
42 | # Bower dependency directory (https://bower.io/)
43 | bower_components
44 |
45 | # node-waf configuration
46 | .lock-wscript
47 |
48 | # Compiled binary addons (https://nodejs.org/api/addons.html)
49 | build/Release
50 |
51 | # Dependency directories
52 | node_modules/
53 | jspm_packages/
54 |
55 | # Snowpack dependency directory (https://snowpack.dev/)
56 | web_modules/
57 |
58 | # TypeScript cache
59 | *.tsbuildinfo
60 |
61 | # Optional npm cache directory
62 | .npm
63 |
64 | # Optional eslint cache
65 | .eslintcache
66 |
67 | # Optional stylelint cache
68 | .stylelintcache
69 |
70 | # Microbundle cache
71 | .rpt2_cache/
72 | .rts2_cache_cjs/
73 | .rts2_cache_es/
74 | .rts2_cache_umd/
75 |
76 | # Optional REPL history
77 | .node_repl_history
78 |
79 | # Output of 'npm pack'
80 | *.tgz
81 |
82 | # Yarn Integrity file
83 | .yarn-integrity
84 |
85 | # dotenv environment variable files
86 | .env
87 | .env.development.local
88 | .env.test.local
89 | .env.production.local
90 | .env.local
91 |
92 | # parcel-bundler cache (https://parceljs.org/)
93 | .cache
94 | .parcel-cache
95 |
96 | # Next.js build output
97 | .next
98 | out
99 |
100 | # Nuxt.js build / generate output
101 | .nuxt
102 | dist
103 |
104 | # Gatsby files
105 | .cache/
106 | # Comment in the public line in if your project uses Gatsby and not Next.js
107 | # https://nextjs.org/blog/next-9-1#public-directory-support
108 | # public
109 |
110 | # vuepress build output
111 | .vuepress/dist
112 |
113 | # vuepress v2.x temp and cache directory
114 | .temp
115 | .cache
116 |
117 | # Docusaurus cache and generated files
118 | .docusaurus
119 |
120 | # Serverless directories
121 | .serverless/
122 |
123 | # FuseBox cache
124 | .fusebox/
125 |
126 | # DynamoDB Local files
127 | .dynamodb/
128 |
129 | # TernJS port file
130 | .tern-port
131 |
132 | # Stores VSCode versions used for testing VSCode extensions
133 | .vscode-test
134 |
135 | # yarn v2
136 | .yarn/cache
137 | .yarn/unplugged
138 | .yarn/build-state.yml
139 | .yarn/install-state.gz
140 | .pnp.*
141 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "arrowParents": "avoid",
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "tabWidth": 2,
7 | "semi": true,
8 | "useTabs": false
9 | }
10 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | enableGlobalCache: true
2 |
3 | nodeLinker: node-modules
4 |
5 | yarnPath: .yarn/releases/yarn-4.5.1.cjs
6 |
--------------------------------------------------------------------------------
/client/.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 | *storybook.log
27 | src/stories/*
28 |
29 | .env.development
30 | .env.production
--------------------------------------------------------------------------------
/client/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/react-vite';
2 |
3 | import { join, dirname } from 'path';
4 |
5 | /**
6 | * This function is used to resolve the absolute path of a package.
7 | * It is needed in projects that use Yarn PnP or are set up within a monorepo.
8 | */
9 | function getAbsolutePath(value: string): any {
10 | return dirname(require.resolve(join(value, 'package.json')));
11 | }
12 | const config: StorybookConfig = {
13 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
14 | addons: [
15 | getAbsolutePath('@storybook/addon-onboarding'),
16 | getAbsolutePath('@storybook/addon-essentials'),
17 | getAbsolutePath('@chromatic-com/storybook'),
18 | getAbsolutePath('@storybook/addon-interactions'),
19 | ],
20 | framework: {
21 | name: getAbsolutePath('@storybook/react-vite'),
22 | options: {},
23 | },
24 | };
25 | export default config;
26 |
--------------------------------------------------------------------------------
/client/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react';
2 | import '@/index.css';
3 | const preview: Preview = {
4 | parameters: {
5 | controls: {
6 | matchers: {
7 | color: /(background|color)$/i,
8 | date: /Date$/i,
9 | },
10 | },
11 | },
12 | };
13 |
14 | export default preview;
15 |
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine
2 |
3 | WORKDIR /app
4 |
5 | COPY package.json yarn.lock .yarnrc.yml ./
6 | COPY .yarn .yarn
7 | COPY client/ client/
8 |
9 | RUN corepack enable && \
10 | yarn install
11 |
12 | WORKDIR /app/client
13 |
14 | EXPOSE 5173
15 | CMD ["yarn", "run", "dev", "--no-open", "--host"]
16 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default tseslint.config({
18 | languageOptions: {
19 | // other options...
20 | parserOptions: {
21 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
22 | tsconfigRootDir: import.meta.dirname,
23 | },
24 | },
25 | })
26 | ```
27 |
28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29 | - Optionally add `...tseslint.configs.stylisticTypeChecked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31 |
32 | ```js
33 | // eslint.config.js
34 | import react from 'eslint-plugin-react'
35 |
36 | export default tseslint.config({
37 | // Set the react version
38 | settings: { react: { version: '18.3' } },
39 | plugins: {
40 | // Add the react plugin
41 | react,
42 | },
43 | rules: {
44 | // other rules...
45 | // Enable its recommended rules
46 | ...react.configs.recommended.rules,
47 | ...react.configs['jsx-runtime'].rules,
48 | },
49 | })
50 | ```
51 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | inear
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.0.1",
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "storybook": "storybook dev -p 6006",
10 | "build-storybook": "storybook build"
11 | },
12 | "dependencies": {
13 | "react": "^18.2.0",
14 | "react-dom": "^18.2.0",
15 | "socket.io-client": "^4.8.1"
16 | },
17 | "devDependencies": {
18 | "@chromatic-com/storybook": "^3.2.2",
19 | "@storybook/addon-essentials": "^8.4.2",
20 | "@storybook/addon-interactions": "^8.4.2",
21 | "@storybook/addon-onboarding": "^8.4.2",
22 | "@storybook/blocks": "^8.4.2",
23 | "@storybook/react": "^8.4.2",
24 | "@storybook/react-vite": "^8.4.2",
25 | "@storybook/test": "^8.4.2",
26 | "@tanstack/eslint-plugin-query": "^5.61.4",
27 | "@tanstack/react-query": "^5.61.5",
28 | "@tanstack/react-query-devtools": "^5.62.0",
29 | "@types/react": "^18.2.0",
30 | "@types/react-dom": "^18.2.0",
31 | "@vitejs/plugin-react": "^4.3.3",
32 | "autoprefixer": "^10.4.20",
33 | "axios": "^1.7.7",
34 | "fast-average-color": "^9.4.0",
35 | "hls.js": "^1.5.17",
36 | "lottie-react": "^2.4.0",
37 | "msw": "^2.6.1",
38 | "postcss": "^8.4.47",
39 | "react-error-boundary": "^4.1.2",
40 | "react-router-dom": "^6.28.0",
41 | "storybook": "^8.4.2",
42 | "swiper": "^11.1.15",
43 | "tailwindcss": "^3.4.14",
44 | "typescript": "^5.0.0",
45 | "vite": "^5.0.0",
46 | "vite-tsconfig-paths": "^5.1.0",
47 | "zustand": "^5.0.1"
48 | },
49 | "packageManager": "yarn@4.5.1",
50 | "msw": {
51 | "workerDirectory": [
52 | "public"
53 | ]
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet, Route, Routes } from 'react-router-dom';
2 | import { MainPage } from '@/pages/MainPage';
3 | import { StreamingPage } from '@/pages/StreamingPage';
4 | import { AdminPage } from '@/pages/AdminPage';
5 | import { AlbumPage } from '@/pages/AlbumPage';
6 | import { AdminLoginPage } from '@/pages/AdminLoginPage';
7 | import { ProtectedRoute } from '@/app/router/ProtectedRoute';
8 | import { Sidebar } from './widgets/sidebar/ui/Sidebar';
9 | import { GlobalBoundary } from './GlobalBoundary';
10 |
11 | const MainLayout = () => (
12 |
18 | );
19 |
20 | export function App() {
21 | return (
22 |
23 |
24 | }>
25 | } />
26 | } />
27 | } />
28 |
29 | } />
30 |
34 |
35 |
36 | }
37 | />
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/client/src/GlobalBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
2 | import { Suspense } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 | import { Button } from './shared/ui';
5 |
6 | export const GlobalErrorFallback = ({
7 | error,
8 | resetErrorBoundary,
9 | }: FallbackProps) => {
10 | const navigate = useNavigate();
11 | const navigateToMain = () => {
12 | navigate('/');
13 | resetErrorBoundary();
14 | };
15 | return (
16 |
17 |
에러가 발생했습니다.
18 |
{error.message}
19 |
20 |
21 | );
22 | };
23 |
24 | export const GlobalBoundary = ({ children }: { children: React.ReactNode }) => {
25 | return (
26 |
27 | 로딩중...}>{children}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/client/src/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router-dom';
2 | import { Sidebar } from './widgets/sidebar/ui/Sidebar';
3 |
4 | export function Layout() {
5 | return (
6 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/NetworkBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
2 | import { Suspense } from 'react';
3 | import { QueryErrorResetBoundary } from '@tanstack/react-query';
4 | import { Button } from './shared/ui';
5 |
6 | export const NetworkFallback = ({
7 | error,
8 | resetErrorBoundary,
9 | }: FallbackProps) => {
10 | const handleClickReset = () => {
11 | resetErrorBoundary();
12 | };
13 | return (
14 |
15 |
이 에러엔 슬픈 전설이 있어
16 |
{error.message}
17 | {/*
*/}
18 |
19 | );
20 | };
21 |
22 | export const NetworkBoundary = ({
23 | children,
24 | }: {
25 | children: React.ReactNode;
26 | }) => {
27 | return (
28 |
29 | {({ reset }) => (
30 |
31 | 로딩중...}
33 | >
34 | {children}
35 |
36 |
37 | )}
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/app/router/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, useLocation } from 'react-router-dom';
2 | import { useEffect, useState } from 'react';
3 | import axios from 'axios';
4 |
5 | export function ProtectedRoute({ children }: { children: React.ReactNode }) {
6 | const [isAuthenticated, setIsAuthenticated] = useState(null);
7 | const location = useLocation();
8 |
9 | useEffect(() => {
10 | const checkAuth = async () => {
11 | try {
12 | await axios.get(
13 | `${import.meta.env.VITE_API_URL}/api/admin/verify-token`,
14 | {
15 | withCredentials: true,
16 | },
17 | );
18 | setIsAuthenticated(true);
19 | } catch (error) {
20 | setIsAuthenticated(false);
21 | }
22 | };
23 |
24 | checkAuth();
25 | }, []);
26 |
27 | if (isAuthenticated === null) {
28 | return Loading...
;
29 | }
30 |
31 | if (!isAuthenticated) {
32 | return ;
33 | }
34 |
35 | return <>{children}>;
36 | }
37 |
--------------------------------------------------------------------------------
/client/src/app/router/Router.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2 | import { routes } from './routes';
3 | import { routerConfig } from './config';
4 |
5 | const router = createBrowserRouter(routes, routerConfig);
6 |
7 | export function Router() {
8 | return (
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/app/router/config.ts:
--------------------------------------------------------------------------------
1 | export const routerConfig = {
2 | future: {
3 | v7_fetcherPersist: true,
4 | v7_normalizeFormMethod: true,
5 | v7_partialHydration: true,
6 | v7_relativeSplatPath: true,
7 | v7_skipActionErrorRevalidation: true,
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/client/src/app/router/index.ts:
--------------------------------------------------------------------------------
1 | export { Router } from './Router';
2 |
--------------------------------------------------------------------------------
/client/src/app/router/routes.tsx:
--------------------------------------------------------------------------------
1 | import { MainPage } from '@/pages/MainPage';
2 | import { StreamingPage } from '@/pages/StreamingPage';
3 | import { Layout } from '@/Layout';
4 | import { AdminPage } from '@/pages/AdminPage';
5 | import { AdminLoginPage } from '@/pages/AdminLoginPage';
6 | import { ProtectedRoute } from '@/app/router/ProtectedRoute';
7 | import { AlbumPage } from '@/pages/AlbumPage';
8 |
9 | export const routes = [
10 | {
11 | path: '/',
12 | element: ,
13 | children: [
14 | {
15 | path: '/',
16 | element: ,
17 | },
18 | {
19 | path: '/streaming/:roomId',
20 | element: ,
21 | },
22 | {
23 | path: '/album/:albumId',
24 | element: ,
25 | },
26 | ],
27 | },
28 | {
29 | path: '/admin/login',
30 | element: ,
31 | },
32 | {
33 | path: '/admin',
34 | element: (
35 |
36 |
37 |
38 | ),
39 | },
40 | ];
41 |
--------------------------------------------------------------------------------
/client/src/assets/default-banner.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/src/assets/default-banner.webp
--------------------------------------------------------------------------------
/client/src/assets/github-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/src/assets/github-logo.png
--------------------------------------------------------------------------------
/client/src/assets/logo-album-cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/src/assets/logo-album-cover.png
--------------------------------------------------------------------------------
/client/src/assets/logo-album-cover.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/src/assets/logo-album-cover.webp
--------------------------------------------------------------------------------
/client/src/entities/album/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 스트리밍 페이지에서 사용되는 앨범 정보
3 | */
4 |
5 | export interface RoomResponse {
6 | success: boolean;
7 | albumResponse: AlbumData;
8 | songResponseList: SongData[];
9 | totalDuration: number;
10 | trackOrder: string;
11 | }
12 |
13 | export interface AlbumData {
14 | id: string;
15 | title: string;
16 | artist: string;
17 | tags: string;
18 | bannerUrl: string | null;
19 | jacketUrl: string | null;
20 | releaseDate: string;
21 | }
22 |
23 | export interface SongData {
24 | id: number;
25 | albumId: string;
26 | title: string;
27 | trackNumber: number;
28 | lyrics: string;
29 | composer: string;
30 | writer: string;
31 | producer: string;
32 | instrument: string;
33 | source: string;
34 | duration: number;
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/entities/albumRegister/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 관리자 페이지에서 앨범 등록을 위한 요청을 보낼 때 사용하는 타입
3 | */
4 | export interface CreateAlbumRequest {
5 | title: string;
6 | artist: string;
7 | albumTag: string;
8 | releaseDate: string;
9 | songs?: Song[];
10 | }
11 |
12 | export interface AlbumImageRequest {
13 | albumCover: any;
14 | bannerCover?: any;
15 | }
16 |
17 | /**
18 | * 앨범에 포함된 곡 정보
19 | */
20 | export interface Song {
21 | title: string;
22 | trackNumber: string;
23 | lyrics?: string;
24 | composer: string;
25 | writer: string;
26 | instrument: string;
27 | producer: string;
28 | source?: string;
29 | }
30 |
31 | export interface SongFilesRequest {
32 | files: any[];
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/entities/comment/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 앨범 상세 페이지 댓글
3 | */
4 | export interface CommentData {
5 | albumId: string;
6 | content: string;
7 | createdAt: string;
8 | }
9 |
10 | /**
11 | * 앨범 상세 페이지 서버 데이터
12 | */
13 | export interface AlbumDetailResponse {
14 | albumDetails: AlbumDetailData;
15 | songDetails: SongDetailData[];
16 | }
17 |
18 | /**
19 | * 앨범 상세 앨범 정보
20 | */
21 | export interface AlbumDetailData {
22 | albumId: string;
23 | albumName: string;
24 | artist: string;
25 | jacketUrl: string;
26 | }
27 |
28 | /**
29 | * 앨범 상세 페이지 노래 정보
30 | */
31 | export interface SongDetailData {
32 | songName: string;
33 | songDuration: string;
34 | }
35 |
--------------------------------------------------------------------------------
/client/src/entities/message/types.ts:
--------------------------------------------------------------------------------
1 | export interface MessageData {
2 | userName: string;
3 | message: string;
4 | userId: string;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/entities/room/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 메인 페이지 사이드바 앨범 정보
3 | * 앨범의 간단한 정보만을 담고 있음
4 | */
5 | export interface SidebarListResponse {
6 | streamingAlbums: AlbumData[];
7 | upComingAlbums: AlbumData[];
8 | }
9 | export interface AlbumData {
10 | albumId: number;
11 | albumName: string;
12 | albumTags?: string;
13 | }
14 |
15 | /**
16 | * 메인 페이지 배너 정보
17 | */
18 | export interface bannerData {
19 | albumId: string;
20 | albumName: string;
21 | albumTags?: string;
22 | artist: string;
23 | bannerImageUrl: string;
24 | currentUserCount: number;
25 | releaseDate: string;
26 | }
27 |
28 | /**
29 | * 메인 페이지 최근 스트리밍 앨범 정보
30 | */
31 |
32 | export interface EndedAlbumListResponse {
33 | endedAlbums: EndedAlbumData[];
34 | }
35 |
36 | export interface EndedAlbumData {
37 | albumId: string;
38 | albumName: string;
39 | artist: string;
40 | albumTags?: string;
41 | jacketUrl?: string;
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/index.ts:
--------------------------------------------------------------------------------
1 | export { LyricsPanel } from './ui/LyricsPanel';
2 | export { PlaylistPanel } from './ui/PlaylistPanel';
3 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/ui/LyricsPanel.css:
--------------------------------------------------------------------------------
1 | .lyrics::-webkit-scrollbar {
2 | background-color: #1e1e1e;
3 | width: 6px;
4 | }
5 |
6 | .lyrics::-webkit-scrollbar-thumb {
7 | background-color: #2a2a2a;
8 | /* border-radius: 4px; */
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/ui/LyricsPanel.tsx:
--------------------------------------------------------------------------------
1 | import './LyricsPanel.css';
2 |
3 | interface LyricsPanelProps {
4 | lyrics: string;
5 | }
6 |
7 | export function LyricsPanel({ lyrics }: LyricsPanelProps) {
8 | const lyricsFormatted = lyrics
9 | .split('\n')
10 | .map((line, index) => {line}
);
11 | return (
12 |
13 | {lyricsFormatted}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/ui/PlaylistPanel.css:
--------------------------------------------------------------------------------
1 | .playlist-scrollbar::-webkit-scrollbar {
2 | width: 0px;
3 | }
4 |
5 | .credit-scrollbar::-webkit-scrollbar {
6 | width: 6px;
7 | }
8 |
9 | .credit-scrollbar::-webkit-scrollbar-thumb {
10 | background-color: #2a2a2a;
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/ui/PlaylistPanel.tsx:
--------------------------------------------------------------------------------
1 | import { SongData } from '@/entities/album/types';
2 | import { PlaylistPanelSong } from './PlaylistPanelSong';
3 | import { PlaylistPanelCredit } from './PlaylistPanelCredit';
4 | interface PlaylistPanelProps {
5 | songs: SongData[];
6 | songIndex: number;
7 | }
8 |
9 | export function PlaylistPanel({ songs, songIndex }: PlaylistPanelProps) {
10 | return (
11 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/ui/PlaylistPanelCredit.tsx:
--------------------------------------------------------------------------------
1 | import { SongData } from '@/entities/album/types';
2 | import './PlaylistPanel.css';
3 |
4 | interface PlaylistPanelCreditProps {
5 | currentSong: SongData;
6 | }
7 |
8 | function CreditListItem({
9 | title,
10 | content,
11 | }: {
12 | title: string;
13 | content: string;
14 | }) {
15 | return (
16 | <>
17 | {title}
18 | {content}
19 | >
20 | );
21 | }
22 |
23 | export function PlaylistPanelCredit({ currentSong }: PlaylistPanelCreditProps) {
24 | return (
25 |
26 |
크레딧
27 |
28 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/client/src/entities/songDetail/ui/PlaylistPanelSong.tsx:
--------------------------------------------------------------------------------
1 | import Lottie from 'lottie-react';
2 | import musicAnimation from '@/shared/lottie/music.json';
3 | import { SongData } from '@/entities/album/types';
4 | import './PlaylistPanel.css';
5 | interface PlaylistPanelSongProps {
6 | songs: SongData[];
7 | songIndex: number;
8 | }
9 |
10 | export function PlaylistPanelSong({
11 | songs,
12 | songIndex,
13 | }: PlaylistPanelSongProps) {
14 | const formattedDuration = (duration: number) => {
15 | const minutes = Math.floor(duration / 60);
16 | const seconds = duration % 60;
17 | return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`;
18 | };
19 |
20 | return (
21 |
22 | {songs.map((song, index) => (
23 |
28 | {songIndex - 1 === index ? (
29 |
33 | ) : (
34 |
{index + 1}
35 | )}
36 |
37 | {song.title}
38 |
39 |
40 | {formattedDuration(Number(song.duration))}
41 |
42 |
43 | ))}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/client/src/features/albumRegister/index.ts:
--------------------------------------------------------------------------------
1 | export { AlbumForm } from './ui/AlbumForm';
2 | export { SongForm } from './ui/SongForm';
3 |
--------------------------------------------------------------------------------
/client/src/features/albumRegister/ui/AlbumForm.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from '@/shared/ui';
2 | interface AlbumFormProps {
3 | albumFormRef: React.RefObject;
4 | }
5 | export function AlbumForm({ albumFormRef }: AlbumFormProps) {
6 | return (
7 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/features/albumRegister/ui/SongForm.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from '@/shared/ui';
2 |
3 | interface SongFormProps {
4 | songFormRef: React.RefObject;
5 | }
6 |
7 | export function SongForm({ songFormRef }: SongFormProps) {
8 | return (
9 |
38 | );
39 | }
40 |
41 | SongForm.displayName = 'SongForm';
42 |
--------------------------------------------------------------------------------
/client/src/features/albumStreaming/hook/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * hls 스트리밍 기본 설정
3 | */
4 | export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
5 | maxBufferLength: 30,
6 | maxMaxBufferLength: 60,
7 | maxBufferSize: 0,
8 | maxBufferHole: 0,
9 | lowLatencyMode: true,
10 | backBufferLength: 0,
11 | };
12 |
--------------------------------------------------------------------------------
/client/src/features/albumStreaming/hook/useStreamingPlayer.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef, useState, useEffect } from 'react';
2 | import Hls from 'hls.js';
3 | import { DEFAULT_STREAMING_CONFIG } from './constants';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | export const useStreamingPlayer = (
7 | roomId: string,
8 | songIndex: number,
9 | setSongIndex: (value: React.SetStateAction) => void,
10 | totalSongs: number,
11 | ) => {
12 | const audioRef = useRef(null);
13 | const hlsRef = useRef(null);
14 | const [isLoaded, setIsLoaded] = useState(false);
15 | const [error, setError] = useState(null);
16 | const navigate = useNavigate();
17 | const initializeMediaSession = useCallback(() => {
18 | if ('mediaSession' in navigator) {
19 | const actions: MediaSessionAction[] = [
20 | 'play',
21 | 'pause',
22 | 'seekbackward',
23 | 'seekforward',
24 | 'previoustrack',
25 | 'nexttrack',
26 | 'stop',
27 | 'seekto',
28 | ];
29 | actions.forEach((action: MediaSessionAction) => {
30 | navigator.mediaSession.setActionHandler(action, () => {});
31 | });
32 | }
33 | }, []);
34 |
35 | const destroyHls = useCallback(() => {
36 | if (hlsRef.current) {
37 | hlsRef.current.destroy();
38 | hlsRef.current = null;
39 | }
40 | }, []);
41 | const createStreamUrl = (roomId: string) =>
42 | `${import.meta.env.VITE_API_URL}/api/music/${roomId}/playlist.m3u8`;
43 |
44 | const initializeHls = (audio: HTMLMediaElement, streamUrl: string) => {
45 | destroyHls();
46 | const hls = new Hls(DEFAULT_STREAMING_CONFIG);
47 | hlsRef.current = hls;
48 |
49 | hls.loadSource(streamUrl);
50 | hls.attachMedia(audio);
51 |
52 | hls.on(Hls.Events.MANIFEST_PARSED, () => setIsLoaded(true));
53 |
54 | hls.on(Hls.Events.ERROR, (event, data) => {
55 | setError(new Error('종료된 방이거나 시스템 오류입니다.'));
56 | });
57 | };
58 |
59 | const playStream = useCallback(() => {
60 | const audio = audioRef.current;
61 | if (!audio) return;
62 |
63 | if (Hls.isSupported()) {
64 | initializeHls(audio, createStreamUrl(roomId));
65 | } else {
66 | const hlsNotSupportedError = new Error('HLS is not supported');
67 | setError(hlsNotSupportedError);
68 | }
69 | }, [roomId]);
70 |
71 | useEffect(() => {
72 | initializeMediaSession();
73 | playStream();
74 | }, []);
75 |
76 | useEffect(() => {
77 | const audio = audioRef.current;
78 | if (!audio) return;
79 |
80 | const handleEnded = () => {
81 | setTimeout(() => {
82 | setIsLoaded(false);
83 |
84 | if (songIndex + 1 > totalSongs) {
85 | Promise.resolve().then(() => {
86 | setSongIndex(1);
87 | alert('모든 곡이 종료되었습니다.');
88 | navigate('/');
89 | });
90 | return;
91 | }
92 |
93 | setSongIndex((prev) => prev + 1);
94 | playStream();
95 | }, 0);
96 | };
97 |
98 | audio.addEventListener('ended', handleEnded);
99 |
100 | return () => {
101 | audio.removeEventListener('ended', handleEnded);
102 | };
103 | }, [songIndex, totalSongs, navigate, playStream]);
104 |
105 | useEffect(() => {
106 | const audio = audioRef.current;
107 | if (!audio || !isLoaded) return;
108 |
109 | audio.play().catch((error) => {
110 | if (error.name === 'NotAllowedError') {
111 | setIsLoaded(false);
112 | } else {
113 | throw error;
114 | }
115 | });
116 | }, [isLoaded]);
117 |
118 | return { audioRef, isLoaded, playStream, error };
119 | };
120 |
--------------------------------------------------------------------------------
/client/src/features/albumStreaming/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/src/features/albumStreaming/index.ts
--------------------------------------------------------------------------------
/client/src/features/albumStreaming/model/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * hls 스트리밍 설정
3 | */
4 | interface StreamingConfig {
5 | maxBufferLength: number;
6 | maxMaxBufferLength: number;
7 | maxBufferSize: number;
8 | maxBufferHole: number;
9 | lowLatencyMode: boolean;
10 | backBufferLength: number;
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/features/chattingInput/index.ts:
--------------------------------------------------------------------------------
1 | export { ChatInput } from './ui/ChatInput';
2 |
--------------------------------------------------------------------------------
/client/src/features/chattingInput/model/sendMessage.ts:
--------------------------------------------------------------------------------
1 | import { useSocketStore } from '@/shared/store/useSocketStore';
2 |
3 | export const sendMessage = (message: string, roomId: string) => {
4 | const { socket } = useSocketStore.getState();
5 |
6 | if (!socket || !socket.connected) {
7 | console.error('소켓 연결 안됨');
8 | return;
9 | }
10 |
11 | socket.emit('message', { message: message, roomId: roomId });
12 | };
13 |
--------------------------------------------------------------------------------
/client/src/features/chattingInput/ui/ChatInput.tsx:
--------------------------------------------------------------------------------
1 | import { SendIcon } from '@/shared/icon/SendIcon';
2 | import { sendMessage } from '../model/sendMessage';
3 | import { useState, FormEvent, memo, useCallback } from 'react';
4 | import { useParams } from 'react-router-dom';
5 | import { useSocketStore } from '@/shared/store/useSocketStore';
6 |
7 | const CHAT_CONFIG = {
8 | TEXT_LIMIT: 150,
9 | TEXT_LIMIT_MESSAGE: '채팅은 150자 이하만 가능합니다',
10 | } as const;
11 |
12 | export const ChatInput = memo(() => {
13 | const [message, setMessage] = useState('');
14 | const [isTextOver, setIsTextOver] = useState(false);
15 | const { roomId } = useParams<{ roomId: string }>();
16 | const socket = useSocketStore((state) => state.socket);
17 |
18 | const handleMessageChange = useCallback((e: FormEvent) => {
19 | const inputValue = e.currentTarget.value;
20 | setMessage(inputValue);
21 | setIsTextOver(inputValue.length >= CHAT_CONFIG.TEXT_LIMIT);
22 | }, []);
23 |
24 | const handleSubmit = useCallback(
25 | (e: FormEvent) => {
26 | e.preventDefault();
27 |
28 | const trimmedMessage = message.trim();
29 | const isValidMessage =
30 | trimmedMessage &&
31 | roomId &&
32 | socket?.connected &&
33 | trimmedMessage.length <= CHAT_CONFIG.TEXT_LIMIT;
34 |
35 | if (isValidMessage) {
36 | sendMessage(trimmedMessage, roomId);
37 | setMessage('');
38 | setIsTextOver(false);
39 | }
40 | },
41 | [message, roomId, socket],
42 | );
43 |
44 | const isSubmitDisabled = !socket?.connected || isTextOver;
45 |
46 | return (
47 |
71 | );
72 | });
73 |
--------------------------------------------------------------------------------
/client/src/features/commentInput/index.ts:
--------------------------------------------------------------------------------
1 | export { CommentInput } from './ui/CommentInput';
2 |
--------------------------------------------------------------------------------
/client/src/features/commentInput/ui/CommentInput.tsx:
--------------------------------------------------------------------------------
1 | import { publicAPI } from '@/shared/api/publicAPI.ts';
2 | import React, { useCallback, useState } from 'react';
3 | import { Button } from '@/shared/ui/Button.tsx';
4 | import { useParams } from 'react-router-dom';
5 | import { CommentData } from '@/entities/comment/types';
6 | const MAX_COMMENT_LENGTH = 200;
7 |
8 | interface CommentInputProps {
9 | setCommentList: (value: React.SetStateAction) => void;
10 | }
11 |
12 | export function CommentInput({ setCommentList }: CommentInputProps) {
13 | const [text, setText] = useState('');
14 | const { albumId } = useParams<{ albumId: string }>();
15 | if (!albumId) return;
16 |
17 | const handleTextChange = useCallback(
18 | (e: React.FormEvent) => {
19 | const inputValue = e.currentTarget.value;
20 | setText(inputValue);
21 | },
22 | [setText],
23 | );
24 |
25 | const handleSubmit = useCallback(
26 | async (e: React.FormEvent) => {
27 | e.preventDefault();
28 |
29 | const trimmedText = text.trim();
30 | const isValidMessage =
31 | trimmedText && trimmedText.length <= MAX_COMMENT_LENGTH;
32 |
33 | if (isValidMessage) {
34 | const response = await publicAPI.createComment(albumId, trimmedText);
35 | if (response.success) {
36 | setCommentList((prev) => [response.commentResponse, ...prev]);
37 | setText('');
38 | }
39 | }
40 | },
41 | [text, albumId, setCommentList, setText],
42 | );
43 |
44 | if (!albumId) return null;
45 |
46 | return (
47 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/client/src/features/songDetail/index.ts:
--------------------------------------------------------------------------------
1 | export { SongDetail } from './ui/SongDetail';
2 |
--------------------------------------------------------------------------------
/client/src/features/songDetail/model/types.ts:
--------------------------------------------------------------------------------
1 | export const CATEGORIES = {
2 | LYRICS: 'lyrics',
3 | PLAYLIST: 'playlist',
4 | } as const;
5 |
--------------------------------------------------------------------------------
/client/src/features/songDetail/ui/CategoryButton.tsx:
--------------------------------------------------------------------------------
1 | interface CategoryButtonProps {
2 | isActive: boolean;
3 | onClick: () => void;
4 | children: React.ReactNode;
5 | }
6 |
7 | export function CategoryButton({
8 | isActive,
9 | onClick,
10 | children,
11 | }: CategoryButtonProps) {
12 | return (
13 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/client/src/features/songDetail/ui/SongDetail.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { SongData } from '@/entities/album/types';
3 | import { SongDetailHeader } from './SongDetailHeader';
4 | import { SongDetailContent } from './SongDetailContent';
5 |
6 | interface SongDetailProps {
7 | songs: SongData[];
8 | songIndex: number;
9 | }
10 |
11 | export function SongDetail({ songs, songIndex }: SongDetailProps) {
12 | const [isOpen, setIsOpen] = useState(false);
13 | const [category, setCategory] = useState('lyrics');
14 |
15 | return (
16 |
22 |
28 | {isOpen && (
29 |
35 | )}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/client/src/features/songDetail/ui/SongDetailContent.tsx:
--------------------------------------------------------------------------------
1 | import { SongData } from '@/entities/album/types';
2 | import { CATEGORIES } from '../model/types';
3 | import { LyricsPanel } from '@/entities/songDetail';
4 | import { PlaylistPanel } from '@/entities/songDetail';
5 |
6 | interface SongDetailContentProps {
7 | isOpen: boolean;
8 | category: string;
9 | songs: SongData[];
10 | songIndex: number;
11 | }
12 |
13 | export function SongDetailContent({
14 | isOpen,
15 | category,
16 | songs,
17 | songIndex,
18 | }: SongDetailContentProps) {
19 | return (
20 |
24 | {category === CATEGORIES.LYRICS ? (
25 |
26 | ) : (
27 |
28 | )}
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/client/src/features/songDetail/ui/SongDetailHeader.tsx:
--------------------------------------------------------------------------------
1 | import { CATEGORIES } from '../model/types';
2 | import { ChevronDown } from '@/shared/icon/ChevronDown';
3 | import { CategoryButton } from './CategoryButton';
4 |
5 | interface SongDetailHeaderProps {
6 | category: string;
7 | isOpen: boolean;
8 | setIsOpen: (value: boolean) => void;
9 | setCategory: (value: string) => void;
10 | }
11 |
12 | export function SongDetailHeader({
13 | category,
14 | isOpen,
15 | setIsOpen,
16 | setCategory,
17 | }: SongDetailHeaderProps) {
18 | const handleCategoryClick = (selectedCategory: string) => {
19 | if (!isOpen) {
20 | setIsOpen(true);
21 | }
22 | setCategory(selectedCategory);
23 | };
24 | return (
25 |
26 |
27 | handleCategoryClick(CATEGORIES.LYRICS)}
30 | >
31 | 가사
32 |
33 | handleCategoryClick(CATEGORIES.PLAYLIST)}
36 | >
37 | 플레이리스트
38 |
39 |
40 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/client/src/features/useCounter/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/client/src/features/useCounter/index.ts
--------------------------------------------------------------------------------
/client/src/features/useCounter/ui/UserCounter.tsx:
--------------------------------------------------------------------------------
1 | import { useSocketStore } from '@/shared/store/useSocketStore';
2 | import Person from '@/shared/icon/Person';
3 | import React from 'react';
4 |
5 | export const UserCounter = React.memo(
6 | function UserCounter() {
7 | const userCount = useSocketStore((state) => state.userCount);
8 | return (
9 |
10 |
11 |
{userCount}명
12 |
13 | );
14 | },
15 | (prevProps, nextProps) => true,
16 | );
17 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css');
2 | @import './reset.css';
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | body {
8 | font-family: 'Pretendard', sans-serif;
9 | background-color: #121212;
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import './index.css';
3 | import { App } from '@/App';
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { BrowserRouter } from 'react-router-dom';
6 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
7 |
8 | const queryClient = new QueryClient({
9 | defaultOptions: {
10 | queries: {
11 | retry: 1,
12 | },
13 | },
14 | });
15 |
16 | createRoot(document.getElementById('root')!).render(
17 | <>
18 |
19 |
25 |
26 | {/* */}
27 |
28 |
29 | >,
30 | );
31 |
--------------------------------------------------------------------------------
/client/src/pages/AdminLoginPage/index.ts:
--------------------------------------------------------------------------------
1 | export { AdminLoginPage } from './ui/AdminLoginPage';
2 |
--------------------------------------------------------------------------------
/client/src/pages/AdminLoginPage/ui/AdminLoginPage.tsx:
--------------------------------------------------------------------------------
1 | // src/pages/AdminLoginPage.tsx
2 | import { useState } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 | import { Button } from '@/shared/ui';
5 | import axios from 'axios';
6 |
7 | export function AdminLoginPage() {
8 | const [adminKey, setAdminKey] = useState('');
9 | const navigate = useNavigate();
10 |
11 | const handleSubmit = async (e: React.FormEvent) => {
12 | e.preventDefault();
13 |
14 | try {
15 | await axios.post(
16 | `${import.meta.env.VITE_API_URL}/api/admin/login`,
17 | { adminKey },
18 | { withCredentials: true },
19 | );
20 | navigate('/admin');
21 | } catch (error) {
22 | console.error('Login error:', error);
23 | setAdminKey('');
24 | alert('관리자 인증에 실패했습니다.');
25 | }
26 | };
27 |
28 | return (
29 |
30 |
31 |
32 | 관리자 로그인
33 |
34 |
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/client/src/pages/AdminPage/index.ts:
--------------------------------------------------------------------------------
1 | export { AdminPage } from './ui/AdminPage';
2 |
--------------------------------------------------------------------------------
/client/src/pages/AdminPage/ui/AdminPage.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/shared/ui';
2 | import { AlbumForm, SongForm } from '@/features/albumRegister';
3 | import { useAlbumForm } from '@/features/albumRegister/model/useAlbumForm';
4 | import { useState } from 'react';
5 | import { Link } from 'react-router-dom';
6 | import { InearLogo } from '@/shared/icon/InearLogo';
7 |
8 | export function AdminPage() {
9 | const { handleSubmit, handleAddSong, songs, songFormRef, albumFormRef } =
10 | useAlbumForm();
11 | const [isSuccess, setIsSuccess] = useState(false);
12 | const [isLoading, setIsLoading] = useState(false);
13 | const handlePost = async () => {
14 | if (!albumFormRef.current || songs.length === 0) return;
15 | setIsLoading(true);
16 | try {
17 | await handleSubmit();
18 | setIsSuccess(true);
19 | albumFormRef.current.reset();
20 |
21 | setTimeout(() => {
22 | setIsSuccess(false);
23 | }, 10000);
24 | } catch (error) {
25 | console.error('방 생성 실패:', error);
26 | setIsSuccess(false);
27 | } finally {
28 | setIsLoading(false);
29 | }
30 | };
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 | 관리자 페이지
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | {songs.map((song, index) => (
49 | -
50 | {index + 1}. {song.title}
51 |
52 | ))}
53 |
54 |
55 |
56 |
62 | {isSuccess && (
63 |
64 | ✔ 방 생성 완료
65 |
66 | )}
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/client/src/pages/AlbumPage/index.ts:
--------------------------------------------------------------------------------
1 | export { AlbumPage } from './ui/AlbumPage';
2 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/index.ts:
--------------------------------------------------------------------------------
1 | export { MainPage } from './ui/MainPage';
2 |
--------------------------------------------------------------------------------
/client/src/pages/MainPage/ui/MainPage.tsx:
--------------------------------------------------------------------------------
1 | import { AlbumList } from '@/widgets/albums';
2 | import { publicAPI } from '@/shared/api/publicAPI';
3 | import { useEffect, useState } from 'react';
4 | import { bannerData } from '@/entities/room/types';
5 | import { Banner } from '@/widgets/banner';
6 | import DefaultBanner from '@/assets/default-banner.webp';
7 |
8 | const defaultBanner: bannerData = {
9 | albumId: '',
10 | albumName: '',
11 | albumTags: '',
12 | artist: '',
13 | bannerImageUrl: DefaultBanner,
14 | currentUserCount: 0,
15 | releaseDate: '',
16 | };
17 |
18 | export function MainPage() {
19 | const [bannerList, setBannerList] = useState([]);
20 | useEffect(() => {
21 | const getAlbumBanner = async () => {
22 | const res = await publicAPI
23 | .getAlbumBanner()
24 | .then((res) => res)
25 | .catch((err) => console.log(err));
26 | const filteredBannerList = res.result.bannerLists.filter(
27 | (banner: bannerData) => banner.bannerImageUrl,
28 | );
29 | setBannerList(
30 | filteredBannerList.length > 0
31 | ? [defaultBanner, ...filteredBannerList]
32 | : [defaultBanner],
33 | );
34 | };
35 | getAlbumBanner();
36 | }, []);
37 | return (
38 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/pages/StreamingErrorPage/index.ts:
--------------------------------------------------------------------------------
1 | export { StreamingErrorPage } from './ui/StreamingErrorPage';
2 |
--------------------------------------------------------------------------------
/client/src/pages/StreamingErrorPage/ui/StreamingErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import { useRouteError } from 'react-router-dom';
2 | class APIError extends Error {
3 | statueCode: number;
4 | originalError: Error;
5 |
6 | constructor(statueCode: number, message: string, originalError?: Error) {
7 | super(message);
8 | this.name = 'APIError';
9 | this.statueCode = statueCode;
10 | this.originalError = originalError || new Error(message);
11 | }
12 | }
13 |
14 | export function StreamingErrorPage({ message }: { message?: string }) {
15 | return (
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/pages/StreamingPage/index.ts:
--------------------------------------------------------------------------------
1 | export { StreamingPage } from './ui/StreamingPage';
2 |
--------------------------------------------------------------------------------
/client/src/pages/StreamingPage/ui/Notice.tsx:
--------------------------------------------------------------------------------
1 | export function Notice({ message, title }: { message: string; title: string }) {
2 | return (
3 |
4 |
5 |
6 |
{title}
7 |
{message}
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/pages/StreamingPage/ui/Standby.tsx:
--------------------------------------------------------------------------------
1 | import { AlbumData } from '@/entities/album/types';
2 | import { convertToKTC, splitDate } from '@/shared/util/timeUtils';
3 | import { Timer } from '@/shared/ui';
4 | import { Notice } from '@/pages/StreamingPage/ui/Notice';
5 |
6 | export function Standby({ album }: { album: AlbumData }) {
7 | const handleCountdownComplete = () => {
8 | window.location.reload();
9 | };
10 |
11 | return (
12 |
13 |
{album.title}
14 |
{album.artist}
15 |
19 |
20 | {splitDate(convertToKTC(album.releaseDate))} 시작 예정
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/pages/StreamingPage/ui/StreamingPage.tsx:
--------------------------------------------------------------------------------
1 | import { Vote } from '@/widgets/vote';
2 | import { Streaming } from '@/widgets/streaming';
3 | import { useSocketStore } from '@/shared/store/useSocketStore';
4 | import { useChatMessageStore } from '@/shared/store/useChatMessageStore';
5 | import { useParams } from 'react-router-dom';
6 | import { useEffect, useState } from 'react';
7 | import { publicAPI } from '@/shared/api/publicAPI';
8 | import { NetworkBoundary } from '@/NetworkBoundary';
9 | import { useQuery } from '@tanstack/react-query';
10 | import { Standby } from './Standby';
11 | import { compareDate, sumSeconds } from '@/shared/util/timeUtils';
12 | import { Chatting } from '@/widgets/chatting';
13 | import { useShallow } from 'zustand/react/shallow';
14 | function validateRoom(roomInfo: any) {
15 | return (
16 | compareDate(
17 | sumSeconds(
18 | roomInfo.albumResponse.releaseDate,
19 | Number(roomInfo.totalDuration),
20 | ),
21 | new Date(),
22 | ) > 0
23 | );
24 | }
25 |
26 | function StreamingContainer() {
27 | const { roomId } = useParams<{ roomId: string }>();
28 | const [songIndex, setSongIndex] = useState(1);
29 | const { data: roomInfo } = useQuery({
30 | queryKey: ['room', roomId],
31 | queryFn: () => publicAPI.getRoomInfo(roomId!),
32 | throwOnError: true,
33 | });
34 |
35 | useEffect(() => {
36 | if (roomInfo) {
37 | setSongIndex(Number(roomInfo.trackOrder));
38 | }
39 | }, [roomInfo]);
40 |
41 | // 방 정보가 없을 때
42 | if (!roomInfo) {
43 | return null;
44 | }
45 |
46 | // 종료된 방일 때
47 | if (!validateRoom(roomInfo)) {
48 | throw new Error('방이 종료되었습니다.');
49 | }
50 |
51 | // 아직 세션이 시작되지 않음
52 | if (roomInfo.trackOrder === null) {
53 | return ;
54 | }
55 |
56 | return (
57 |
58 |
63 | {roomInfo && }
64 |
65 | );
66 | }
67 |
68 | export function StreamingPage() {
69 | const { connect, reset } = useSocketStore(
70 | useShallow((state) => ({ connect: state.connect, reset: state.reset })),
71 | );
72 | const clearMessages = useChatMessageStore((state) => state.clearMessages);
73 | const { roomId } = useParams<{ roomId: string }>();
74 |
75 | useEffect(() => {
76 | // 페이지 진입 시 소켓 초기화
77 | reset();
78 | clearMessages();
79 |
80 | if (roomId) {
81 | connect(roomId);
82 | }
83 |
84 | // 페이지 벗어날 때 소켓 리셋
85 | return () => {
86 | reset();
87 | };
88 | }, [roomId]);
89 |
90 | return (
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/client/src/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html,
7 | body,
8 | div,
9 | span,
10 | applet,
11 | object,
12 | iframe,
13 | h1,
14 | h2,
15 | h3,
16 | h4,
17 | h5,
18 | h6,
19 | p,
20 | blockquote,
21 | pre,
22 | a,
23 | abbr,
24 | acronym,
25 | address,
26 | big,
27 | cite,
28 | code,
29 | del,
30 | dfn,
31 | em,
32 | img,
33 | ins,
34 | kbd,
35 | q,
36 | s,
37 | samp,
38 | small,
39 | strike,
40 | strong,
41 | sub,
42 | sup,
43 | tt,
44 | var,
45 | b,
46 | u,
47 | i,
48 | center,
49 | dl,
50 | dt,
51 | dd,
52 | ol,
53 | ul,
54 | li,
55 | fieldset,
56 | form,
57 | label,
58 | legend,
59 | table,
60 | caption,
61 | tbody,
62 | tfoot,
63 | thead,
64 | tr,
65 | th,
66 | td,
67 | article,
68 | aside,
69 | canvas,
70 | details,
71 | embed,
72 | figure,
73 | figcaption,
74 | footer,
75 | header,
76 | hgroup,
77 | menu,
78 | nav,
79 | output,
80 | ruby,
81 | section,
82 | summary,
83 | time,
84 | mark,
85 | audio,
86 | video {
87 | margin: 0;
88 | padding: 0;
89 | border: 0;
90 | font-size: 100%;
91 | font: inherit;
92 | vertical-align: baseline;
93 | }
94 | /* HTML5 display-role reset for older browsers */
95 | article,
96 | aside,
97 | details,
98 | figcaption,
99 | figure,
100 | footer,
101 | header,
102 | hgroup,
103 | menu,
104 | nav,
105 | section {
106 | display: block;
107 | }
108 | body {
109 | line-height: 1;
110 | }
111 | ol,
112 | ul {
113 | list-style: none;
114 | }
115 | blockquote,
116 | q {
117 | quotes: none;
118 | }
119 | blockquote:before,
120 | blockquote:after,
121 | q:before,
122 | q:after {
123 | content: '';
124 | content: none;
125 | }
126 | table {
127 | border-collapse: collapse;
128 | border-spacing: 0;
129 | }
130 |
--------------------------------------------------------------------------------
/client/src/shared/api/adminAPI.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const adminApi = axios.create({
4 | baseURL: `${import.meta.env.VITE_API_URL}/api`,
5 | withCredentials: true,
6 | });
7 |
8 | export const albumAPI = {
9 | createAlbum: async (formData: FormData) => {
10 | return adminApi.post('/admin/album', formData, {
11 | headers: {
12 | 'Content-Type': 'multipart/form-data',
13 | },
14 | });
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/client/src/shared/api/errorMessage.ts:
--------------------------------------------------------------------------------
1 | export const ERROR_MESSAGE = {
2 | NETWORK: {
3 | NETWORK_ERROR: '네트워크 연결에 문제가 있습니다.',
4 | },
5 | HTTP: {
6 | 400: '잘못된 요청입니다.',
7 | 403: '접근 권한이 없습니다.',
8 | 404: '요청한 리소스를 찾을 수 없습니다.',
9 | 500: '서버에 문제가 발생했습니다.',
10 | 502: '서버에 문제가 발생했습니다.',
11 | },
12 | DEFAULT: {
13 | UNKNOWN_ERROR: '알 수 없는 에러가 발생했습니다.',
14 | },
15 | COMMENT: {
16 | COMMENT_MESSAGE_TO_LONG: '댓글은 200자 이내로 작성해주세요.',
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/client/src/shared/api/publicAPI.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError, AxiosInstance, HttpStatusCode } from 'axios';
2 | import { ERROR_MESSAGE } from './errorMessage';
3 |
4 | class APIError extends Error {
5 | statueCode: number;
6 | originalError: Error;
7 |
8 | constructor(statueCode: number, message: string, originalError?: Error) {
9 | super(message);
10 | this.name = 'APIError';
11 | this.statueCode = statueCode;
12 | this.originalError = originalError || new Error(message);
13 | }
14 | }
15 |
16 | const publicInstance: AxiosInstance = axios.create({
17 | baseURL: `${import.meta.env.VITE_API_URL}/api`,
18 | });
19 |
20 | class CustomError extends Error {
21 | constructor(message: string) {
22 | super(message);
23 | this.name = 'CustomError';
24 | }
25 | }
26 |
27 | export const publicAPI = {
28 | getAlbumSidebar: async () => {
29 | const { data } = await publicInstance.get(`/album/sidebar`);
30 | return data.result;
31 | },
32 | getAlbumBanner: async () => {
33 | try {
34 | const { data } = await publicInstance.get('/album/banner');
35 | return data;
36 | } catch (error) {
37 | throw error;
38 | }
39 | },
40 | getRoomInfo: async (roomId: string) => {
41 | const { data } = await publicInstance.get(`/room/${roomId}`);
42 | return data;
43 | },
44 | getAlbumEnded: async () => {
45 | try {
46 | const { data } = await publicInstance.get('/album/ended');
47 | return data;
48 | } catch (error) {
49 | throw error;
50 | }
51 | },
52 | getComment: async (albumId: string) => {
53 | const { data } = await publicInstance.get(`/comment/album/${albumId}`);
54 | return data;
55 | },
56 | getAlbumInfo: async (albumId: string) => {
57 | const { data } = await publicInstance.get(`/album/${albumId}`);
58 | return data;
59 | },
60 | createComment: async (albumId: string, content: string) => {
61 | const { data } = await publicInstance.post(`/comment/album/${albumId}`, {
62 | content,
63 | });
64 | return data;
65 | },
66 | };
67 |
--------------------------------------------------------------------------------
/client/src/shared/api/socket.ts:
--------------------------------------------------------------------------------
1 | import { io, Socket } from 'socket.io-client';
2 |
3 | export const createSocket = (roomId: string): Socket => {
4 | const URL = `${import.meta.env.VITE_API_URL}/rooms`;
5 | return io(URL, {
6 | autoConnect: false,
7 | reconnectionAttempts: 3,
8 | query: {
9 | roomId,
10 | },
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/client/src/shared/hook/useSocketEvents.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io-client';
2 | import { useCallback, useEffect } from 'react';
3 |
4 | interface SocketEvents {
5 | [key: string]: (...args: any[]) => void;
6 | }
7 |
8 | interface UseSocketEventsProps {
9 | socket: Socket | null;
10 | events: SocketEvents;
11 | }
12 |
13 | /**
14 | * [현재 미사용]
15 | * 소켓 연결과 스트리밍 전체 연결 확인 후에 삭제할 예정
16 | * @returns
17 | */
18 |
19 | export function useSocketEvents({ socket, events }: UseSocketEventsProps) {
20 | const registerEvents = useCallback(() => {
21 | Object.entries(events).forEach(([event, handler]) => {
22 | socket?.on(event, handler);
23 | });
24 | }, [socket, events]);
25 |
26 | const unregisterEvents = useCallback(() => {
27 | Object.entries(events).forEach(([event, handler]) => {
28 | socket?.off(event, handler);
29 | });
30 | }, [socket, events]);
31 |
32 | useEffect(() => {
33 | registerEvents();
34 | return () => {
35 | unregisterEvents();
36 | };
37 | }, [registerEvents, unregisterEvents]);
38 | }
39 |
--------------------------------------------------------------------------------
/client/src/shared/hook/useStreamingRoom.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useParams } from 'react-router-dom';
3 | import { useSocketEvents } from './useSocketEvents';
4 | import { Socket } from 'socket.io-client';
5 | import { createSocket } from '../api/socket';
6 |
7 | /**
8 | * [현재 미사용]
9 | * 소켓 연결과 스트리밍 전체 연결 확인 후에 삭제할 예정
10 | * @returns
11 | */
12 |
13 | export function useStreamingRoom() {
14 | const [isConnected, setIsConnected] = useState(false);
15 | const [socket, setSocket] = useState(null);
16 | const { roomId } = useParams();
17 |
18 | const handleDisconnect = () => {
19 | setIsConnected(false);
20 | };
21 |
22 | const handleJoinRoom = (data: any) => {
23 | console.log('방 입장 : ', data);
24 | };
25 |
26 | const handleConnectError = (err: Error) => {
27 | setIsConnected(false);
28 | console.log(`연결 오류 : ${err.message}`);
29 | };
30 |
31 | useEffect(() => {
32 | if (!roomId) return;
33 | const newSocket = createSocket(roomId);
34 |
35 | if (!newSocket.connected) {
36 | newSocket.connect();
37 | }
38 | setSocket(newSocket);
39 |
40 | return () => {
41 | console.log('DISCONNECTED');
42 | newSocket.removeAllListeners();
43 | newSocket.disconnect();
44 | };
45 | }, []);
46 |
47 | useSocketEvents({
48 | socket,
49 | events: {
50 | disconnect: handleDisconnect,
51 | connect_error: handleConnectError,
52 | joinedRoom: handleJoinRoom,
53 | },
54 | });
55 |
56 | return { isConnected, socket };
57 | }
58 |
--------------------------------------------------------------------------------
/client/src/shared/icon/ChevronDown.tsx:
--------------------------------------------------------------------------------
1 | export function ChevronDown() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/shared/icon/InearLogo.tsx:
--------------------------------------------------------------------------------
1 | export function InearLogo() {
2 | return (
3 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/shared/icon/Person.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | function Person() {
3 | return (
4 |
16 | );
17 | }
18 |
19 | export default React.memo(Person);
20 |
--------------------------------------------------------------------------------
/client/src/shared/icon/PlayIcon.tsx:
--------------------------------------------------------------------------------
1 | export function PlayIcon() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/shared/icon/SendIcon.tsx:
--------------------------------------------------------------------------------
1 | export function SendIcon() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/shared/icon/Volume.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function Volume() {
4 | return (
5 |
17 | );
18 | }
19 |
20 | export default React.memo(Volume);
21 |
--------------------------------------------------------------------------------
/client/src/shared/icon/VolumeMuted.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | function VolumeMuted() {
3 | return (
4 |
22 | );
23 | }
24 |
25 | export default React.memo(VolumeMuted);
26 |
--------------------------------------------------------------------------------
/client/src/shared/store/state/chatState.ts:
--------------------------------------------------------------------------------
1 | import { MessageData } from '@/entities/message/types';
2 |
3 | export interface ChatMessageState {
4 | messages: MessageData[];
5 | addMessage: (message: MessageData) => void;
6 | clearMessages: () => void;
7 | reset: () => void;
8 | }
9 |
10 | export interface ChatActions {
11 | addMessage: (message: MessageData) => void;
12 | clearMessages: () => void;
13 | reset: () => void;
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/shared/store/state/socketState.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io-client';
2 |
3 | export interface SocketState {
4 | socket: Socket | null;
5 | isConnected: boolean;
6 | roomId: string | null;
7 | userCount: number;
8 | }
9 |
10 | export interface SocketActions {
11 | connect: (roomId: string) => void;
12 | disconnect: () => void;
13 | reset: () => void;
14 | setUserCount: (count: number) => void;
15 | setConnectionStatus: (status: boolean) => void;
16 | resetAllStores: () => void;
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/shared/store/state/voteState.ts:
--------------------------------------------------------------------------------
1 | export type VoteType = { votes: Record; trackNumber: string };
2 |
3 | export interface VoteState {
4 | voteData: VoteType;
5 | }
6 |
7 | export interface VoteActions {
8 | showVote: (voteData: VoteType) => void;
9 | updateVote: (voteData: VoteType) => void;
10 | reset: () => void;
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/shared/store/useChatMessageStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { ChatMessageState } from './state/chatState';
3 | const MAX_MESSAGE_LENGTH = 150;
4 | const INITIAL_STATE = {
5 | messages: [],
6 | };
7 |
8 | export const useChatMessageStore = create((set) => ({
9 | messages: [],
10 |
11 | addMessage: (message) =>
12 | set((state) => {
13 | const updatedMessages =
14 | state.messages.length >= MAX_MESSAGE_LENGTH
15 | ? [...state.messages.slice(1), message]
16 | : [...state.messages, message];
17 | return { messages: updatedMessages };
18 | }),
19 |
20 | clearMessages: () => set({ messages: [] }),
21 | reset: () => set(INITIAL_STATE),
22 | }));
23 |
--------------------------------------------------------------------------------
/client/src/shared/store/useSocketStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { createSocket } from '../api/socket';
3 | import { useVoteStore } from './useVoteStore';
4 | import { SocketActions, SocketState } from './state/socketState';
5 | import { useChatMessageStore } from './useChatMessageStore';
6 | import { setupSocketListeners } from './utils/socketEvents';
7 |
8 | export const useSocketStore = create(
9 | (set, get) => ({
10 | socket: null,
11 | isConnected: false,
12 | roomId: null,
13 | userCount: 0,
14 |
15 | setConnectionStatus: (status: boolean) => set({ isConnected: status }),
16 |
17 | setUserCount: (count: number) => set({ userCount: count }),
18 |
19 | resetAllStores: () => {
20 | useVoteStore.getState().reset();
21 | useChatMessageStore.getState().reset();
22 | },
23 |
24 | connect: (newRoomId: string) => {
25 | const { socket, roomId } = get();
26 | if (roomId === newRoomId && socket?.connected) return;
27 |
28 | const newSocket = createSocket(newRoomId);
29 | setupSocketListeners(newSocket, {
30 | socketStore: get(),
31 | voteStore: useVoteStore.getState(),
32 | chatStore: useChatMessageStore.getState(),
33 | });
34 |
35 | newSocket.connect();
36 | set({ socket: newSocket, roomId: newRoomId });
37 | },
38 |
39 | disconnect: () => {
40 | const { socket } = get();
41 | if (socket) {
42 | socket.removeAllListeners();
43 | socket.disconnect();
44 | get().resetAllStores();
45 | set({ isConnected: false, socket: null, roomId: null });
46 | }
47 | },
48 |
49 | reset: () => {
50 | const { socket } = get();
51 | if (socket) {
52 | socket.removeAllListeners();
53 | socket.disconnect();
54 | get().resetAllStores();
55 | }
56 | set({ socket: null, isConnected: false, roomId: null });
57 | },
58 | }),
59 | );
60 |
--------------------------------------------------------------------------------
/client/src/shared/store/useVoteStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { VoteActions, VoteState, VoteType } from './state/voteState';
3 |
4 | const INITIAL_STATE: VoteType = {
5 | votes: {},
6 | trackNumber: '',
7 | };
8 |
9 | export const useVoteStore = create((set) => ({
10 | voteData: {
11 | votes: {},
12 | trackNumber: '',
13 | },
14 |
15 | showVote: (voteData: VoteType) =>
16 | set((state) => ({
17 | voteData: {
18 | ...state.voteData,
19 | ...voteData,
20 | },
21 | })),
22 |
23 | updateVote: (voteData: VoteType) =>
24 | set((state) => ({
25 | voteData: {
26 | ...state.voteData,
27 | votes: voteData.votes,
28 | },
29 | })),
30 |
31 | reset: () => set({ voteData: INITIAL_STATE }),
32 | }));
33 |
--------------------------------------------------------------------------------
/client/src/shared/store/utils/socketEvents.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io-client';
2 | import { ChatActions } from '../state/chatState';
3 | import { SocketActions } from '../state/socketState';
4 | import { VoteActions, VoteType } from '../state/voteState';
5 | import { MessageData } from '@/entities/message/types';
6 |
7 | interface Stores {
8 | socketStore: SocketActions;
9 | voteStore: VoteActions;
10 | chatStore: ChatActions;
11 | }
12 |
13 | export const setupSocketListeners = (socket: Socket, stores: Stores) => {
14 | socket.on('connect', () => {
15 | stores.socketStore.setConnectionStatus(true);
16 | });
17 |
18 | socket.on('disconnect', () => {
19 | stores.socketStore.setConnectionStatus(false);
20 | });
21 |
22 | socket.on('voteShow', (data: VoteType) => {
23 | stores.voteStore.showVote(data);
24 | });
25 |
26 | socket.on('voteUpdated', (data: VoteType) => {
27 | stores.voteStore.updateVote(data);
28 | });
29 |
30 | socket.on(
31 | 'roomUsersUpdated',
32 | (data: { roomId: string; userCount: number }) => {
33 | stores.socketStore.setUserCount(data.userCount);
34 | },
35 | );
36 |
37 | socket.on('broadcast', (data: MessageData) => {
38 | stores.chatStore.addMessage(data);
39 | });
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/shared/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | interface ButtonProps extends React.ButtonHTMLAttributes {
2 | message: string;
3 | }
4 |
5 | export function Button({ message, ...props }: ButtonProps) {
6 | return (
7 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/shared/ui/Input.tsx:
--------------------------------------------------------------------------------
1 | interface InputProps extends React.InputHTMLAttributes {
2 | labelName: string;
3 | type?: string;
4 | placeholder?: string;
5 | }
6 |
7 | export function Input({
8 | labelName,
9 | type = 'text',
10 | placeholder = '',
11 | ...props
12 | }: InputProps) {
13 | return (
14 |
15 |
16 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/shared/ui/Timer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useCallback } from 'react';
2 |
3 | interface TimerProps {
4 | releaseDate: string;
5 | onCountdownComplete?: () => void;
6 | timerClassName?: string;
7 | }
8 |
9 | export const Timer = React.memo(function Timer({
10 | releaseDate,
11 | onCountdownComplete,
12 | timerClassName = 'text-5xl font-bold mb-4',
13 | }: TimerProps) {
14 | const [timeRemaining, setTimeRemaining] = useState('00:00:00');
15 |
16 | const calculateTimeRemaining = useCallback(() => {
17 | const releaseTime = new Date(releaseDate).getTime();
18 | const now = new Date().getTime();
19 | const distance = releaseTime - now;
20 |
21 | if (distance < 0) {
22 | setTimeRemaining('00:00:00');
23 | onCountdownComplete?.();
24 | return null;
25 | }
26 |
27 | const hours = Math.floor(
28 | (distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
29 | );
30 | const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
31 | const seconds = Math.floor((distance % (1000 * 60)) / 1000);
32 |
33 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
34 | }, [releaseDate, onCountdownComplete]);
35 |
36 | useEffect(() => {
37 | let timer: NodeJS.Timeout;
38 |
39 | if (releaseDate) {
40 | const updateTimer = () => {
41 | const newTime = calculateTimeRemaining();
42 | if (newTime) setTimeRemaining(newTime);
43 | };
44 |
45 | updateTimer();
46 | timer = setInterval(updateTimer, 1000);
47 | }
48 |
49 | return () => timer && clearInterval(timer);
50 | }, [calculateTimeRemaining, releaseDate]);
51 |
52 | return {timeRemaining}
;
53 | });
54 |
--------------------------------------------------------------------------------
/client/src/shared/ui/index.ts:
--------------------------------------------------------------------------------
1 | export { Button } from './Button';
2 | export { Input } from './Input';
3 | export { Timer } from './Timer';
4 |
--------------------------------------------------------------------------------
/client/src/shared/util/timeUtils.ts:
--------------------------------------------------------------------------------
1 | export type TimeZoneOffset = {
2 | readonly KST: 9;
3 | };
4 |
5 | export const TIMEZONE_OFFSET: TimeZoneOffset = {
6 | KST: 9,
7 | } as const;
8 |
9 | /**
10 | * 한국 시간으로 변환
11 | * @param dateString
12 | * @returns
13 | */
14 | export function convertToKTC(dateString: string) {
15 | const date = new Date(dateString);
16 | date.setHours(date.getHours() + TIMEZONE_OFFSET.KST);
17 | return date.toISOString();
18 | }
19 | /**
20 | * 월/일 시간 문자열 반환
21 | * @param dateString
22 | * @returns
23 | */
24 | export function splitDate(dateString: string) {
25 | const [date, time] = dateString.split('T');
26 | const [_, month, day] = date.split('-');
27 | return `${month}월 ${day}일 ${time.slice(0, 5)}`;
28 | }
29 | /**
30 | * 시/분/초 문자열 반환
31 | * @param time
32 | * @returns
33 | */
34 | export function splitTime(time: string) {
35 | const hour = Math.floor(Number(time) / 3600);
36 | const minute = Math.floor((Number(time) % 3600) / 60);
37 | const second = Math.floor(Number(time) % 60);
38 |
39 | return (
40 | (hour > 0 ? String(hour).padStart(2, '0') + ':' : '') +
41 | String(minute).padStart(2, '0') +
42 | ':' +
43 | String(second).padStart(2, '0')
44 | );
45 | }
46 |
47 | /**
48 | * 초를 더한 시간 반환
49 | * @param dateString
50 | * @param seconds
51 | * @returns
52 | */
53 | export function sumSeconds(dateString: string, seconds: number) {
54 | const date = new Date(dateString);
55 | date.setTime(date.getTime() + seconds * 1000);
56 | return date;
57 | }
58 |
59 | /**
60 | * 시간 비교 0보다 크면 date1이 더 큼
61 | * @param date1
62 | * @param date2
63 | * @returns
64 | */
65 | export function compareDate(date1: Date, date2: Date) {
66 | return date1.getTime() - date2.getTime();
67 | }
68 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/index.ts:
--------------------------------------------------------------------------------
1 | export { AlbumList } from './ui/AlbumList';
2 | export { CommentList } from './ui/CommentList.tsx';
3 | export { Playlist } from './ui/Playlist';
4 | export { AlbumArtist } from './ui/AlbumArtist';
5 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/AlbumArtist.tsx:
--------------------------------------------------------------------------------
1 | interface AlbumArtistProps {
2 | artist: string;
3 | songLength: number;
4 | totalDuration: number;
5 | }
6 |
7 | export function AlbumArtist({
8 | artist,
9 | songLength,
10 | totalDuration,
11 | }: AlbumArtistProps) {
12 | const hour = Math.floor(Number(totalDuration) / 3600);
13 | const minute = Math.floor((Number(totalDuration) % 3600) / 60);
14 | const second = Math.floor(Number(totalDuration) % 60);
15 |
16 | return (
17 |
22 | {artist}
23 |
24 | •
25 | {songLength}곡
26 |
27 |
28 | •
29 |
30 | {(hour > 0 ? String(hour).padStart(2, '0') + '시간 ' : '') +
31 | String(minute).padStart(2, '0') +
32 | '분 ' +
33 | String(second).padStart(2, '0') +
34 | '초'}
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/AlbumCard.tsx:
--------------------------------------------------------------------------------
1 | interface AlbumCard {
2 | albumId: string;
3 | albumName: string;
4 | artist: string;
5 | albumTags?: string;
6 | jacketUrl: string;
7 | }
8 |
9 | export function AlbumCard({ album }: { album: AlbumCard }) {
10 | const tagString = album.albumTags
11 | ? `#${album.albumTags.split(', ').join(' #')}`
12 | : '태그 없음';
13 | return (
14 |
18 |
23 | {album.albumName}
24 | {album.artist}
25 | {tagString}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/AlbumList.tsx:
--------------------------------------------------------------------------------
1 | import LogoAlbum from '@/assets/logo-album-cover.webp';
2 | import { AlbumCard } from './AlbumCard';
3 | import { useEffect, useState } from 'react';
4 | import { EndedAlbumListResponse } from '@/entities/room/types';
5 | import { publicAPI } from '@/shared/api/publicAPI';
6 |
7 | export function AlbumList() {
8 | const [endedAlbumList, setEndedAlbumList] =
9 | useState();
10 |
11 | useEffect(() => {
12 | const getAlbumEnded = async () => {
13 | const res = await publicAPI
14 | .getAlbumEnded()
15 | .then((res) => res)
16 | .catch((err) => console.log(err));
17 | setEndedAlbumList(res.result);
18 | };
19 |
20 | getAlbumEnded();
21 | }, []);
22 |
23 | if (!endedAlbumList) return null;
24 | return (
25 |
26 |
최근 등록된 앨범
27 |
28 | {endedAlbumList.endedAlbums.slice(0, 7).map((album) => (
29 | -
30 |
39 |
40 | ))}
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/Comment.tsx:
--------------------------------------------------------------------------------
1 | import { CommentData } from '@/entities/comment/types';
2 | interface CommentProps {
3 | comment: CommentData;
4 | index: number;
5 | }
6 |
7 | export function Comment({ comment, index }: CommentProps) {
8 | const date: Date = new Date(comment.createdAt);
9 | const dateFormat = `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}.`;
10 | return (
11 |
12 | 댓글 #{index + 1}
13 | {comment.content}
14 | {dateFormat}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/CommentList.tsx:
--------------------------------------------------------------------------------
1 | import { Comment } from './Comment.tsx';
2 | import { publicAPI } from '@/shared/api/publicAPI.ts';
3 | import { useEffect, useState } from 'react';
4 | import { CommentInput } from '@/features/commentInput';
5 | interface CommentListProps {
6 | albumId: string;
7 | }
8 |
9 | interface Comment {
10 | albumId: string;
11 | content: string;
12 | createdAt: string;
13 | }
14 |
15 | export function CommentList({ albumId }: CommentListProps) {
16 | const [commentList, setCommentList] = useState([]);
17 |
18 | useEffect(() => {
19 | (async () => {
20 | const commentResponse = await publicAPI
21 | .getComment(albumId)
22 | .catch((err) => console.log(err));
23 |
24 | setCommentList(commentResponse.result.albumComments);
25 | })();
26 | }, []);
27 |
28 | return (
29 |
30 |
31 | 코멘트
32 |
33 | 최신 10개의 댓글만 조회합니다.
34 |
35 |
36 |
37 | {commentList.slice(0, 10).map((comment, index) => (
38 |
39 | ))}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/Playlist.tsx:
--------------------------------------------------------------------------------
1 | import { TrackItem } from './TrackItem';
2 | import './Scrollbar.css';
3 | import { SongDetailData } from '@/entities/comment/types';
4 | export interface PlaylistComponentProps {
5 | playlist: SongDetailData[];
6 | }
7 |
8 | export function Playlist({ playlist }: PlaylistComponentProps) {
9 | return (
10 |
11 | {playlist.map((item, index) => (
12 |
13 | ))}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/Scrollbar.css:
--------------------------------------------------------------------------------
1 | .playlist::-webkit-scrollbar-thumb {
2 | background: #fafafa !important;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/widgets/albums/ui/TrackItem.tsx:
--------------------------------------------------------------------------------
1 | import { SongDetailData } from '@/entities/comment/types';
2 | import { splitTime } from '@/shared/util/timeUtils';
3 | interface TrackItemProps {
4 | trackData: SongDetailData;
5 | index: number;
6 | }
7 |
8 | export function TrackItem({ trackData, index }: TrackItemProps) {
9 | return (
10 |
15 |
19 |
20 |
21 | {splitTime(trackData.songDuration)}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/widgets/banner/index.ts:
--------------------------------------------------------------------------------
1 | export { Banner } from './ui/Banner';
2 |
--------------------------------------------------------------------------------
/client/src/widgets/banner/ui/Banner.css:
--------------------------------------------------------------------------------
1 | .banner-swiper .swiper-pagination {
2 | background-color: #121212;
3 | width: fit-content !important;
4 | left: 50% !important;
5 | transform: translateX(-50%);
6 | padding: 0 6px 0 6px;
7 | border-radius: 1rem;
8 | }
9 |
10 | .banner-swiper .swiper-wrapper .swiper-pagination {
11 | background-color: red;
12 | width: 200px;
13 | left: 50%;
14 | transform: translateX(-50%);
15 | }
16 |
17 | .banner-swiper .swiper-pagination-bullet {
18 | background-color: #5a5a5a;
19 | opacity: 0.8;
20 | }
21 |
22 | .banner-swiper .swiper-pagination-bullet.swiper-pagination-bullet-active {
23 | background-color: #00ff99;
24 | opacity: 1;
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/widgets/banner/ui/Banner.tsx:
--------------------------------------------------------------------------------
1 | import { Swiper, SwiperSlide } from 'swiper/react';
2 | import { BannerSlide } from './BannerSlide';
3 | import { Pagination, Autoplay } from 'swiper/modules';
4 | import { bannerData } from '@/entities/room/types';
5 | import './Banner.css';
6 | import 'swiper/css';
7 | import 'swiper/css/pagination';
8 |
9 | interface BannerProps {
10 | bannerList: bannerData[];
11 | }
12 |
13 | export function Banner({ bannerList }: BannerProps) {
14 | return (
15 |
16 |
28 | {bannerList.map((banner) => (
29 |
30 |
31 |
32 | ))}
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/client/src/widgets/banner/ui/BannerSlide.tsx:
--------------------------------------------------------------------------------
1 | import { bannerData } from '@/entities/room/types';
2 | import 'swiper/css';
3 | import 'swiper/css/pagination';
4 | import { useNavigate } from 'react-router-dom';
5 | import { compareDate, convertToKTC, splitDate } from '@/shared/util/timeUtils';
6 |
7 | interface BannerSlideProps {
8 | banner: bannerData;
9 | }
10 |
11 | export function BannerSlide({ banner }: BannerSlideProps) {
12 | const navigate = useNavigate();
13 | const handleClick = () => {
14 | if (!banner.albumId) return;
15 | navigate(`/streaming/${banner.albumId}`);
16 | };
17 | const checkLive = compareDate(new Date(), new Date(banner.releaseDate)) > 0;
18 | return (
19 |
20 |

25 | {banner.albumId && (
26 |
27 |
28 |
29 |
32 | LIVE
33 |
34 | {checkLive && (
35 |
{banner.currentUserCount} 명
36 | )}
37 |
38 |
39 | #{banner.albumTags?.split(',').join(' #')}
40 |
41 |
{banner.albumName}
42 |
43 |
44 |
{banner.artist}
45 |
46 | {splitDate(convertToKTC(banner.releaseDate))} 시작
47 |
48 |
49 |
50 | )}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/hook/useChatMessage.ts:
--------------------------------------------------------------------------------
1 | import { useChatMessageStore } from '@/shared/store/useChatMessageStore';
2 |
3 | export function useChatMessage() {
4 | const messages = useChatMessageStore((state) => state.messages);
5 | return { messages };
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/index.ts:
--------------------------------------------------------------------------------
1 | export { Chatting } from './ui/Chatting';
2 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/ui/Chatting.css:
--------------------------------------------------------------------------------
1 | .chatting::-webkit-scrollbar {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/ui/Chatting.tsx:
--------------------------------------------------------------------------------
1 | import { ChattingContainer } from './ChattingContainer';
2 | import { UserCounter } from '@/features/useCounter/ui/UserCounter';
3 |
4 | export function Chatting() {
5 | return (
6 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/ui/ChattingContainer.tsx:
--------------------------------------------------------------------------------
1 | import { ChatInput } from '@/features/chattingInput';
2 | import { useChatMessage } from '../hook/useChatMessage';
3 | import MessageList from './MessageList';
4 |
5 | export function ChattingContainer() {
6 | const { messages } = useChatMessage();
7 |
8 | return (
9 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/ui/Message.tsx:
--------------------------------------------------------------------------------
1 | interface MessageData {
2 | userName: string;
3 | message: string;
4 | userId: string;
5 | }
6 |
7 | export function Message({ userName, message, userId }: MessageData) {
8 | return (
9 |
10 | {userName}
11 |
12 | #{userId}
13 |
14 | {message}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/ui/MessageList.tsx:
--------------------------------------------------------------------------------
1 | import { MessageData } from '@/entities/message/types';
2 | import { Message } from './Message';
3 | import './Chatting.css';
4 | import React, { useEffect } from 'react';
5 |
6 | interface MessageListProps {
7 | messages: MessageData[];
8 | }
9 |
10 | function MessageList({ messages }: MessageListProps) {
11 | const messageEndRef = React.useRef(null);
12 |
13 | useEffect(() => {
14 | if (!messageEndRef.current) return;
15 | messageEndRef.current.scrollIntoView({ behavior: 'smooth' });
16 | }, [messages]);
17 | return (
18 |
19 | {messages.map((msg, index) => (
20 |
26 | ))}
27 |
28 |
29 | );
30 | }
31 |
32 | export default React.memo(MessageList);
33 |
--------------------------------------------------------------------------------
/client/src/widgets/chatting/useChatMessage.ts:
--------------------------------------------------------------------------------
1 | import { useChatMessageStore } from '@/shared/store/useChatMessageStore';
2 |
3 | export function useChatMessage() {
4 | const messages = useChatMessageStore((state) => state.messages);
5 | return { messages };
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/hook/useSidebarAlbum.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { publicAPI } from '@/shared/api/publicAPI';
3 | import { useLocation } from 'react-router-dom';
4 | export const useSidebarAlbum = () => {
5 | const location = useLocation();
6 | return useQuery({
7 | queryKey: ['albumSidebar', location.pathname],
8 | queryFn: () => publicAPI.getAlbumSidebar(),
9 | refetchOnWindowFocus: true,
10 | refetchOnMount: true,
11 | staleTime: 10000,
12 | refetchInterval: 15000,
13 | throwOnError: true,
14 | });
15 | };
16 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/index.ts:
--------------------------------------------------------------------------------
1 | export { Sidebar } from './ui/Sidebar';
2 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/ui/Credit.tsx:
--------------------------------------------------------------------------------
1 | import GithubLogo from '@/assets/github-logo.png';
2 |
3 | export function Credit() {
4 | return (
5 |
6 |
10 |
15 |
16 |
17 | 우리에게 커피를 사주고 싶습니까?
18 |
19 |
Created By: BST(버그사냥단)
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/ui/RoomList.tsx:
--------------------------------------------------------------------------------
1 | import { RoomListItem } from './RoomListItem';
2 | import './StreamingList.css';
3 | import { AlbumData } from '@/entities/room/types';
4 | import { useSidebarAlbum } from '@/widgets/sidebar/hook/useSidebarAlbum';
5 |
6 | function RoomListContainer({
7 | albums,
8 | title,
9 | }: {
10 | albums?: AlbumData[];
11 | title: string;
12 | }) {
13 | return (
14 |
15 |
{title}
16 | {albums?.length !== 0 ? (
17 |
18 | {albums?.map((album) => (
19 |
20 | ))}
21 |
22 | ) : (
23 |
방이 없습니다
24 | )}
25 |
26 | );
27 | }
28 |
29 | export function RoomList() {
30 | const { data: roomList, isLoading, isError, error } = useSidebarAlbum();
31 |
32 | return (
33 |
34 |
38 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/ui/RoomListItem.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from 'react-router-dom';
2 | import { AlbumData } from '@/entities/room/types';
3 | interface RoomListItemProps {
4 | album: AlbumData;
5 | }
6 |
7 | export function RoomListItem({ album }: RoomListItemProps) {
8 | const navigate = useNavigate();
9 | const tagString = album.albumTags
10 | ? `#${album.albumTags.split(', ').join(' #')}`
11 | : '태그 없음';
12 |
13 | const handleClick = () => {
14 | navigate(`/streaming/${album.albumId}`);
15 | };
16 |
17 | return (
18 |
19 | {album.albumName}
20 | {tagString}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/ui/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { InearLogo } from '@/shared/icon/InearLogo';
2 | import { RoomList } from './RoomList';
3 | import { Credit } from './Credit';
4 | import { Link } from 'react-router-dom';
5 | import { NetworkBoundary } from '@/NetworkBoundary';
6 | export function Sidebar() {
7 | return (
8 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/widgets/sidebar/ui/StreamingList.css:
--------------------------------------------------------------------------------
1 | .streaming-list::-webkit-scrollbar {
2 | background-color: #121212;
3 | width: 6px;
4 | }
5 |
6 | .streaming-list::-webkit-scrollbar-thumb {
7 | background-color: #414141;
8 | /* border-radius: 4px; */
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/widgets/streaming/index.ts:
--------------------------------------------------------------------------------
1 | export { Streaming } from './ui/Streaming';
2 |
--------------------------------------------------------------------------------
/client/src/widgets/streaming/ui/AlbumBackground.tsx:
--------------------------------------------------------------------------------
1 | interface AlbumBackgroundProps {
2 | coverImage: string;
3 | }
4 |
5 | export function AlbumBackground({ coverImage }: AlbumBackgroundProps) {
6 | return (
7 | <>
8 |
13 |
14 | >
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/widgets/streaming/ui/AudioController.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | interface AudioControllerProps {
4 | audioRef: React.RefObject;
5 | songDuration: number;
6 | }
7 |
8 | export const AudioController = ({
9 | audioRef,
10 | songDuration,
11 | }: AudioControllerProps) => {
12 | const [progress, setProgress] = useState(0);
13 |
14 | useEffect(() => {
15 | const audio = audioRef.current;
16 | if (!audio) return;
17 | setProgress(0);
18 | const updateProgress = () => {
19 | const progressPercent =
20 | ((songDuration - audio.duration + audio.currentTime) / songDuration) *
21 | 100;
22 | setProgress(progressPercent);
23 | };
24 |
25 | audio.addEventListener('timeupdate', updateProgress);
26 |
27 | return () => {
28 | audio.removeEventListener('timeupdate', updateProgress);
29 | };
30 | }, [songDuration]);
31 |
32 | return (
33 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/client/src/widgets/streaming/ui/Streaming.tsx:
--------------------------------------------------------------------------------
1 | import { AlbumBackground } from './AlbumBackground';
2 | import { AlbumInfo } from './AlbumInfo';
3 | import { RoomResponse } from '@/entities/album/types';
4 | import DefaultCover from '@/assets/logo-album-cover.webp';
5 | import { SongDetail } from '@/features/songDetail';
6 |
7 | interface StreamingProps {
8 | roomInfo: RoomResponse;
9 | songIndex: number;
10 | setSongIndex: (value: React.SetStateAction) => void;
11 | }
12 |
13 | export function Streaming({
14 | roomInfo,
15 | songIndex,
16 | setSongIndex,
17 | }: StreamingProps) {
18 | return (
19 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/client/src/widgets/streaming/ui/Volume.css:
--------------------------------------------------------------------------------
1 | .volume-range {
2 | overflow: hidden;
3 | height: 6px;
4 | -webkit-appearance: none;
5 | background: #f3f3f3;
6 | transition: width 0.3s ease-in-out;
7 | }
8 |
9 | .volume-range:focus {
10 | outline: none;
11 | }
12 |
13 | .volume-range::-webkit-slider-runnable-track {
14 | cursor: pointer;
15 | /* border: 2px solid #ff96ab; */
16 | }
17 |
18 | .volume-range::-webkit-slider-thumb {
19 | -webkit-appearance: none;
20 | width: 8px;
21 | height: 8px;
22 | background: #00af69;
23 | /* box-shadow: 1px 1px 7px #d16a6e; */
24 | cursor: pointer;
25 | box-shadow: -100vw 0 0 100vw #00e589;
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/widgets/vote/index.ts:
--------------------------------------------------------------------------------
1 | export { Vote } from './ui/Vote';
2 |
--------------------------------------------------------------------------------
/client/src/widgets/vote/ui/ScrollBar.css:
--------------------------------------------------------------------------------
1 | *::-webkit-scrollbar {
2 | /*background-color: #2a2a2a;*/
3 | width: 6px;
4 | }
5 |
6 | *::-webkit-scrollbar-thumb {
7 | background-color: #1e1e1e;
8 | /*border-radius: 4px;*/
9 | }
10 |
11 | .vote:hover .votebg {
12 | opacity: 75%;
13 | color: #121212;
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/widgets/vote/ui/Vote.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronDown } from '@/shared/icon/ChevronDown';
2 | import { useState } from 'react';
3 | import { useSocketStore } from '@/shared/store/useSocketStore';
4 | import { SongData } from '@/entities/album/types.ts';
5 | import { useVote } from '@/widgets/vote/useVote.ts';
6 | import './ScrollBar.css';
7 |
8 | export function Vote({ songs }: { songs: SongData[] }) {
9 | const [isOpen, setIsOpen] = useState(false);
10 | const { voteData } = useVote();
11 | const socket = useSocketStore((state) => state.socket);
12 |
13 | const handleVoteClick = (trackNumber: string) => {
14 | if (!socket) return;
15 | socket.emit('vote', { trackNumber });
16 | };
17 | return (
18 |
19 |
setIsOpen(!isOpen)}
22 | >
23 |
24 |
최애의 트랙
25 |
내 취향 음악은?
26 |
27 |
30 |
31 |
32 |
33 |
36 | {Object.values(voteData.votes).map((item, index) =>
37 | songs[index] ? (
38 |
handleVoteClick(String(index + 1))}
42 | >
43 |
44 |
48 |
51 | {songs[index].title}
52 |
53 |
54 |
57 |
58 | ) : null,
59 | )}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/client/src/widgets/vote/useVote.ts:
--------------------------------------------------------------------------------
1 | import { useVoteStore } from '@/shared/store/useVoteStore';
2 |
3 | export function useVote() {
4 | const voteData = useVoteStore((state) => state.voteData);
5 | return { voteData };
6 | }
7 |
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
4 | theme: {
5 | extend: {
6 | colors: {
7 | brand: '#00FF99',
8 | point: '#E02020',
9 | grayscale: {
10 | 50: '#FAFAFA',
11 | 100: '#F3F3F3',
12 | 150: '#E8E8E8',
13 | 200: '#D0D0D0',
14 | 300: '#B8B8B8',
15 | 400: '#898989',
16 | 500: '#5A5A5A',
17 | 600: '#414141',
18 | 700: '#2A2A2A',
19 | 800: '#1E1E1E',
20 | 900: '#121212',
21 | },
22 | },
23 | },
24 | },
25 | plugins: [],
26 | };
27 |
--------------------------------------------------------------------------------
/client/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "Bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true
23 | // "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx",
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 |
22 | /* 경로 설정 */
23 | "baseUrl": "./",
24 | "paths": {
25 | "@/*": ["src/*"]
26 | }
27 | },
28 | "include": ["src"]
29 | }
30 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "Bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true
21 | // "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import path from 'path';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [react(), tsconfigPaths()],
9 | server: {
10 | open: true,
11 | proxy: {
12 | '/images': {
13 | target: 'https://inear-music.kr.object.ncloudstorage.com',
14 | changeOrigin: true,
15 | rewrite: (path) => path.replace(/^\/images/, ''),
16 | },
17 | },
18 | },
19 | resolve: {
20 | alias: [{ find: '@', replacement: path.resolve(__dirname, 'src') }],
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | networks:
2 | webapp:
3 | name: webapp
4 |
5 | services:
6 | server:
7 | build:
8 | context: .
9 | dockerfile: server/Dockerfile
10 | container_name: server
11 | volumes:
12 | - ./server:/app/server
13 | - ./tsconfig.json:/app/tsconfig.json
14 | - ./package.json:/app/package.json
15 | - ./yarn.lock:/app/yarn.lock
16 | - ./.yarnrc.yml:/app/.yarnrc.yml
17 | - ./.yarn:/app/.yarn
18 | command:
19 | - yarn
20 | - --cwd
21 | - /app/server
22 | - dev
23 | environment:
24 | - NODE_ENV=development
25 | networks:
26 | - webapp
27 | healthcheck:
28 | test:
29 | [
30 | 'CMD-SHELL',
31 | 'wget -q --spider http://server:3000/api/health || exit 1',
32 | ]
33 | interval: 7s
34 | timeout: 10s
35 | retries: 5
36 | restart: unless-stopped
37 |
38 | client:
39 | build:
40 | context: .
41 | dockerfile: client/Dockerfile
42 | container_name: client
43 | volumes:
44 | - ./client:/app/client
45 | - ./package.json:/app/package.json
46 | - ./yarn.lock:/app/yarn.lock
47 | - ./.yarnrc.yml:/app/.yarnrc.yml
48 | - ./.yarn:/app/.yarn
49 | environment:
50 | - NODE_ENV=development
51 | - CHOKIDAR_USEPOLLING=true
52 | - WATCHPACK_POLLING=true
53 | networks:
54 | - webapp
55 | restart: unless-stopped
56 |
57 | nginx:
58 | build:
59 | context: .
60 | dockerfile: nginx/Dockerfile
61 | container_name: nginx
62 | volumes:
63 | - ./nginx/conf.d:/etc/nginx/conf.d:ro
64 | - ./client/dist/:/usr/share/nginx/html
65 | ports:
66 | - '80:80'
67 | depends_on:
68 | - server
69 | - client
70 | networks:
71 | - webapp
72 | restart: unless-stopped
73 |
--------------------------------------------------------------------------------
/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:alpine
2 |
3 | COPY nginx/nginx.conf /etc/nginx/nginx.conf
--------------------------------------------------------------------------------
/nginx/conf.d/default.conf:
--------------------------------------------------------------------------------
1 | upstream frontend {
2 | server client:5173;
3 | }
4 | upstream backend {
5 | server server:3000;
6 | }
7 |
8 | server {
9 | listen 80;
10 | server_name localhost;
11 |
12 | proxy_http_version 1.1;
13 | proxy_set_header Upgrade $http_upgrade;
14 | proxy_set_header Connection 'upgrade';
15 | proxy_set_header Host $host;
16 | client_max_body_size 100M;
17 |
18 | location / {
19 | proxy_pass http://frontend;
20 | }
21 |
22 | location /api {
23 | proxy_pass http://backend;
24 | }
25 |
26 | location /socket.io {
27 | proxy_pass http://backend;
28 | }
29 | }
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 |
2 | user nginx;
3 | worker_processes auto;
4 |
5 | error_log /var/log/nginx/error.log notice;
6 | pid /var/run/nginx.pid;
7 |
8 |
9 | events {
10 | worker_connections 1024;
11 | }
12 |
13 |
14 | http {
15 | include /etc/nginx/mime.types;
16 | default_type application/octet-stream;
17 |
18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
19 | '$status $body_bytes_sent "$http_referer" '
20 | '"$http_user_agent" "$http_x_forwarded_for"';
21 |
22 | access_log /var/log/nginx/access.log main;
23 |
24 | sendfile on;
25 | #tcp_nopush on;
26 |
27 | keepalive_timeout 65;
28 |
29 | #gzip on;
30 |
31 | include /etc/nginx/conf.d/*.conf;
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "type": "module",
4 | "workspaces": [
5 | "client",
6 | "server"
7 | ],
8 | "resolutions": {
9 | "path-to-regexp": "^0.1.12"
10 | },
11 | "scripts": {
12 | "client": "yarn workspace client",
13 | "server": "yarn workspace server",
14 | "dev:client": "cd client && yarn dev",
15 | "dev:server": "cd server && yarn dev",
16 | "dev": "concurrently \"yarn dev:client\" \"yarn dev:server\"",
17 | "build:client": "cd client && yarn build",
18 | "build:server": "cd server && yarn build",
19 | "build": "yarn build:client && yarn build:server",
20 | "lint": "eslint .",
21 | "lint:fix": "eslint . --fix",
22 | "lint:server": "eslint server/",
23 | "lint:client": "eslint client/",
24 | "lint:server:fix": "eslint server/ --fix",
25 | "lint:client:fix": "eslint client/ --fix",
26 | "format": "prettier --write \"src/**/*.ts\"",
27 | "test:server": "cd server && yarn test",
28 | "test:server:cov": "cd server && yarn test:cov"
29 | },
30 | "packageManager": "yarn@4.5.1",
31 | "devDependencies": {
32 | "@typescript-eslint/eslint-plugin": "^7.0.0",
33 | "@typescript-eslint/parser": "^7.0.0",
34 | "eslint": "^8.56.0",
35 | "eslint-config-airbnb": "^19.0.4",
36 | "eslint-config-airbnb-base": "^15.0.0",
37 | "eslint-config-airbnb-typescript": "^18.0.0",
38 | "eslint-config-prettier": "~9.1.0",
39 | "eslint-config-standard": "~17.1.0",
40 | "eslint-import-resolver-typescript": "^3.6.1",
41 | "eslint-plugin-import": "^2.29.1",
42 | "eslint-plugin-jsx-a11y": "^6.8.0",
43 | "eslint-plugin-n": "~16.6.2",
44 | "eslint-plugin-prettier": "~5.1.3",
45 | "eslint-plugin-promise": "~6.1.1",
46 | "eslint-plugin-react": "^7.33.2",
47 | "eslint-plugin-react-hooks": "^4.6.0",
48 | "globals": "^15.12.0",
49 | "typescript": "^5.6.3",
50 | "typescript-eslint": "^8.13.0"
51 | },
52 | "dependencies": {
53 | "@types/fluent-ffmpeg": "^2.1.27",
54 | "concurrently": "^9.1.0",
55 | "fluent-ffmpeg": "^2.1.3"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/server/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js,json,yml}]
8 | charset = utf-8
9 | indent_style = space
10 | indent_size = 2
11 |
--------------------------------------------------------------------------------
/server/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/** linguist-vendored
2 | /.yarn/releases/* binary
3 | /.yarn/plugins/**/* binary
4 | /.pnp.* binary linguist-generated
5 | node_modules/
--------------------------------------------------------------------------------
/server/.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 | .env.test
16 | # OS
17 | .DS_Store
18 |
19 | # Tests
20 | /coverage
21 | /.nyc_output
22 |
23 | # IDEs and editors
24 | /.idea
25 | .project
26 | .classpath
27 | .c9/
28 | *.launch
29 | .settings/
30 | *.sublime-workspace
31 |
32 | # IDE - VSCode
33 | .vscode/*
34 | !.vscode/settings.json
35 | !.vscode/tasks.json
36 | !.vscode/launch.json
37 | !.vscode/extensions.json
38 |
39 | # dotenv environment variable files
40 | .env
41 | .env.development.local
42 | .env.test.local
43 | .env.production.local
44 | .env.local
45 |
46 | test/music
47 |
48 | # temp directory
49 | .temp
50 | .tmp
51 |
52 | # Runtime data
53 | pids
54 | *.pid
55 | *.seed
56 | *.pid.lock
57 |
58 | # Diagnostic reports (https://nodejs.org/api/report.html)
59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
60 |
61 | .env.development
62 | .env.production
--------------------------------------------------------------------------------
/server/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "arcanis.vscode-zipfs"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/server/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "search.exclude": {
3 | "**/.yarn": true,
4 | "**/.pnp.*": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/server/.yarn/install-state.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2024/web18-inear/54ca8af29187610ecec421ad61b53789006f7b29/server/.yarn/install-state.gz
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS builder
2 |
3 | WORKDIR /app
4 |
5 | COPY package.json yarn.lock .yarnrc.yml tsconfig.json ./
6 | COPY .yarn .yarn
7 | COPY server server
8 |
9 | RUN corepack enable
10 | RUN yarn install
11 |
12 | WORKDIR /app/server
13 | RUN yarn install
14 | RUN yarn build
15 |
16 | FROM node:22-alpine
17 |
18 | WORKDIR /app
19 |
20 | RUN apk add --no-cache ffmpeg
21 | RUN corepack enable
22 |
23 | COPY --from=builder /app/server/package.json ./package.json
24 | COPY --from=builder /app/yarn.lock ./yarn.lock
25 | COPY --from=builder /app/.yarnrc.yml ./.yarnrc.yml
26 | COPY --from=builder /app/.yarn ./.yarn
27 | COPY --from=builder /app/server/dist ./dist
28 |
29 | RUN yarn workspaces focus --production --all
30 |
31 | EXPOSE 3000
32 | CMD ["node", "dist/main.js"]
33 |
--------------------------------------------------------------------------------
/server/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/socket-test.yml:
--------------------------------------------------------------------------------
1 | config:
2 | # 일단 로컬 환경 URL
3 | target: 'http://localhost:3000'
4 | phases:
5 | # 10초 동안
6 | - duration: 10
7 | # 1초에 20명
8 | arrivalRate: 20
9 | engines:
10 | socketio-v3:
11 | transports: ['websocket']
12 | timeout: 10000
13 | variables:
14 | # 원하는 roomId 설정
15 | roomId: '7b82b46b-d705-48a5-9bc5-918ee1a124a0'
16 | scenarios:
17 | - name: 'Chat room flow'
18 | engine: socketio-v3
19 | flow:
20 | - think: 1
21 |
22 | - namespace: '/rooms'
23 | connect:
24 | query: 'roomId={{ roomId }}'
25 | headers:
26 | x-forwarded-for: '{{ $randomNumber(1000000, 9999999) }}'
27 |
28 | - think: 2
29 |
30 | - namespace: '/rooms'
31 | emit:
32 | channel: 'message'
33 | data:
34 | message: 'Test message'
35 | roomId: '{{ roomId }}'
36 |
37 | - think: 1
38 |
39 | - namespace: '/rooms'
40 | emit:
41 | channel: 'vote'
42 | data:
43 | # 일단 노래 두 개가 있다고 가정하고, 랜덤 값 지정
44 | trackNumber: "{{ $randomNumber(1,2) }}"
45 |
46 | - think: 30
--------------------------------------------------------------------------------
/server/src/admin/admin.guard.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Injectable,
3 | CanActivate,
4 | ExecutionContext,
5 | UnauthorizedException,
6 | } from '@nestjs/common';
7 | import { JwtService } from '@nestjs/jwt';
8 |
9 | @Injectable()
10 | export class AdminGuard implements CanActivate {
11 | constructor(private jwtService: JwtService) {}
12 |
13 | async canActivate(context: ExecutionContext): Promise {
14 | const request = context.switchToHttp().getRequest();
15 | const token = request.cookies['admin_token'];
16 |
17 | if (!token) {
18 | throw new UnauthorizedException('Client does not have token');
19 | }
20 |
21 | try {
22 | const payload = await this.jwtService.verifyAsync(token);
23 | if (payload.role !== 'admin') {
24 | throw new UnauthorizedException('Does not have admin authentication');
25 | }
26 | request.user = payload;
27 |
28 | return true;
29 | } catch {
30 | throw new UnauthorizedException('Invalid token');
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/server/src/admin/admin.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import { AdminController } from './admin.controller';
4 | import { MusicModule } from '@/music/music.module';
5 | import { AdminService } from './admin.service';
6 | import { AdminRedisRepository } from './admin.redis.repository';
7 | import { RedisModule } from '@/common/redis/redis.module';
8 | import { TypeOrmModule } from '@nestjs/typeorm';
9 | import { Song } from '@/song/song.entity';
10 | import { Album } from '@/album/album.entity';
11 | import { RoomModule } from '@/room/room.module';
12 | import { AlbumModule } from '@/album/album.module';
13 | import { SongModule } from '@/song/song.module';
14 | import { JwtModule } from '@nestjs/jwt';
15 | import { AdminTransactionService } from './admin.transaction.service';
16 |
17 | @Module({
18 | imports: [
19 | ConfigModule.forRoot({
20 | isGlobal: true,
21 | envFilePath: '.env',
22 | }),
23 | AlbumModule,
24 | RedisModule,
25 | MusicModule,
26 | RoomModule,
27 | SongModule,
28 | TypeOrmModule.forFeature([Album, Song]),
29 | JwtModule.register({
30 | secret: process.env.JWT_SECRET || 'temporary-secret-key',
31 | }),
32 | ],
33 | controllers: [AdminController],
34 | providers: [AdminService, AdminRedisRepository, AdminTransactionService],
35 | exports: [AdminService],
36 | })
37 | export class AdminModule {}
38 |
--------------------------------------------------------------------------------
/server/src/admin/admin.redis.repository.ts:
--------------------------------------------------------------------------------
1 | import { REDIS_CLIENT } from '@/common/redis/redis.module';
2 | import { Inject, Injectable } from '@nestjs/common';
3 | import { RedisClientType } from 'redis';
4 |
5 | export class AdminRedisRepository {
6 | constructor(
7 | @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType,
8 | ) {}
9 |
10 | private getStreamingSessionKey(albumId: string): string {
11 | return `rooms:${albumId}:session`;
12 | }
13 |
14 | async createStreamingSession(
15 | albumId: string,
16 | releaseTimestamp: number,
17 | songDurations: number[],
18 | ): Promise {
19 | const key = this.getStreamingSessionKey(albumId);
20 | const multi = this.redisClient.multi();
21 |
22 | multi
23 | .hSet(key, 'releaseTimestamp', releaseTimestamp.toString())
24 | .hSet(key, 'songs', JSON.stringify(songDurations));
25 |
26 | await multi.exec();
27 | }
28 |
29 | async deleteStreamingSession(albumId: string): Promise {
30 | const key = this.getStreamingSessionKey(albumId);
31 | await this.redisClient.del(key);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/server/src/admin/admin.transaction.service.ts:
--------------------------------------------------------------------------------
1 | import { Album } from '@/album/album.entity';
2 | import { Injectable } from '@nestjs/common';
3 | import { DataSource } from 'typeorm';
4 | import { RoomService } from '@/room/room.service';
5 | import { AlbumRepository } from '@/album/album.repository';
6 | import { AdminService } from './admin.service';
7 | import { UploadedFiles } from './admin.controller';
8 | import { InjectDataSource } from '@nestjs/typeorm';
9 |
10 | @Injectable()
11 | export class AdminTransactionService {
12 | constructor(
13 | @InjectDataSource()
14 | private readonly dataSource: DataSource,
15 | private readonly roomService: RoomService,
16 | private readonly albumRepository: AlbumRepository,
17 | private readonly adminService: AdminService,
18 | ) {}
19 |
20 | async saveInitialAlbum(albumData: Album): Promise {
21 | return await this.dataSource.transaction(async () => {
22 | return await this.albumRepository.save(albumData);
23 | });
24 | }
25 |
26 | async saveRemainingData(
27 | album: Album,
28 | processedSongs: any[],
29 | files: UploadedFiles,
30 | ): Promise {
31 | await this.dataSource.transaction(async () => {
32 | // 1. 앨범 이미지 업로드 및 DB 저장
33 |
34 | await this.adminService.saveAlbumCoverAndBanner(files, album.id);
35 |
36 | // 2. Streaming Session 초기화
37 | await this.adminService.initializeStreamingSession(processedSongs, album);
38 |
39 | // 3. Songs 저장
40 | await this.adminService.saveSongs(processedSongs, album.id);
41 |
42 | // 4. Room 저장
43 | await this.roomService.initializeRoom(album.id);
44 | });
45 | }
46 |
47 | async deleteCreatedAlbum(albumId: string) {
48 | await this.albumRepository.delete(albumId);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/server/src/admin/dto/album.dto.ts:
--------------------------------------------------------------------------------
1 | import {
2 | IsArray,
3 | IsDate,
4 | IsNotEmpty,
5 | IsNumber,
6 | IsOptional,
7 | IsString,
8 | Min,
9 | ValidateNested,
10 | } from 'class-validator';
11 | import { SongDto } from './song.dto';
12 | import { Expose, Transform, Type } from 'class-transformer';
13 |
14 | export class AlbumDto {
15 | @IsNotEmpty()
16 | @IsString()
17 | title: string;
18 |
19 | @IsNotEmpty()
20 | @IsString()
21 | artist: string;
22 |
23 | @IsNotEmpty()
24 | @IsDate()
25 | @Type(() => Date)
26 | releaseDate: Date;
27 |
28 | @IsNotEmpty()
29 | @IsNumber()
30 | @Min(0)
31 | @IsOptional()
32 | totalTracks?: number;
33 |
34 | @IsArray()
35 | @ValidateNested({ each: true })
36 | @Type(() => SongDto)
37 | songs: SongDto[];
38 |
39 | @IsString()
40 | @Expose({ name: 'albumTag' })
41 | @Transform(({ value, obj }) => value || obj.tags || '')
42 | tags: string;
43 |
44 | @IsNumber()
45 | totalDuration: number;
46 |
47 | @IsString()
48 | bannerUrl: string;
49 |
50 | @IsString()
51 | jacketUrl: string;
52 |
53 | public setBannerUrl(url: string) {
54 | this.bannerUrl = url;
55 | }
56 |
57 | public setJacketUrl(url: string) {
58 | this.jacketUrl = url;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/server/src/admin/dto/song.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
2 |
3 | export class SongDto {
4 | @IsString()
5 | @IsNotEmpty()
6 | title: string;
7 |
8 | @IsNumber()
9 | @Min(1)
10 | trackNumber: number;
11 |
12 | @IsString()
13 | lyrics?: string;
14 |
15 | @IsString()
16 | producer: string;
17 |
18 | @IsString()
19 | composer: string;
20 |
21 | @IsString()
22 | writer: string;
23 |
24 | @IsString()
25 | instrument: string;
26 |
27 | @IsString()
28 | source: string;
29 |
30 | constructor(
31 | title: string,
32 | trackNumber: number,
33 | producer: string,
34 | composer: string,
35 | writer: string,
36 | instrument: string,
37 | source: string,
38 | lyrics?: string,
39 | ) {
40 | this.title = title;
41 | this.trackNumber = trackNumber;
42 | this.producer = producer;
43 | this.composer = composer;
44 | this.writer = writer;
45 | this.instrument = instrument;
46 | this.source = source;
47 | this.lyrics = lyrics;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/server/src/album/album.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get, Param } from '@nestjs/common';
2 | import { AlbumService } from './album.service';
3 | import { MainBannerResponseDto } from './dto/main-banner-response.dto';
4 | import { SideBarResponseDto } from './dto/side-bar-response.dto';
5 | import { EndedAlbumResponseDto } from './dto/ended-album-response.dto';
6 | import { AlbumDetailResponseDto } from './dto/album-detail-response.dto';
7 | import { ApiParam, ApiResponse } from '@nestjs/swagger';
8 |
9 | @Controller('album')
10 | export class AlbumController {
11 | constructor(private readonly albumService: AlbumService) {}
12 |
13 | @Get('banner')
14 | async getMainBannerInfos(): Promise {
15 | return await this.albumService.getMainBannerInfos();
16 | }
17 |
18 | @Get('sidebar')
19 | async getSideBarInfos(): Promise {
20 | return await this.albumService.getSideBarInfos();
21 | }
22 |
23 | @Get('ended')
24 | async getEndedAlbums(): Promise {
25 | return await this.albumService.getEndedAlbums();
26 | }
27 |
28 | @Get(':albumId')
29 | @ApiParam({ name: 'albumId', required: true })
30 | @ApiResponse({ status: 200, description: `success to get album detail` })
31 | async getAlbumDetail(
32 | @Param('albumId') albumId: string,
33 | ): Promise {
34 | return await this.albumService.getAlbumDetail(albumId);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/server/src/album/album.entity.ts:
--------------------------------------------------------------------------------
1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
2 | import { AlbumDto } from '@/admin/dto/album.dto';
3 |
4 | @Entity()
5 | export class Album {
6 | @PrimaryGeneratedColumn('uuid')
7 | id: string;
8 |
9 | @Column({ type: 'varchar', length: 50, nullable: false })
10 | title: string;
11 |
12 | @Column({ type: 'varchar', length: 50, nullable: false })
13 | artist: string;
14 |
15 | @Column({ type: 'varchar', length: 30 })
16 | tags: string;
17 |
18 | @Column({
19 | name: 'release_date',
20 | type: 'timestamp',
21 | nullable: false,
22 | })
23 | releaseDate: Date;
24 |
25 | @Column({ name: 'total_duration', type: 'int' })
26 | totalDuration: number;
27 |
28 | @Column({ name: 'banner_url', type: 'varchar', length: 500 })
29 | bannerUrl: string;
30 |
31 | @Column({ name: 'jacket_url', type: 'varchar', length: 500 })
32 | jacketUrl: string;
33 |
34 | constructor(albumDto?: AlbumDto) {
35 | if (!albumDto) return;
36 | this.title = albumDto.title;
37 | this.artist = albumDto.artist;
38 | this.tags = albumDto.tags;
39 | this.releaseDate = albumDto.releaseDate;
40 | this.totalDuration = albumDto.totalDuration;
41 | this.bannerUrl = albumDto.bannerUrl;
42 | this.jacketUrl = albumDto.jacketUrl;
43 | }
44 |
45 | public getId() {
46 | return this.id;
47 | }
48 |
49 | // 소수 잘려있는 것 대비해 2초 추가
50 | public setTotalDuration(totalDuration: number) {
51 | this.totalDuration = totalDuration + 2;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/server/src/album/album.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 | import { Album } from '@/album/album.entity';
4 | import { AlbumRepository } from '@/album/album.repository';
5 | import { AlbumController } from './album.controller';
6 | import { AlbumService } from './album.service';
7 | import { AlbumRedisRepository } from './album.redis.repository';
8 |
9 | @Module({
10 | imports: [TypeOrmModule.forFeature([Album])],
11 | controllers: [AlbumController],
12 | providers: [AlbumService, AlbumRepository, AlbumRedisRepository],
13 | exports: [AlbumRepository],
14 | })
15 | export class AlbumModule {}
16 |
--------------------------------------------------------------------------------
/server/src/album/album.redis.repository.ts:
--------------------------------------------------------------------------------
1 | import { Inject, Injectable } from '@nestjs/common';
2 | import { REDIS_CLIENT } from '@/common/redis/redis.module';
3 | import { RedisClientType } from 'redis';
4 |
5 | @Injectable()
6 | export class AlbumRedisRepository {
7 | constructor(
8 | @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType,
9 | ) {}
10 |
11 | private roomKey(roomId: string): string {
12 | return `rooms:${roomId}`;
13 | }
14 |
15 | async getCurrentUsers(roomId: string): Promise {
16 | const roomKey = this.roomKey(roomId);
17 | const currentUsers = await this.redisClient.hGet(roomKey, 'currentUsers');
18 | return parseInt(currentUsers || '0', 10);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/album/album.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { AlbumRepository } from './album.repository';
3 | import { AlbumRedisRepository } from './album.redis.repository';
4 | import {
5 | MainBannerDto,
6 | MainBannerResponseDto,
7 | } from './dto/main-banner-response.dto';
8 | import { SideBarResponseDto } from './dto/side-bar-response.dto';
9 | import { EndedAlbumResponseDto } from './dto/ended-album-response.dto';
10 | import { AlbumDetailResponseDto } from './dto/album-detail-response.dto';
11 |
12 | @Injectable()
13 | export class AlbumService {
14 | constructor(
15 | private readonly albumRepository: AlbumRepository,
16 | private readonly albumRedisRepository: AlbumRedisRepository,
17 | ) {}
18 | async getMainBannerInfos(): Promise {
19 | const date = new Date();
20 | const albumBannerInfos =
21 | await this.albumRepository.getAlbumBannerInfos(date);
22 |
23 | const banners = await Promise.all(
24 | albumBannerInfos.map(async (album) => {
25 | const currentUserCount =
26 | await this.albumRedisRepository.getCurrentUsers(album.albumId);
27 | return MainBannerDto.from(album, currentUserCount);
28 | }),
29 | );
30 | return new MainBannerResponseDto(banners);
31 | }
32 |
33 | async getSideBarInfos(): Promise {
34 | const date = new Date();
35 | const recentSideBarAlbums =
36 | await this.albumRepository.getRecentSideBarInfos(date);
37 |
38 | const upComingAlbums =
39 | await this.albumRepository.getUpComingSideBarInfos(date);
40 | return new SideBarResponseDto(recentSideBarAlbums, upComingAlbums);
41 | }
42 |
43 | async getEndedAlbums(): Promise {
44 | const date = new Date();
45 | const recentAlbums = await this.albumRepository.getEndedAlbumsInfos(date);
46 |
47 | return new EndedAlbumResponseDto(recentAlbums);
48 | }
49 |
50 | async getAlbumDetail(albumId: string): Promise {
51 | const albumDetail = await this.albumRepository.getAlbumDetailInfos(albumId);
52 | const albumSongDetail =
53 | await this.albumRepository.getAlbumDetailSongInfos(albumId);
54 | return new AlbumDetailResponseDto(albumDetail, albumSongDetail);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/server/src/album/dto/album-detail-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class AlbumDetailResponseDto {
4 | result: {
5 | albumDetails: AlbumDetailDto;
6 | songDetails: AlbumDetailSongDto[];
7 | };
8 | constructor(
9 | albumDetails: AlbumDetailDto,
10 | songDetails: AlbumDetailSongDto[],
11 | ) {
12 | this.result = {
13 | albumDetails,
14 | songDetails,
15 | };
16 | }
17 | }
18 |
19 | export class AlbumDetailDto {
20 | @ApiProperty()
21 | albumId: string;
22 | @ApiProperty()
23 | albumName: string;
24 | @ApiProperty()
25 | artist: string;
26 | @ApiProperty()
27 | jacketUrl: string;
28 | }
29 |
30 | export class AlbumDetailSongDto {
31 | @ApiProperty()
32 | songName: string;
33 | @ApiProperty()
34 | songDuration: number;
35 | }
36 |
--------------------------------------------------------------------------------
/server/src/album/dto/album-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { Expose } from 'class-transformer';
2 |
3 | export class AlbumResponseDto {
4 | @Expose()
5 | id: string;
6 |
7 | @Expose()
8 | title: string;
9 |
10 | @Expose()
11 | artist: string;
12 |
13 | @Expose()
14 | tags: string;
15 |
16 | @Expose()
17 | releaseDate: Date;
18 |
19 | @Expose()
20 | bannerUrl: string;
21 |
22 | @Expose()
23 | jacketUrl: string;
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/album/dto/ended-album-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class EndedAlbumResponseDto {
4 | @ApiProperty({ type: () => EndedAlbumDto, isArray: true })
5 | result: {
6 | endedAlbums: EndedAlbumDto[];
7 | };
8 |
9 | constructor(endedAlbums: EndedAlbumDto[]) {
10 | this.result = {
11 | endedAlbums,
12 | };
13 | }
14 | }
15 |
16 | export class EndedAlbumDto {
17 | @ApiProperty()
18 | albumId: string;
19 | @ApiProperty()
20 | albumName: string;
21 | @ApiProperty()
22 | artist: string;
23 | @ApiProperty()
24 | albumTags: string;
25 | @ApiProperty()
26 | jacketUrl: string;
27 | }
28 |
--------------------------------------------------------------------------------
/server/src/album/dto/main-banner-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 | import { GetAlbumBannerInfosTuple } from '../album.repository';
3 |
4 | export class MainBannerResponseDto {
5 | @ApiProperty({ type: () => MainBannerDto, isArray: true })
6 | result: {
7 | bannerLists: MainBannerDto[];
8 | };
9 |
10 | constructor(bannerLists: MainBannerDto[]) {
11 | this.result = {
12 | bannerLists,
13 | };
14 | }
15 | }
16 |
17 | export class MainBannerDto {
18 | @ApiProperty()
19 | albumId: string;
20 | @ApiProperty()
21 | albumName: string;
22 | @ApiProperty()
23 | albumTags: string;
24 | @ApiProperty()
25 | artist: string;
26 | @ApiProperty()
27 | releaseDate: Date;
28 | @ApiProperty()
29 | bannerImageUrl: string;
30 | @ApiProperty()
31 | currentUserCount: number;
32 |
33 | constructor(
34 | albumId: string,
35 | albumName: string,
36 | albumTags: string,
37 | artist: string,
38 | releaseDate: Date,
39 | bannerImageUrl: string,
40 | currentUserCount: number,
41 | ) {
42 | this.albumId = albumId;
43 | this.albumName = albumName;
44 | this.albumTags = albumTags;
45 | this.artist = artist;
46 | this.releaseDate = releaseDate;
47 | this.bannerImageUrl = bannerImageUrl;
48 | this.currentUserCount = currentUserCount;
49 | }
50 | static from(album: GetAlbumBannerInfosTuple, currentUserCount: number) {
51 | return new MainBannerDto(
52 | album.albumId,
53 | album.albumName,
54 | album.albumTags,
55 | album.artist,
56 | album.releaseDate,
57 | album.bannerImageUrl,
58 | currentUserCount,
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/server/src/album/dto/side-bar-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class SideBarResponseDto {
4 | @ApiProperty({ type: () => SideBarDto, isArray: true })
5 | result: {
6 | streamingAlbums: SideBarDto[];
7 | upComingAlbums: SideBarDto[];
8 | };
9 |
10 | constructor(streamingAlbums: SideBarDto[], upComingAlbums: SideBarDto[]) {
11 | this.result = {
12 | streamingAlbums,
13 | upComingAlbums,
14 | };
15 | }
16 | }
17 |
18 | export class SideBarDto {
19 | @ApiProperty()
20 | albumId: string;
21 | @ApiProperty()
22 | albumName: string;
23 | @ApiProperty()
24 | albumTags: string;
25 |
26 | constructor(albumId: string, albumName: string, albumTags: string) {
27 | this.albumId = albumId;
28 | this.albumName = albumName;
29 | this.albumTags = albumTags;
30 | }
31 | static from(album: SideBarDto) {
32 | return new SideBarDto(album.albumId, album.albumName, album.albumTags);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { ApiOperation, ApiTags } from '@nestjs/swagger';
3 |
4 | @ApiTags('건강 체크')
5 | @Controller()
6 | export class AppController {
7 | @ApiOperation({ summary: 'health check' })
8 | @Get('/health')
9 | healthCheck() {
10 | return { success: true, health: 'healthy' };
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/server/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Logger, Module } from '@nestjs/common';
2 | import { AppController } from '@/app.controller';
3 | import { CommonModule } from '@/common/common.module';
4 | import { ConfigModule } from '@nestjs/config';
5 | import { RedisModule } from '@/common/redis/redis.module';
6 | import { MusicModule } from './music/music.module';
7 | import { EmojiModule } from './emoji/emoji.module';
8 | import { AdminModule } from './admin/admin.module';
9 | import { AlbumModule } from '@/album/album.module';
10 | import { TypeOrmModule } from '@nestjs/typeorm';
11 | import { Album } from '@/album/album.entity';
12 | import { Song } from '@/song/song.entity';
13 | import { SongModule } from '@/song/song.module';
14 | import { MusicRepository } from '@/music/music.repository';
15 | import { RoomModule } from '@/room/room.module';
16 | import { SchedulerService } from './common/scheduler/scheduler.service';
17 | import { ScheduleModule } from '@nestjs/schedule';
18 | import { Comment } from './comment/comment.entity';
19 | import { CommentModule } from './comment/comment.module';
20 |
21 | @Module({
22 | imports: [
23 | CommonModule,
24 | ScheduleModule.forRoot(),
25 | ConfigModule.forRoot(),
26 | RedisModule,
27 | MusicModule,
28 | AdminModule,
29 | EmojiModule,
30 | AlbumModule,
31 | SongModule,
32 | RoomModule,
33 | CommentModule,
34 | TypeOrmModule.forRoot({
35 | type: 'mysql',
36 | host: process.env.DB_HOST,
37 | port: parseInt(process.env.DB_PORT),
38 | username: process.env.DB_USERNAME,
39 | password: process.env.DB_PASSWORD,
40 | database: process.env.DB_DATABASE,
41 | entities: [Album, Song, Comment],
42 | }),
43 | ],
44 | controllers: [AppController],
45 | providers: [Logger, MusicRepository, SchedulerService],
46 | })
47 | export class AppModule {}
48 |
--------------------------------------------------------------------------------
/server/src/comment/comment.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Get, Param, Post } from '@nestjs/common';
2 | import { CommentService } from './comment.service';
3 | import { ApiBody, ApiOperation, ApiParam } from '@nestjs/swagger';
4 | import { AlbumCommentResponseDto } from './dto/album-comment-response.dto';
5 |
6 | @Controller('comment')
7 | export class CommentController {
8 | constructor(private readonly commentService: CommentService) {}
9 |
10 | @ApiOperation({ summary: '댓글 추가' })
11 | @ApiParam({ name: 'albumId', required: true, description: '앨범 id' })
12 | @ApiBody({
13 | description: '댓글 내용',
14 | schema: { type: 'object', properties: { content: { type: 'string' } } },
15 | })
16 | @Post('/album/:albumId')
17 | async createComment(
18 | @Param('albumId') albumId: string,
19 | @Body('content') content: string,
20 | ): Promise {
21 | const commentResponse = await this.commentService.createComment(
22 | albumId,
23 | content,
24 | );
25 | return {
26 | success: true,
27 | commentResponse,
28 | };
29 | }
30 |
31 | @ApiOperation({ summary: '댓글 불러오기' })
32 | @ApiParam({ name: 'albumId', required: true, description: '앨범 id' })
33 | @Get('/album/:albumId')
34 | async getAlbumComments(
35 | @Param('albumId') albumId: string,
36 | ): Promise {
37 | return await this.commentService.getAlbumComments(albumId);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/server/src/comment/comment.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | Column,
4 | PrimaryGeneratedColumn,
5 | ManyToOne,
6 | JoinColumn,
7 | CreateDateColumn,
8 | } from 'typeorm';
9 | import { Album } from '@/album/album.entity';
10 |
11 | @Entity()
12 | export class Comment {
13 | @PrimaryGeneratedColumn()
14 | id: number;
15 |
16 | @Column({ name: 'album_id', type: 'char', length: 36, nullable: false })
17 | albumId: string;
18 |
19 | @ManyToOne(() => Album, { nullable: false })
20 | @JoinColumn({ name: 'album_id' })
21 | album: Album;
22 |
23 | @Column({ type: 'varchar', length: 200, nullable: false })
24 | content: string;
25 |
26 | @CreateDateColumn({ type: 'timestamp', name: 'created_at', nullable: false })
27 | createdAt: Date;
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/comment/comment.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 | import { Comment } from './comment.entity';
4 | import { CommentController } from './comment.controller';
5 | import { CommentService } from './comment.service';
6 | import { CommentRepository } from './comment.repository';
7 |
8 | @Module({
9 | imports: [TypeOrmModule.forFeature([Comment])],
10 | controllers: [CommentController],
11 | providers: [CommentService, CommentRepository],
12 | })
13 | export class CommentModule {}
14 |
--------------------------------------------------------------------------------
/server/src/comment/comment.repository.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
3 | import { Comment } from './comment.entity';
4 | import { DataSource, Repository } from 'typeorm';
5 | import { AlbumCommentDto } from './dto/album-comment-response.dto';
6 | import { plainToInstance } from 'class-transformer';
7 |
8 | @Injectable()
9 | export class CommentRepository {
10 | constructor(
11 | @InjectRepository(Comment)
12 | private readonly commentRepository: Repository,
13 | @InjectDataSource() private readonly dataSource: DataSource,
14 | ) {}
15 |
16 | async createComment(commentData: {
17 | albumId: string;
18 | content: string;
19 | }): Promise {
20 | return await this.commentRepository.save(commentData);
21 | }
22 |
23 | async getCommentInfos(albumId: string): Promise {
24 | const commentInfos = await this.dataSource
25 | .createQueryBuilder()
26 | .from(Comment, 'comment')
27 | .select(['album_id as albumId', 'content', 'created_at as createdAt'])
28 | .where('album_id = :albumId', { albumId })
29 | .orderBy('created_at', 'DESC')
30 | .limit(10)
31 | .getRawMany();
32 |
33 | return plainToInstance(AlbumCommentDto, commentInfos);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/server/src/comment/comment.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { CommentRepository } from './comment.repository';
3 | import { Comment } from './comment.entity';
4 | import { AlbumCommentResponseDto } from './dto/album-comment-response.dto';
5 |
6 | @Injectable()
7 | export class CommentService {
8 | constructor(private readonly commentRepository: CommentRepository) {}
9 |
10 | async createComment(albumId: string, content: string): Promise {
11 | return await this.commentRepository.createComment({
12 | albumId,
13 | content,
14 | });
15 | }
16 |
17 | async getAlbumComments(albumId: string): Promise {
18 | const comments = await this.commentRepository.getCommentInfos(albumId);
19 | return new AlbumCommentResponseDto(comments);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/comment/dto/album-comment-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class AlbumCommentResponseDto {
4 | @ApiProperty({ type: () => AlbumCommentDto, isArray: true })
5 | result: {
6 | albumComments: AlbumCommentDto[];
7 | };
8 |
9 | constructor(albumComments: AlbumCommentDto[]) {
10 | this.result = {
11 | albumComments,
12 | };
13 | }
14 | }
15 |
16 | export class AlbumCommentDto {
17 | @ApiProperty()
18 | albumId: string;
19 | @ApiProperty()
20 | content: string;
21 | @ApiProperty()
22 | createdAt: Date;
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/common/common.module.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Global,
3 | Logger,
4 | MiddlewareConsumer,
5 | Module,
6 | NestModule,
7 | } from '@nestjs/common';
8 | import { WinstonModule } from 'nest-winston';
9 | import { ClsModule, ClsService } from 'nestjs-cls';
10 | import { v4 as uuidv4 } from 'uuid';
11 | import { winstonLoggerTransport } from '@/common/logger/logger.config';
12 | import { LoggerContextMiddleware } from '@/common/logger/logger-context.middleware';
13 | const services = [Logger];
14 |
15 | @Global()
16 | @Module({
17 | imports: [
18 | ClsModule.forRoot({
19 | global: true,
20 | middleware: {
21 | mount: true,
22 | generateId: true,
23 | idGenerator: (req: Request) => req.headers['X-Request-Id'] ?? uuidv4(),
24 | },
25 | }),
26 | WinstonModule.forRootAsync({
27 | inject: [ClsService],
28 | useFactory: (clsService: ClsService) => {
29 | return {
30 | transports: [winstonLoggerTransport(clsService)],
31 | };
32 | },
33 | }),
34 | ],
35 | providers: services,
36 | exports: services,
37 | })
38 | export class CommonModule implements NestModule {
39 | public configure(consumer: MiddlewareConsumer): void {
40 | consumer.apply(LoggerContextMiddleware).forRoutes('*');
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/server/src/common/constants/repository.constant.ts:
--------------------------------------------------------------------------------
1 | export const ORDER = {
2 | ASC: 'ASC',
3 | DESC: 'DESC',
4 | } as const;
5 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/base.exception.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus } from '@nestjs/common';
2 |
3 | export class BaseException extends HttpException {
4 | constructor(
5 | message: string,
6 | status: HttpStatus,
7 | details?: Record,
8 | ) {
9 | const response = {
10 | statusCode: status,
11 | message,
12 | error: HttpStatus[status],
13 | time: new Date().toISOString(),
14 | details,
15 | };
16 | super(response, status);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/album/album-creation-fail.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class AlbumCreationFailedException extends BaseException {
5 | constructor() {
6 | super(
7 | '앨범 생성 중 오류가 발생하였습니다.',
8 | HttpStatus.INTERNAL_SERVER_ERROR,
9 | );
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/album/album-not-found-by-timestamp.exception.ts:
--------------------------------------------------------------------------------
1 | import { HttpStatus } from '@nestjs/common';
2 | import { BaseException } from '../../base.exception';
3 |
4 | export class AlbumNotFoundByTimestampException extends BaseException {
5 | constructor(albumId: string, timestamp: number) {
6 | super('주어진 시간에 맞는 앨범을 찾을 수 없습니다.', HttpStatus.NOT_FOUND, {
7 | albumId,
8 | timestamp,
9 | });
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/album/album-not-found.exception.ts:
--------------------------------------------------------------------------------
1 | import { HttpStatus } from '@nestjs/common';
2 | import { BaseException } from '../../base.exception';
3 |
4 | export class AlbumNotFoundException extends BaseException {
5 | constructor(albumId: string) {
6 | super('앨범을 찾을 수 없습니다.', HttpStatus.NOT_FOUND, { albumId });
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/room/room-inactive.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class RoomInactiveException extends BaseException {
5 | constructor(roomKey: string) {
6 | super('비활성화 상태인 방입니다.', HttpStatus.NOT_FOUND, { roomKey });
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/room/room-is-full.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class RoomIsFullException extends BaseException {
5 | constructor(roomKey: string, maxCapacity: number) {
6 | super('방의 인원이 가득찼습니다.', HttpStatus.SERVICE_UNAVAILABLE, {
7 | roomKey,
8 | maxCapacity,
9 | });
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/room/room-not-found.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class RoomNotFoundException extends BaseException {
5 | constructor() {
6 | super('방이 존재하지 않습니다.', HttpStatus.NOT_FOUND);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/room/user-not-in-room.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class UserNotInRoomException extends BaseException {
5 | constructor(roomId: string, userId: string) {
6 | super('방에 사용자가 존재하지 않습니다.', HttpStatus.FORBIDDEN, {
7 | roomId,
8 | userId,
9 | });
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/room/user-room-info-not-found.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class UserRoomInfoNotFoundException extends BaseException {
5 | constructor(socketId: string) {
6 | super('사용자의 방 정보가 존재하지 않습니다.', HttpStatus.BAD_REQUEST, {
7 | socketId,
8 | });
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/song/missing-song-files.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class MissingSongFiles extends BaseException {
5 | constructor() {
6 | super('음악 파일이 필요합니다.', HttpStatus.BAD_REQUEST);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/domain/vote/already-vote-this-room.exception.ts:
--------------------------------------------------------------------------------
1 | import { BaseException } from '@/common/exceptions/base.exception';
2 | import { HttpStatus } from '@nestjs/common';
3 |
4 | export class AlreadyVoteThisRoomException extends BaseException {
5 | constructor(roomId: string) {
6 | super('이미 투표한 방입니다.', HttpStatus.BAD_REQUEST, { roomId });
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/global-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ArgumentsHost,
3 | Catch,
4 | ExceptionFilter,
5 | HttpException,
6 | HttpStatus,
7 | Logger,
8 | } from '@nestjs/common';
9 | import { Request, Response } from 'express';
10 |
11 | @Catch()
12 | export class GlobalExceptionFilter implements ExceptionFilter {
13 | private readonly logger = new Logger(GlobalExceptionFilter.name);
14 |
15 | catch(exception: unknown, host: ArgumentsHost) {
16 | const ctx = host.switchToHttp();
17 | const response = ctx.getResponse();
18 | const request = ctx.getRequest();
19 |
20 | if (exception instanceof HttpException) {
21 | this.logException(exception, { path: request.url });
22 | return response.status(exception.getStatus()).json({
23 | time: new Date().toISOString(),
24 | ...this.getExceptionResponse(exception),
25 | errorName: exception.name,
26 | });
27 | }
28 | // 예상치 못한 예외 처리
29 | this.logException(exception, { path: request.url });
30 | return this.getUnexpectedExceptionResponse(exception, response);
31 | }
32 |
33 | private getExceptionResponse(exception: any): Record {
34 | const exceptionResponse = exception.getResponse();
35 | return exceptionResponse instanceof Object
36 | ? exceptionResponse
37 | : { message: exceptionResponse };
38 | }
39 |
40 | private getUnexpectedExceptionResponse(exception: any, response: Response) {
41 | return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
42 | statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
43 | error: exception.constructor.name,
44 | errorName: exception.code,
45 | message: exception.message,
46 | time: exception.time,
47 | });
48 | }
49 |
50 | private logException(exception: any, requestInfo: any) {
51 | this.logger.error({
52 | type: exception.constructor.name,
53 | message: exception.message,
54 | ...requestInfo,
55 | });
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/server/src/common/exceptions/ws-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import { ArgumentsHost, Catch, WsExceptionFilter } from '@nestjs/common';
2 |
3 | @Catch()
4 | export class CustomWsExceptionFilter implements WsExceptionFilter {
5 | catch(exception: any, host: ArgumentsHost) {
6 | const client = host.switchToWs().getClient();
7 |
8 | client.emit('error', {
9 | error: exception.constructor.name,
10 | message: exception.message,
11 | time: exception.time || new Date().toISOString(),
12 | });
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/common/logger/logger-context.middleware.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Inject,
3 | Injectable,
4 | Logger,
5 | LoggerService,
6 | NestMiddleware,
7 | } from '@nestjs/common';
8 | import { NextFunction, Request, Response } from 'express';
9 |
10 | @Injectable()
11 | export class LoggerContextMiddleware implements NestMiddleware {
12 | constructor(@Inject(Logger) private readonly logger: LoggerService) {}
13 |
14 | use(req: Request, res: Response, next: NextFunction) {
15 | const { ip, method, originalUrl } = req;
16 | const originalSend = res.send;
17 | const logger = this.logger;
18 |
19 | //@ts-ignore
20 | res.send = (...args: any[]) => {
21 | const [body] = args;
22 | if (typeof body === 'string') {
23 | logger.log(`Response Body: ${body}`);
24 | }
25 | originalSend.apply(res, args);
26 | };
27 |
28 | res.on('finish', () => {
29 | const { statusCode } = res;
30 | this.logger.log(`${ip} ${method} ${originalUrl} ${statusCode}`);
31 | if (Object.keys(req.body).length !== 0) {
32 | this.logger.log(`Request body: ${JSON.stringify(req.body)}`);
33 | }
34 | });
35 | next();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/src/common/logger/logger.config.ts:
--------------------------------------------------------------------------------
1 | import * as winston from 'winston';
2 | import { utilities as nestWinstonModuleUtilities } from 'nest-winston/dist/winston.utilities';
3 | import { ClsService } from 'nestjs-cls';
4 |
5 | const winstonLoggerTransport = (clsService: ClsService) => {
6 | const format = winston.format.combine(
7 | winston.format.timestamp(),
8 | winston.format.ms(),
9 | winston.format((info) => {
10 | info.message = `[${clsService.getId()}] ${info.message}`;
11 | return info;
12 | })(),
13 | nestWinstonModuleUtilities.format.nestLike('inear', {
14 | colors: true,
15 | prettyPrint: true,
16 | }),
17 | );
18 | return new winston.transports.Console({ format: format });
19 | };
20 | export { winstonLoggerTransport };
21 |
--------------------------------------------------------------------------------
/server/src/common/randomname/random-name.util.ts:
--------------------------------------------------------------------------------
1 | export class RandomNameUtil {
2 | private static readonly adjective = [
3 | '행복한',
4 | '슬픈',
5 | '우울한',
6 | '쓸쓸한',
7 | '무거운',
8 | '따뜻한',
9 | '작은',
10 | '큰',
11 | '맛있는',
12 | '달콤한',
13 | '어려운',
14 | '쉬운',
15 | '재미있는',
16 | '훌륭한',
17 | '잘생긴',
18 | '예쁜',
19 | '귀여운',
20 | '매력적인',
21 | '편리한',
22 | '친절한',
23 | '순수한',
24 | '청결한',
25 | '상냥한',
26 | '예의바른',
27 | '높은',
28 | '먼',
29 | '정직한',
30 | '성실한',
31 | '공정한',
32 | '버릇없는',
33 | '더러운',
34 | '얇은',
35 | '뚱뚱한',
36 | '매끄러운',
37 | '유창한',
38 | ];
39 | private static readonly animal = [
40 | '사자',
41 | '호랑이',
42 | '코끼리',
43 | '기린',
44 | '얼룩말',
45 | '치타',
46 | '판다',
47 | '캥거루',
48 | '코알라',
49 | '고릴라',
50 | '자리',
51 | '코뿔소',
52 | '북극곰',
53 | '회색곰',
54 | '침팬지',
55 | '돌고래',
56 | '고래',
57 | '박쥐',
58 | '다람쥐',
59 | '토끼',
60 | ];
61 |
62 | public static generate(): string {
63 | const adjectvieLength = RandomNameUtil.adjective.length;
64 | const animalLength = RandomNameUtil.animal.length;
65 |
66 | const adjectvieIndex = Math.floor(Math.random() * adjectvieLength);
67 | const animalIndex = Math.floor(Math.random() * animalLength);
68 |
69 | return `${RandomNameUtil.adjective[adjectvieIndex]} ${RandomNameUtil.animal[animalIndex]}`;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/server/src/common/redis/redis.module.ts:
--------------------------------------------------------------------------------
1 | import { Global, Module } from '@nestjs/common';
2 | import { ConfigModule, ConfigService } from '@nestjs/config';
3 | import { createRedisProvider } from './redis.provider';
4 |
5 | export const REDIS_CLIENT = 'REDIS_CLIENT' as const;
6 |
7 | @Global()
8 | @Module({
9 | imports: [ConfigModule],
10 | providers: [
11 | {
12 | provide: REDIS_CLIENT,
13 | useFactory: createRedisProvider,
14 | inject: [ConfigService],
15 | },
16 | ],
17 | exports: [REDIS_CLIENT],
18 | })
19 | export class RedisModule {}
20 |
--------------------------------------------------------------------------------
/server/src/common/redis/redis.provider.ts:
--------------------------------------------------------------------------------
1 | import { ConfigService } from '@nestjs/config';
2 | import { createClient, RedisClientType } from 'redis';
3 |
4 | export const createRedisProvider = async (
5 | configService: ConfigService,
6 | ): Promise => {
7 | const client = createClient({
8 | socket: {
9 | host: configService.get('REDIS_HOST'),
10 | port: configService.get('REDIS_PORT'),
11 | },
12 | password: configService.get('REDIS_PASSWORD'),
13 | }) as RedisClientType;
14 |
15 | await client.connect();
16 | return client;
17 | };
18 |
--------------------------------------------------------------------------------
/server/src/common/s3Cache/s3Cache.service.ts:
--------------------------------------------------------------------------------
1 | import { CACHE_MANAGER } from '@nestjs/cache-manager';
2 | import { Inject, Injectable, NotFoundException } from '@nestjs/common';
3 | import { ConfigService } from '@nestjs/config';
4 | import { S3 } from 'aws-sdk';
5 | import { Cache } from 'cache-manager';
6 |
7 | @Injectable()
8 | export class S3CacheService {
9 | private readonly s3: S3;
10 | constructor(
11 | private readonly configService: ConfigService,
12 | @Inject(CACHE_MANAGER) private cacheManager: Cache,
13 | ) {
14 | this.s3 = new S3({
15 | endpoint: this.configService.get('S3_END_POINT'),
16 | region: this.configService.get('S3_REGION'),
17 | credentials: {
18 | accessKeyId: this.configService.get('S3_ACCESS_KEY'),
19 | secretAccessKey: this.configService.get('S3_SECRET_KEY'),
20 | },
21 | });
22 | }
23 |
24 | async fetchFromS3({
25 | cacheKey,
26 | s3Key,
27 | cacheTTL,
28 | transform,
29 | }: {
30 | cacheKey: string;
31 | s3Key: string;
32 | cacheTTL: number;
33 | transform: (data: Buffer) => T;
34 | }): Promise {
35 | const cached = await this.cacheManager.get(cacheKey);
36 | if (cached) {
37 | return cached;
38 | }
39 |
40 | try {
41 | const s3Response = await this.s3
42 | .getObject({
43 | Bucket: this.configService.get('S3_BUCKET_NAME'),
44 | Key: s3Key,
45 | })
46 | .promise();
47 |
48 | const content = transform(s3Response.Body as Buffer);
49 | await this.cacheManager.set(cacheKey, content, cacheTTL);
50 | return content;
51 | } catch (e) {
52 | const fileType = cacheKey.startsWith('m3u8:') ? 'M3U8' : 'Segment(ts)';
53 | throw new NotFoundException(`❗ ${fileType} Not Found : ${s3Key}`);
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/server/src/common/scheduler/scheduler.service.ts:
--------------------------------------------------------------------------------
1 | import { Inject, Injectable } from '@nestjs/common';
2 | import { Cron } from '@nestjs/schedule';
3 | import { REDIS_CLIENT } from '../redis/redis.module';
4 | import { RedisClientType } from 'redis';
5 | import { AlbumRepository } from '@/album/album.repository';
6 | import { ConfigService } from '@nestjs/config';
7 |
8 | @Injectable()
9 | export class SchedulerService {
10 | private readonly MINUTES = 30;
11 | private readonly ROOM_ID: string;
12 | constructor(
13 | @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType,
14 | private readonly albumRepository: AlbumRepository,
15 | private readonly configService: ConfigService,
16 | ) {
17 | this.ROOM_ID = this.configService.get('TEST_ROOM_ID');
18 | }
19 |
20 | @Cron('25,55 * * * *')
21 | async updateReleaseTimestamp() {
22 | const currentTime = new Date();
23 | console.log(
24 | `SCHEDULAR EXECUTED TO UPDATE TIME, Current time (KST): ${currentTime.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`,
25 | );
26 | try {
27 | await this.albumRepository.updateReleaseDate(this.ROOM_ID, this.MINUTES);
28 | const updatedMySQLTime = await this.albumRepository.getReleaseDate(
29 | this.ROOM_ID,
30 | );
31 |
32 | const sessionKey = `rooms:${this.ROOM_ID}:session`;
33 | const redisTimestamp = await this.redisClient.hGet(
34 | sessionKey,
35 | 'releaseTimestamp',
36 | );
37 |
38 | if (redisTimestamp) {
39 | const redisTime = parseInt(redisTimestamp);
40 | const mysqlTime = updatedMySQLTime.getTime();
41 | console.log('Updated MySQL time: ', updatedMySQLTime);
42 |
43 | if (redisTime !== mysqlTime) {
44 | await this.redisClient.hSet(
45 | sessionKey,
46 | 'releaseTimestamp',
47 | mysqlTime.toString(),
48 | );
49 | console.log(
50 | `Updated Redis releaseTimestamp to match MySQL: ${new Date(mysqlTime).toISOString()}`,
51 | );
52 | }
53 | }
54 | } catch (error) {
55 | console.error('Redis, MySQL 시간 업데이트 실패', error);
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/server/src/emoji/dto/emoji-request.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsString } from 'class-validator';
2 |
3 | export class EmojiRequestDto {
4 | @IsString()
5 | @IsNotEmpty()
6 | emojiId: string;
7 |
8 | @IsString()
9 | @IsNotEmpty()
10 | sessionId: string;
11 |
12 | @IsString()
13 | @IsNotEmpty()
14 | emojiName: string;
15 | }
16 |
--------------------------------------------------------------------------------
/server/src/emoji/emoji.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Post } from '@nestjs/common';
2 | import { EmojiService } from './emoji.service';
3 | import { EmojiRequestDto } from './dto/emoji-request.dto';
4 |
5 | @Controller('emoji')
6 | export class EmojiController {
7 | constructor(private readonly emojiService: EmojiService) {}
8 |
9 | @Post('url')
10 | async getImageUrl(@Body() emojiRequest: EmojiRequestDto): Promise {
11 | const url = await this.emojiService.generateImageUrl(emojiRequest);
12 | return url;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/emoji/emoji.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import { EmojiService } from './emoji.service';
4 | import { EmojiController } from './emoji.controller';
5 |
6 | @Module({
7 | imports: [
8 | ConfigModule.forRoot({
9 | isGlobal: true,
10 | envFilePath: '.env',
11 | }),
12 | ],
13 | controllers: [EmojiController],
14 | providers: [EmojiService],
15 | exports: [EmojiService],
16 | })
17 | export class EmojiModule {}
18 |
--------------------------------------------------------------------------------
/server/src/emoji/emoji.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { S3 } from 'aws-sdk';
4 | import { EmojiRequestDto } from './dto/emoji-request.dto';
5 |
6 | @Injectable()
7 | export class EmojiService {
8 | private readonly s3: S3;
9 |
10 | constructor(private readonly configService: ConfigService) {
11 | this.s3 = new S3({
12 | endpoint: this.configService.get('S3_END_POINT'),
13 | region: this.configService.get('S3_REGION'),
14 | credentials: {
15 | accessKeyId: this.configService.get('S3_ACCESS_KEY'),
16 | secretAccessKey: this.configService.get('S3_SECRET_KEY'),
17 | },
18 | });
19 | }
20 |
21 | async generateImageUrl(req: EmojiRequestDto): Promise {
22 | // URL 유효 시간 = 5분
23 | const key = `emoji/${req.sessionId}/${req.emojiId}/${req.emojiName}.png`; // TODO : 이모지는 무슨 형식으로 저장해야 하는지 확인
24 |
25 | const s3Params = {
26 | Bucket: this.configService.get('S3_BUCKET_NAME'),
27 | Key: key,
28 | Expires: this.configService.get('S3_URL_EXPIRATION_SECONDS'),
29 | ContentType: 'image/png',
30 | };
31 |
32 | return this.s3.getSignedUrlPromise('putObject', s3Params);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { AppModule } from '@/app.module';
3 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
5 | import { NestExpressApplication } from '@nestjs/platform-express';
6 | import { join } from 'path';
7 | import { ValidationPipe } from '@nestjs/common';
8 | import { GlobalExceptionFilter } from '@/common/exceptions/global-exception.filter';
9 | import cookieParser from 'cookie-parser';
10 |
11 | async function bootstrap() {
12 | const app = await NestFactory.create(AppModule, {
13 | bufferLogs: true,
14 | });
15 |
16 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
17 | app.setGlobalPrefix('api');
18 | app.useGlobalPipes(new ValidationPipe());
19 | app.use(cookieParser());
20 |
21 | const config = new DocumentBuilder()
22 | .setTitle('inear')
23 | .setDescription('inear API description')
24 | .setVersion('1.0.0')
25 | .build();
26 | const document = SwaggerModule.createDocument(app, config);
27 | SwaggerModule.setup('api/api-document', app, document);
28 |
29 | app.useGlobalFilters(new GlobalExceptionFilter());
30 |
31 | app.useStaticAssets(join(__dirname, '..', 'public'), {
32 | prefix: '/',
33 | });
34 | app.enableCors({
35 | origin: ['http://localhost:5173', 'https://www.inear.live'],
36 | credentials: true,
37 | });
38 | await app.listen(process.env.PORT ?? 3000);
39 | }
40 |
41 | bootstrap();
42 |
--------------------------------------------------------------------------------
/server/src/music/music.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | Header,
5 | Headers,
6 | HttpException,
7 | HttpStatus,
8 | Param,
9 | Query,
10 | StreamableFile,
11 | } from '@nestjs/common';
12 | import { MusicService } from './music.service';
13 |
14 | @Controller('music')
15 | export class MusicController {
16 | constructor(private readonly musicService: MusicService) {}
17 |
18 | @Get(':albumId/playlist.m3u8')
19 | @Header('Content-Type', 'application/x-mpegURL')
20 | async getMusicFile(@Param('albumId') albumId: string) {
21 | return await this.musicService.generateMusicFile(albumId);
22 | }
23 |
24 | @Get(':albumId/:songIndex/playlist:segmentId.ts')
25 | @Header('Content-Type', 'video/MP2T')
26 | async getSegment(
27 | @Param('albumId') albumId: string,
28 | @Param('songIndex') songIndex: string,
29 | @Param('segmentId') segmentId: string,
30 | ) {
31 | return new StreamableFile(
32 | await this.musicService.getSegment(albumId, songIndex, segmentId),
33 | { type: 'video/MP2T' },
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/server/src/music/music.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 | import { MusicProcessingSevice } from './music.processor';
4 | import { CacheModule } from '@nestjs/cache-manager';
5 | import { MusicService } from './music.service';
6 | import { MusicController } from './music.controller';
7 | import { MusicRepository } from './music.repository';
8 | import { M3U8Parser } from './parser/m3u8-parser';
9 | import { S3CacheService } from '../common/s3Cache/s3Cache.service';
10 |
11 | @Module({
12 | imports: [
13 | ConfigModule.forRoot({
14 | isGlobal: true,
15 | envFilePath: '.env',
16 | }),
17 | CacheModule.register({
18 | ttl: 3600000,
19 | max: 1000,
20 | isGlobal: true,
21 | }),
22 | ],
23 | controllers: [MusicController],
24 | providers: [
25 | MusicProcessingSevice,
26 | MusicService,
27 | MusicRepository,
28 | M3U8Parser,
29 | S3CacheService,
30 | ],
31 | exports: [MusicProcessingSevice, MusicRepository],
32 | })
33 | export class MusicModule {}
34 |
--------------------------------------------------------------------------------
/server/src/music/music.repository.ts:
--------------------------------------------------------------------------------
1 | import { AlbumNotFoundByTimestampException } from '@/common/exceptions/domain/album/album-not-found-by-timestamp.exception';
2 | import { AlbumNotFoundException } from '@/common/exceptions/domain/album/album-not-found.exception';
3 | import { RoomNotFoundException } from '@/common/exceptions/domain/room/room-not-found.exception';
4 | import { REDIS_CLIENT } from '@/common/redis/redis.module';
5 | import { Inject, Injectable } from '@nestjs/common';
6 | import { RedisClientType } from 'redis';
7 |
8 | interface RoomSession {
9 | releaseTimestamp: number;
10 | songs: number[];
11 | }
12 |
13 | interface SongMetadata {
14 | id: string;
15 | startTime: number;
16 | duration: number;
17 | }
18 |
19 | @Injectable()
20 | export class MusicRepository {
21 | constructor(
22 | @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType,
23 | ) {}
24 |
25 | private albumKey(albumId: string): string {
26 | return `rooms:${albumId}:session`;
27 | }
28 |
29 | private async getRoomSession(albumId: string): Promise {
30 | const albumKey = this.albumKey(albumId);
31 | const exists = await this.redisClient.exists(albumKey);
32 |
33 | if (!exists) {
34 | throw new RoomNotFoundException();
35 | }
36 |
37 | const [releaseTimestampStr, songStr] = await Promise.all([
38 | this.redisClient.hGet(albumKey, 'releaseTimestamp'),
39 | this.redisClient.hGet(albumKey, 'songs'),
40 | ]);
41 | if (!releaseTimestampStr || !songStr) {
42 | throw new AlbumNotFoundException(albumId);
43 | }
44 |
45 | return {
46 | releaseTimestamp: parseInt(releaseTimestampStr, 10),
47 | songs: JSON.parse(songStr),
48 | };
49 | }
50 |
51 | async findSongByJoinTimestamp(
52 | albumId: string,
53 | joinTimestamp: number,
54 | ): Promise {
55 | const session = await this.getRoomSession(albumId);
56 |
57 | let currentTime = session.releaseTimestamp;
58 | // TODO : 고차함수로 반복
59 | for (let i = 0; i < session.songs.length; i++) {
60 | const songEndTime = currentTime + session.songs[i] * 1000;
61 |
62 | if (joinTimestamp >= currentTime && joinTimestamp < songEndTime) {
63 | return {
64 | id: `${i + 1}`,
65 | startTime: currentTime,
66 | duration: session.songs[i],
67 | };
68 | }
69 | currentTime = songEndTime;
70 | }
71 | return undefined;
72 | // throw new AlbumNotFoundByTimestampException(albumId, joinTimestamp);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/server/src/music/music.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { MusicRepository } from './music.repository';
3 | import { M3U8Parser } from './parser/m3u8-parser';
4 | import { S3CacheService } from '@/common/s3Cache/s3Cache.service';
5 |
6 | @Injectable()
7 | export class MusicService {
8 | private readonly SEGMENT_DURATION = 2;
9 |
10 | constructor(
11 | private readonly musicRepository: MusicRepository,
12 | private readonly m3u8Parser: M3U8Parser,
13 | private readonly s3CacheService: S3CacheService,
14 | ) {}
15 |
16 | private async getM3U8Content(
17 | albumId: string,
18 | songMetadata: { id: string; duration: number },
19 | ): Promise {
20 | return this.s3CacheService.fetchFromS3({
21 | cacheKey: `m3u8:${albumId}:${songMetadata.id}`,
22 | s3Key: `converted/${albumId}/${parseInt(songMetadata.id, 10)}/playlist.m3u8`,
23 | cacheTTL: songMetadata.duration * 1000,
24 | transform: (buffer) => buffer.toString(),
25 | });
26 | }
27 |
28 | private async getSegmentContent(
29 | albumId: string,
30 | songIndex: string,
31 | segmentId: string,
32 | ): Promise {
33 | return this.s3CacheService.fetchFromS3({
34 | cacheKey: `segment:${albumId}:${songIndex}:${segmentId}`,
35 | s3Key: `converted/${albumId}/${parseInt(songIndex, 10)}/playlist${segmentId}.ts`,
36 | cacheTTL: 3600000,
37 | transform: (buffer) => buffer,
38 | });
39 | }
40 |
41 | async generateMusicFile(albumId: string): Promise {
42 | const joinTimestamp = Date.now();
43 | const songMetadata = await this.musicRepository.findSongByJoinTimestamp(
44 | albumId,
45 | joinTimestamp,
46 | );
47 | const m3u8Content = await this.getM3U8Content(albumId, songMetadata);
48 | const skipSegments = Math.floor(
49 | (joinTimestamp - songMetadata.startTime) / (this.SEGMENT_DURATION * 1000),
50 | );
51 |
52 | return this.m3u8Parser.parse(
53 | m3u8Content,
54 | skipSegments,
55 | albumId,
56 | songMetadata.id,
57 | );
58 | }
59 |
60 | async getSegment(
61 | albumId: string,
62 | songIndex: string,
63 | segmentId: string,
64 | ): Promise {
65 | return await this.getSegmentContent(albumId, songIndex, segmentId);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/server/src/music/parser/m3u8-parser.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 |
3 | @Injectable()
4 | export class M3U8Parser {
5 | parse(
6 | content: string,
7 | skipSegments: number,
8 | albumId: string,
9 | songIndex: string,
10 | ): string {
11 | const lines = content.split('\n');
12 | let segmentCount = 0;
13 |
14 | return (
15 | lines
16 | .map((line) => {
17 | if (!line) return null;
18 |
19 | if (line.startsWith('#EXT-X-MEDIA-SEQUENCE')) {
20 | return `#EXT-X-MEDIA-SEQUENCE:${skipSegments}`;
21 | }
22 |
23 | if (line.startsWith('#EXTINF') || (!line.startsWith('#') && line)) {
24 | const shouldInclude = segmentCount >= skipSegments;
25 | if (!line.startsWith('#')) {
26 | const segmentNumber = line.match(/playlist(\d+)\.ts/)?.[1];
27 | segmentCount++;
28 |
29 | if (shouldInclude && segmentNumber) {
30 | return `/api/music/${albumId}/${songIndex}/playlist${segmentNumber}.ts`;
31 | }
32 | return null;
33 | }
34 | return shouldInclude ? line : null;
35 | }
36 |
37 | return line;
38 | })
39 | .filter((line): line is string => line !== null)
40 | .join('\n') + '\n'
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/server/src/room/room.constant.ts:
--------------------------------------------------------------------------------
1 | export const ROOM_STATUS = {
2 | ACTIVE: 'active',
3 | INACTIVE: 'inactive',
4 | } as const;
5 |
--------------------------------------------------------------------------------
/server/src/room/room.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Controller,
4 | Get,
5 | Inject,
6 | Param,
7 | Post,
8 | Req,
9 | } from '@nestjs/common';
10 | import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
11 | import { RoomRepository } from '@/room/room.repository';
12 | import { REDIS_CLIENT } from '@/common/redis/redis.module';
13 | import { RedisClientType } from 'redis';
14 | import * as crypto from 'node:crypto';
15 | import { RandomNameUtil } from '@/common/randomname/random-name.util';
16 | import { Request } from 'express';
17 | import { Album } from '@/album/album.entity';
18 | import { Song } from '@/song/song.entity';
19 | import { AlbumResponseDto } from '@/album/dto/album-response.dto';
20 | import { SongResponseDto } from '@/song/dto/song-response.dto';
21 | import { plainToInstance } from 'class-transformer';
22 | import { SongRepository } from '@/song/song.repository';
23 | import { ORDER } from '@/common/constants/repository.constant';
24 | import { AlbumRepository } from '@/album/album.repository';
25 | import { RoomService } from '@/room/room.service';
26 |
27 | @ApiTags('기본')
28 | @Controller('room')
29 | export class RoomController {
30 | constructor(
31 | @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType,
32 | private readonly roomRepository: RoomRepository,
33 | private readonly albumRepository: AlbumRepository,
34 | private readonly songRepository: SongRepository,
35 | private readonly roomService: RoomService,
36 | ) {}
37 |
38 | @ApiOperation({ summary: '방 정보 확인' })
39 | @ApiParam({ name: 'roomId', required: true })
40 | @ApiResponse({ status: 200, description: 'Room info retrieved successfully' })
41 | @Get('/:roomId')
42 | async getRoomInfo(@Param('roomId') roomId: string): Promise {
43 | const roomKey = `rooms:${roomId}`;
44 | const roomInfo = await this.redisClient.hGetAll(roomKey);
45 | const albumInfo = await this.albumRepository.findById(roomId);
46 | const albumResponse = await this.getAlbumResponseDto(albumInfo);
47 | const songList = await this.songRepository.getAlbumTracksSorted(
48 | roomId,
49 | ORDER.ASC,
50 | );
51 | const songResponseList = await Promise.all(
52 | songList.map(async (song) => await this.getSongResponseDto(song)),
53 | );
54 |
55 | const totalDuration = songResponseList.reduce((acc, song) => {
56 | return acc + parseInt(song.duration);
57 | }, 0);
58 |
59 | const trackOrder = await this.roomService.getTrackOrder(roomId);
60 |
61 | return {
62 | success: true,
63 | ...roomInfo,
64 | albumResponse,
65 | songResponseList,
66 | totalDuration,
67 | trackOrder,
68 | };
69 | }
70 |
71 | async getAlbumResponseDto(album: Album): Promise {
72 | return plainToInstance(AlbumResponseDto, album, {
73 | excludeExtraneousValues: true,
74 | });
75 | }
76 |
77 | async getSongResponseDto(song: Song): Promise {
78 | return plainToInstance(SongResponseDto, song, {
79 | excludeExtraneousValues: true,
80 | });
81 | }
82 |
83 | @ApiOperation({ summary: '방 참여' })
84 | @ApiResponse({ status: 201, description: 'Joined room successfully' })
85 | @Post('/join')
86 | async joinRoom(
87 | @Req() req: Request,
88 | @Body() joinRoomDto: { userId: string; roomId: string },
89 | ): Promise