diff --git a/.github/workflows/server-cd.yml b/.github/workflows/server-cd.yml index ccfbc59..3b54edb 100644 --- a/.github/workflows/server-cd.yml +++ b/.github/workflows/server-cd.yml @@ -2,12 +2,12 @@ name: Server CD on: pull_request: - branches: [ "develop", "main" ] + branches: ["develop", "main"] paths: - "server/**" types: - closed - + jobs: deploy: runs-on: ubuntu-20.04 @@ -19,40 +19,50 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v3 - + - name: Set up Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - + cache: "npm" + cache-dependency-path: "**/package-lock.json" + - name: Install depenencies run: | - cd server - npm install + cd server + npm install - name: Create prod.env file env: - DB_HOST_IP: ${{ secrets.SERVER_ENV_DB_HOST_IP }} - DB_PORT: ${{ secrets.SERVER_ENV_DB_PORT }} - DB_USER_NAME: ${{ secrets.SERVER_ENV_DB_USER_NAME }} - DB_PASSWORD: ${{ secrets.SERVER_ENV_DB_PASSWORD }} - DB_DATABASE_NAME: ${{ secrets.SERVER_ENV_DB_DATABASE_NAME }} - ACCESS_ID: ${{ secrets.SERVER_ENV_ACCESS_ID }} - SECRET_ACCESS_KEY: ${{ secrets.SERVER_ENV_SECRET_ACCESS_KEY }} - JWT_SECRET_KEY: ${{ secrets.SERVER_ENV_JWT_SECRET_KEY }} + DB_HOST_IP: ${{ secrets.SERVER_ENV_DB_HOST_IP }} + DB_PORT: ${{ secrets.SERVER_ENV_DB_PORT }} + DB_USER_NAME: ${{ secrets.SERVER_ENV_DB_USER_NAME }} + DB_PASSWORD: ${{ secrets.SERVER_ENV_DB_PASSWORD }} + DB_DATABASE_NAME: ${{ secrets.SERVER_ENV_DB_DATABASE_NAME }} + ACCESS_ID: ${{ secrets.SERVER_ENV_ACCESS_ID }} + SECRET_ACCESS_KEY: ${{ secrets.SERVER_ENV_SECRET_ACCESS_KEY }} + JWT_SECRET_KEY: ${{ secrets.SERVER_ENV_JWT_SECRET_KEY }} + GREEN_EYE_SECRET_KEY: ${{secrets.GREEN_EYE_SECRET_KEY}} + GREEN_EYE_REQUEST_URL: ${{secrets.GREEN_EYE_REQUEST_URL}} + CLOUD_FUNCTIONS_EXECUTE_URL: ${{secrets.CLOUD_FUNCTIONS_EXECUTE_URL}} + CLOUD_FUNCTIONS_REQUEST_URL: ${{secrets.CLOUD_FUNCTIONS_REQUEST_URL}} + API_GW_ACCESS_KEY: ${{secrets.API_GW_ACCESS_KEY}} run: | - cd server - touch prod.env - echo "DB_HOST_IP=$DB_HOST_IP" >> prod.env - echo "DB_PORT=$DB_PORT" >> prod.env - echo "DB_USER_NAME=$DB_USER_NAME" >> prod.env - echo "DB_PASSWORD=$DB_PASSWORD" >> prod.env - echo "DB_DATABASE_NAME=$DB_DATABASE_NAME" >> prod.env - echo "ACCESS_ID=$ACCESS_ID" >> prod.env - echo "SECRET_ACCESS_KEY=$SECRET_ACCESS_KEY" >> prod.env - echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" >> prod.env + cd server + touch prod.env + echo "DB_HOST_IP=$DB_HOST_IP" >> prod.env + echo "DB_PORT=$DB_PORT" >> prod.env + echo "DB_USER_NAME=$DB_USER_NAME" >> prod.env + echo "DB_PASSWORD=$DB_PASSWORD" >> prod.env + echo "DB_DATABASE_NAME=$DB_DATABASE_NAME" >> prod.env + echo "ACCESS_ID=$ACCESS_ID" >> prod.env + echo "SECRET_ACCESS_KEY=$SECRET_ACCESS_KEY" >> prod.env + echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" >> prod.env + echo "GREEN_EYE_SECRET_KEY=$GREEN_EYE_SECRET_KEY" >> prod.env + echo "GREEN_EYE_REQUEST_URL=$GREEN_EYE_REQUEST_URL" >> prod.env + echo "CLOUD_FUNCTIONS_EXECUTE_URL=$CLOUD_FUNCTIONS_EXECUTE_URL" >> prod.env + echo "CLOUD_FUNCTIONS_REQUEST_URL=$CLOUD_FUNCTIONS_REQUEST_URL" >> prod.env + echo "API_GW_ACCESS_KEY=$API_GW_ACCESS_KEY" >> prod.env - name: Build Docker image run: docker build --platform linux/amd64 ./server -t ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest @@ -66,14 +76,14 @@ jobs: - name: SSH into Server uses: appleboy/ssh-action@master with: - host: ${{ secrets.SERVER_SSH_HOST }} - username: ${{ secrets.SERVER_SSH_USER }} - password: ${{ secrets.SERVER_SSH_PASSWORD }} - port: ${{ secrets.SERVER_SSH_PORT }} - script: | - docker login ${{ secrets.NCP_REGISTRY }} -u ${{ secrets.NCP_DOCKER_ACCESS_KEY_ID }} -p ${{ secrets.NCP_DOCKER_SECRET_KEY }} - docker pull ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest - docker stop catchy-tape-latest - docker rm catchy-tape-latest - docker run -d -p 3000:3000 --name catchy-tape-latest ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest - curl -X POST -H 'Content-type: application/json' --data '{"text":"서버 배포 성공!"}' ${{ secrets.SLACK_WEBHOOK_URL }} + host: ${{ secrets.SERVER_SSH_HOST }} + username: ${{ secrets.SERVER_SSH_USER }} + password: ${{ secrets.SERVER_SSH_PASSWORD }} + port: ${{ secrets.SERVER_SSH_PORT }} + script: | + docker login ${{ secrets.NCP_REGISTRY }} -u ${{ secrets.NCP_DOCKER_ACCESS_KEY_ID }} -p ${{ secrets.NCP_DOCKER_SECRET_KEY }} + docker pull ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest + docker stop catchy-tape-latest + docker rm catchy-tape-latest + docker run -d -p 3000:3000 --name catchy-tape-latest ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest + curl -X POST -H 'Content-type: application/json' --data '{"text":"서버 배포 성공!"}' ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/server-dev-ci.yml b/.github/workflows/server-dev-ci.yml index 2a02fb5..efaebea 100644 --- a/.github/workflows/server-dev-ci.yml +++ b/.github/workflows/server-dev-ci.yml @@ -34,5 +34,36 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: npm install + - name: Create dev.env file + env: + DB_HOST_IP: ${{ secrets.SERVER_DEV_DB_HOST_IP }} + DB_PORT: ${{ secrets.SERVER_DEV_DB_PORT }} + DB_USER_NAME: ${{ secrets.SERVER_DEV_DB_USER_NAME }} + DB_PASSWORD: ${{ secrets.SERVER_DEV_DB_PASSWORD }} + DB_DATABASE_NAME: ${{ secrets.SERVER_DEV_DB_DATABASE_NAME }} + ACCESS_ID: ${{ secrets.SERVER_ENV_ACCESS_ID }} + SECRET_ACCESS_KEY: ${{ secrets.SERVER_ENV_SECRET_ACCESS_KEY }} + JWT_SECRET_KEY: ${{ secrets.SERVER_ENV_JWT_SECRET_KEY }} + GREEN_EYE_SECRET_KEY: ${{secrets.GREEN_EYE_SECRET_KEY}} + GREEN_EYE_REQUEST_URL: ${{secrets.GREEN_EYE_REQUEST_URL}} + CLOUD_FUNCTIONS_EXECUTE_URL: ${{secrets.CLOUD_FUNCTIONS_EXECUTE_URL}} + CLOUD_FUNCTIONS_REQUEST_URL: ${{secrets.CLOUD_FUNCTIONS_REQUEST_URL}} + API_GW_ACCESS_KEY: ${{secrets.API_GW_ACCESS_KEY}} + run: | + touch dev.env + echo "DB_HOST_IP=$DB_HOST_IP" >> dev.env + echo "DB_PORT=$DB_PORT" >> dev.env + echo "DB_USER_NAME=$DB_USER_NAME" >> dev.env + echo "DB_PASSWORD=$DB_PASSWORD" >> dev.env + echo "DB_DATABASE_NAME=$DB_DATABASE_NAME" >> dev.env + echo "ACCESS_ID=$ACCESS_ID" >> dev.env + echo "SECRET_ACCESS_KEY=$SECRET_ACCESS_KEY" >> dev.env + echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" >> dev.env + echo "GREEN_EYE_SECRET_KEY=$GREEN_EYE_SECRET_KEY" >> dev.env + echo "GREEN_EYE_REQUEST_URL=$GREEN_EYE_REQUEST_URL" >> dev.env + echo "CLOUD_FUNCTIONS_EXECUTE_URL" >> dev.env + echo "CLOUD_FUNCTIONS_REQUEST_URL" >> dev.env + echo "API_GW_ACCESS_KEY" >> dev.env + - name: Execute Test run: npm run test diff --git a/README.md b/README.md index d7c3755..b460f76 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,14 @@ ### 📡 Server | Category | TechStack | | ------------- | ------------- | -| | | +| Framework, Language | NestJS, TypeScript | +| DB | MySQL | +| ORM | TypeORM | +| Test | Jest | +| API Docs | SwaggerHub | +| CI/CD | Github Actions | +| NCP | Server, Container Registry, VPC, Object Storage| +
그 외 기록 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c48c4ae..1a7a08f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.ohdodok.catchytape" minSdk = 26 targetSdk = 33 - versionCode = 2 - versionName = "0.2.0" + versionCode = 3 + versionName = "0.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -66,7 +66,9 @@ dependencies { implementation(project(":feature:player")) implementation(project(":feature:playlist")) implementation(project(":feature:mypage")) + implementation(project(":feature:search")) implementation(project(":core:data")) + implementation(project(":core:domain")) implementation(libs.core.ktx) implementation(libs.appcompat) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ac85f4..c26ecfc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,7 +28,7 @@ android:exported="true" /> diff --git a/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt b/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt index e324875..89d1968 100644 --- a/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt +++ b/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt @@ -1,5 +1,6 @@ package com.ohdodok.catchytape +import android.animation.ObjectAnimator import android.content.ComponentName import android.net.ConnectivityManager import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED @@ -8,6 +9,8 @@ import android.view.View import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.animation.doOnEnd +import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -19,17 +22,25 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.navigation.ui.setupWithNavController import com.ohdodok.catchytape.databinding.ActivityMainBinding +import com.ohdodok.catchytape.feature.player.PlayerEvent import com.ohdodok.catchytape.feature.player.PlayerListener import com.ohdodok.catchytape.feature.player.PlayerViewModel -import com.ohdodok.catchytape.mediacontrol.PlaybackService +import com.ohdodok.catchytape.feature.player.getMediasWithMetaData import com.ohdodok.catchytape.feature.player.millisecondsPerSecond +import com.ohdodok.catchytape.feature.player.moveNextMedia import com.ohdodok.catchytape.feature.player.navigateToPlayer +import com.ohdodok.catchytape.feature.player.onPreviousBtnClick +import com.ohdodok.catchytape.mediasession.PlaybackService import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import javax.inject.Inject import com.ohdodok.catchytape.core.ui.R.string as uiString +private const val BOTTOM_NAV_ANIMATION_DURATION = 700L + @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -39,14 +50,16 @@ class MainActivity : AppCompatActivity() { private lateinit var connectivityManager: ConnectivityManager private val playViewModel: PlayerViewModel by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + binding.viewModel = playViewModel binding.lifecycleOwner = this + WindowCompat.setDecorFitsSystemWindows(window, false) + setupBottomNav() setUpPlayerController() connectivityManager = getSystemService(ConnectivityManager::class.java) @@ -57,6 +70,9 @@ class MainActivity : AppCompatActivity() { setupPlayer() setupPlayButton() + setupPreviousButton() + setupNextButton() + observePlaylistChange() } override fun onStart() { @@ -106,11 +122,20 @@ class MainActivity : AppCompatActivity() { } private fun hideBottomNav() { - binding.bottomNav.visibility = View.GONE + val height = binding.bottomNav.height.toFloat() + ObjectAnimator.ofFloat(binding.bottomNav, "translationY", height).apply { + duration = BOTTOM_NAV_ANIMATION_DURATION + doOnEnd { binding.bottomNav.visibility = View.GONE } + start() + } } private fun showBottomNav() { binding.bottomNav.visibility = View.VISIBLE + ObjectAnimator.ofFloat(binding.bottomNav, "translationY", 0f).apply { + duration = BOTTOM_NAV_ANIMATION_DURATION + start() + } } private fun hidePlayerController() { @@ -121,7 +146,6 @@ class MainActivity : AppCompatActivity() { binding.pcvController.visibility = View.VISIBLE } - private fun setupPlayer() { player.addListener(PlayerListener(playViewModel)) player.prepare() @@ -142,6 +166,22 @@ class MainActivity : AppCompatActivity() { } } + private fun observePlaylistChange() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + playViewModel.events + .filterIsInstance() + .collectLatest { event -> + val newItems = getMediasWithMetaData(event.currentPlaylist.musics) + player.clearMediaItems() + player.setMediaItems(newItems) + + player.seekTo(event.currentPlaylist.startMusicIndex, 0) + player.play() + } + } + } + } private fun setupPlayButton() { binding.pcvController.setOnPlayButtonClick { @@ -149,4 +189,16 @@ class MainActivity : AppCompatActivity() { else player.play() } } + + private fun setupPreviousButton() { + binding.pcvController.setOnPreviousButtonClick { + player.onPreviousBtnClick() + } + } + + private fun setupNextButton() { + binding.pcvController.setOnNextButtonClick { + player.moveNextMedia() + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/ohdodok/catchytape/mediacontrol/PlaybackService.kt b/android/app/src/main/java/com/ohdodok/catchytape/mediasession/PlaybackService.kt similarity index 82% rename from android/app/src/main/java/com/ohdodok/catchytape/mediacontrol/PlaybackService.kt rename to android/app/src/main/java/com/ohdodok/catchytape/mediasession/PlaybackService.kt index d84e832..59796f6 100644 --- a/android/app/src/main/java/com/ohdodok/catchytape/mediacontrol/PlaybackService.kt +++ b/android/app/src/main/java/com/ohdodok/catchytape/mediasession/PlaybackService.kt @@ -1,5 +1,6 @@ -package com.ohdodok.catchytape.mediacontrol +package com.ohdodok.catchytape.mediasession +import android.content.Intent import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService @@ -26,6 +27,11 @@ class PlaybackService : MediaSessionService() { super.onDestroy() } + override fun onTaskRemoved(rootIntent: Intent?) { + player.pause() + stopSelf() + } + override fun onGetSession( controllerInfo: MediaSession.ControllerInfo ): MediaSession? = mediaSession diff --git a/android/app/src/main/res/drawable/ic_search.xml b/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..82383d4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 7d4c4e6..4fcaf14 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -5,6 +5,10 @@ + + @@ -31,21 +35,23 @@ android:id="@+id/pcv_controller" android:layout_width="0dp" android:layout_height="65dp" - app:artist="@string/artist" app:duration="@{viewModel.uiState.duration}" - app:playing="@{viewModel.uiState.isPlaying}" app:layout_constraintBottom_toTopOf="@id/bottom_nav" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:progress="@{viewModel.uiState.currentPositionSecond}" - app:thumbnailUrl="https://picsum.photos/200" - app:title="@string/title" /> - + app:music="@{viewModel.uiState.currentMusic}" + app:nextEnabled="@{viewModel.uiState.nextEnable}" + app:playEnabled="@{viewModel.uiState.playEnable}" + app:playing="@{viewModel.uiState.isPlaying}" + app:previousEnabled="@{viewModel.uiState.previousEnable}" + app:progress="@{viewModel.uiState.currentPositionSecond}" /> + + - + \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt index 9384533..e0bbb3e 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt @@ -6,6 +6,7 @@ import com.ohdodok.catchytape.core.data.model.MusicResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Query interface MusicApi { @@ -22,4 +23,10 @@ interface MusicApi { @GET("musics/my-uploads") suspend fun getMyUploads(): List + + + @GET("musics/search") + suspend fun getSearchedMusics( + @Query("keyword") keyword: String + ): List } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/PlaylistApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/PlaylistApi.kt index 4292867..b1a2329 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/PlaylistApi.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/PlaylistApi.kt @@ -1,10 +1,13 @@ package com.ohdodok.catchytape.core.data.api +import com.ohdodok.catchytape.core.data.model.AddMusicToPlaylistRequest +import com.ohdodok.catchytape.core.data.model.MusicResponse import com.ohdodok.catchytape.core.data.model.PlaylistRequest import com.ohdodok.catchytape.core.data.model.PlaylistResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path interface PlaylistApi { @@ -16,4 +19,14 @@ interface PlaylistApi { @Body title: PlaylistRequest ) + @GET("playlists/{playlistId}") + suspend fun getPlaylist( + @Path("playlistId")playlistId: Int + ): List + + @POST("playlists/{playlistId}") + suspend fun postMusicToPlaylist( + @Path("playlistId")playlistId: Int, + @Body music: AddMusicToPlaylistRequest, + ) } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt index a9b3d37..348bda8 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt @@ -2,6 +2,8 @@ package com.ohdodok.catchytape.core.data.api import com.ohdodok.catchytape.core.data.model.LoginRequest import com.ohdodok.catchytape.core.data.model.LoginResponse +import com.ohdodok.catchytape.core.data.model.MusicIdRequest +import com.ohdodok.catchytape.core.data.model.MusicResponse import com.ohdodok.catchytape.core.data.model.NicknameResponse import com.ohdodok.catchytape.core.data.model.SignUpRequest import retrofit2.Response @@ -9,6 +11,7 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path interface UserApi { @@ -32,4 +35,12 @@ interface UserApi { suspend fun verify( @Header("Authorization") accessToken: String, ): Response + + @GET("users/recent-played") + suspend fun getRecentPlayed(): List + + @PUT("users/recent-played") + suspend fun putRecentPlayed( + @Body musicIdRequest: MusicIdRequest, + ) } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/AddMusicToPlaylistRequest.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/AddMusicToPlaylistRequest.kt new file mode 100644 index 0000000..e0ad689 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/AddMusicToPlaylistRequest.kt @@ -0,0 +1,8 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class AddMusicToPlaylistRequest( + val musicId: String +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicIdRequest.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicIdRequest.kt new file mode 100644 index 0000000..de13f4c --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicIdRequest.kt @@ -0,0 +1,8 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicIdRequest( + val musicId: String +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt index 3a246bd..90f030e 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt @@ -20,7 +20,8 @@ data class MusicResponse ( id = musicId, title = title, artist = user.nickname, - imageUrl = cover + imageUrl = cover, + musicUrl = musicFile, ) } } diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt index bc2394a..df7e5f8 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt @@ -1,6 +1,8 @@ package com.ohdodok.catchytape.core.data.repository import com.ohdodok.catchytape.core.data.api.MusicApi +import com.ohdodok.catchytape.core.data.api.UserApi +import com.ohdodok.catchytape.core.data.model.MusicIdRequest import com.ohdodok.catchytape.core.data.model.MusicRequest import com.ohdodok.catchytape.core.data.model.toDomains import com.ohdodok.catchytape.core.domain.model.Music @@ -10,7 +12,8 @@ import kotlinx.coroutines.flow.flow import javax.inject.Inject class MusicRepositoryImpl @Inject constructor( - private val musicApi: MusicApi + private val musicApi: MusicApi, + private val userApi: UserApi ) : MusicRepository { override fun getGenres(): Flow> = flow { @@ -47,4 +50,13 @@ class MusicRepositoryImpl @Inject constructor( val myMusics = musicApi.getMyUploads() emit(myMusics.toDomains()) } + + override fun getSearchedMusics(keyword: String): Flow> = flow { + val searchedMusics = musicApi.getSearchedMusics(keyword) + emit(searchedMusics.toDomains()) + } + + override suspend fun updateRecentPlayedMusic(musicId: String) { + userApi.putRecentPlayed(MusicIdRequest(musicId = musicId)) + } } diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/PlaylistRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/PlaylistRepositoryImpl.kt index defecec..c3e07b1 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/PlaylistRepositoryImpl.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/PlaylistRepositoryImpl.kt @@ -1,7 +1,11 @@ package com.ohdodok.catchytape.core.data.repository import com.ohdodok.catchytape.core.data.api.PlaylistApi +import com.ohdodok.catchytape.core.data.api.UserApi +import com.ohdodok.catchytape.core.data.model.AddMusicToPlaylistRequest import com.ohdodok.catchytape.core.data.model.PlaylistRequest +import com.ohdodok.catchytape.core.data.model.toDomains +import com.ohdodok.catchytape.core.domain.model.Music import com.ohdodok.catchytape.core.domain.model.Playlist import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository import kotlinx.coroutines.flow.Flow @@ -10,19 +14,35 @@ import javax.inject.Inject class PlaylistRepositoryImpl @Inject constructor( - private val playlistApi: PlaylistApi + private val playlistApi: PlaylistApi, + private val userApi: UserApi, ) : PlaylistRepository { - override fun getPlaylists(): Flow> = flow { val playlistResponse = playlistApi.getPlaylists() - emit( playlistResponse.map { it.toDomain() }) + emit(playlistResponse.map { it.toDomain() }) } - override suspend fun postPlaylist(title: String) { playlistApi.postPlaylist(PlaylistRequest(title = title)) } + + override fun getRecentPlaylist(): Flow> = flow { + val response = userApi.getRecentPlayed() + emit(response.toDomains()) + } + + override fun getPlaylist(playlistId: Int): Flow> = flow { + val response = playlistApi.getPlaylist(playlistId) + emit(response.toDomains()) + } + + override suspend fun addMusicToPlaylist(playlistId: Int, musicId: String) { + playlistApi.postMusicToPlaylist( + playlistId = playlistId, + music = AddMusicToPlaylistRequest(musicId), + ) + } } diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CtErrorType.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CtErrorType.kt index 38ee7cb..ee1276c 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CtErrorType.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CtErrorType.kt @@ -15,10 +15,14 @@ enum class CtErrorType(val errorCode: Int) { NOT_EXIST_USER(4005), ALREADY_EXIST_EMAIL(4006), NOT_EXIST_GENRE(4007), + INVALID_GREEN_EYE_REQUEST(4011), + FAIL_GREEN_EYE_IMAGE_RECOGNITION(4012), + BAD_IMAGE(4013), WRONG_TOKEN(4100), EXPIRED_TOKEN(4101), SERVER(5000), - SERVICE(5001); + SERVICE(5001), + ENCODING_FAILURE(5002); companion object { val ctErrorEnums = CtErrorType.values().toList() diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CurrentPlaylist.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CurrentPlaylist.kt new file mode 100644 index 0000000..69f7114 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/CurrentPlaylist.kt @@ -0,0 +1,16 @@ +package com.ohdodok.catchytape.core.domain.model + +data class CurrentPlaylist( + val startMusic: Music, + val musics: List, +) { + val startMusicIndex: Int + + init { + require(musics.contains(startMusic)) { + "재생할 노래가 재생할 노래 목록에 포함되어 있어야 합니다." + } + + startMusicIndex = musics.indexOf(startMusic) + } +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt index d157001..8d35a20 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt @@ -4,5 +4,6 @@ data class Music( val id: String, val title: String, val artist: String, - val imageUrl: String + val imageUrl: String, + val musicUrl: String, ) diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt index 2168d01..e191636 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt @@ -13,4 +13,7 @@ interface MusicRepository { fun getMyMusics(): Flow> + fun getSearchedMusics(keyword: String): Flow> + + suspend fun updateRecentPlayedMusic(musicId: String) } \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/PlaylistRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/PlaylistRepository.kt index bb2b7eb..c6f19e2 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/PlaylistRepository.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/PlaylistRepository.kt @@ -1,5 +1,6 @@ package com.ohdodok.catchytape.core.domain.repository +import com.ohdodok.catchytape.core.domain.model.Music import com.ohdodok.catchytape.core.domain.model.Playlist import kotlinx.coroutines.flow.Flow @@ -8,4 +9,10 @@ interface PlaylistRepository { fun getPlaylists(): Flow> suspend fun postPlaylist(title: String) + + fun getRecentPlaylist(): Flow> + + fun getPlaylist(playlistId: Int): Flow> + + suspend fun addMusicToPlaylist(playlistId: Int, musicId: String) } \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/player/CurrentPlayListUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/player/CurrentPlayListUseCase.kt new file mode 100644 index 0000000..d930094 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/player/CurrentPlayListUseCase.kt @@ -0,0 +1,30 @@ +package com.ohdodok.catchytape.core.domain.usecase.player + +import com.ohdodok.catchytape.core.domain.model.CurrentPlaylist +import com.ohdodok.catchytape.core.domain.model.Music +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CurrentPlaylistUseCase @Inject constructor() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _currentPlaylist = Channel() + val currentPlaylist: ReceiveChannel = _currentPlaylist + + fun playMusics(startMusic: Music, musics: List) { + val newPlaylist = CurrentPlaylist( + startMusic = startMusic, + musics = musics, + ) + + scope.launch { + _currentPlaylist.send(newPlaylist) + } + } +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/playlist/GetPlaylistUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/playlist/GetPlaylistUseCase.kt new file mode 100644 index 0000000..cb2b726 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/playlist/GetPlaylistUseCase.kt @@ -0,0 +1,20 @@ +package com.ohdodok.catchytape.core.domain.usecase.playlist + +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetPlaylistUseCase @Inject constructor( + private val playlistRepository: PlaylistRepository +) { + + operator fun invoke(playlistId: Int): Flow> { + return if (playlistId == RECENT_PLAYLIST_ID) { + playlistRepository.getRecentPlaylist() + } else { + playlistRepository.getPlaylist(playlistId) + } + } + +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/playlist/GetPlaylistsUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/playlist/GetPlaylistsUseCase.kt new file mode 100644 index 0000000..2fd2768 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/playlist/GetPlaylistsUseCase.kt @@ -0,0 +1,26 @@ +package com.ohdodok.catchytape.core.domain.usecase.playlist + +import com.ohdodok.catchytape.core.domain.model.Playlist +import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +const val RECENT_PLAYLIST_ID = 0 + +class GetPlaylistsUseCase @Inject constructor( + private val playlistRepository: PlaylistRepository +) { + + operator fun invoke(): Flow> = combine( + playlistRepository.getPlaylists(), + playlistRepository.getRecentPlaylist() + ) { playlists, recentPlaylist -> + (playlists + Playlist( + id = RECENT_PLAYLIST_ID, + title = "최근 재생 목록", + thumbnailUrl = recentPlaylist.firstOrNull()?.imageUrl ?: "", + trackSize = recentPlaylist.size, + )).sortedBy { it.id } + } +} \ No newline at end of file diff --git a/android/core/ui/build.gradle.kts b/android/core/ui/build.gradle.kts index f664468..58ef4f2 100644 --- a/android/core/ui/build.gradle.kts +++ b/android/core/ui/build.gradle.kts @@ -1,6 +1,7 @@ @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { id("catchytape.android.library") + id("catchytape.android.hilt") } android { diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt index ec6821f..0ade2eb 100644 --- a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt @@ -14,7 +14,9 @@ fun RecyclerView.bindItems(items: List) { } @BindingAdapter("imgUrl") -fun ImageView.bindImg(url: String) { +fun ImageView.bindImg(url: String?) { + if (url == null) return + Glide.with(this.context) .load(url) .into(this) diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt index 34cb5c5..44699d6 100644 --- a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt @@ -6,17 +6,20 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.ui.MusicAdapter.Listener import com.ohdodok.catchytape.core.ui.databinding.ItemMusicHorizontalBinding import com.ohdodok.catchytape.core.ui.databinding.ItemMusicVerticalBinding -class MusicAdapter(private val musicItemOrientation: Orientation) : - ListAdapter(MusicDiffUtil) { +class MusicAdapter( + private val musicItemOrientation: Orientation, + private val listener: Listener = Listener { }, // todo : 클릭 이벤트 구현이 완료되면 디폴트 값을 지워주세요. +) : ListAdapter(MusicDiffUtil) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (musicItemOrientation) { - Orientation.HORIZONTAL -> HorizontalViewHolder.from(parent) - Orientation.VERTICAL -> VerticalViewHolder.from(parent) + Orientation.HORIZONTAL -> HorizontalViewHolder.from(parent, listener) + Orientation.VERTICAL -> VerticalViewHolder.from(parent, listener) } } @@ -28,41 +31,55 @@ class MusicAdapter(private val musicItemOrientation: Orientation) : } - class HorizontalViewHolder private constructor(private val binding: ItemMusicHorizontalBinding) : + class HorizontalViewHolder private constructor( + private val binding: ItemMusicHorizontalBinding, + private val listener: Listener, + ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: Music) { binding.music = item + binding.listener = listener } companion object { - fun from(parent: ViewGroup) = HorizontalViewHolder( + fun from(parent: ViewGroup, listener: Listener) = HorizontalViewHolder( ItemMusicHorizontalBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), + listener ) } } - class VerticalViewHolder private constructor(private val binding: ItemMusicVerticalBinding) : + class VerticalViewHolder private constructor( + private val binding: ItemMusicVerticalBinding, + private val listener: Listener, + ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: Music) { binding.music = item + binding.listener = listener } companion object { - fun from(parent: ViewGroup) = VerticalViewHolder( + fun from(parent: ViewGroup, listener: Listener) = VerticalViewHolder( ItemMusicVerticalBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), + listener ) } } + + fun interface Listener { + fun onClick(music: Music) + } } object MusicDiffUtil : DiffUtil.ItemCallback() { diff --git a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistAdapter.kt similarity index 69% rename from android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistAdapter.kt rename to android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistAdapter.kt index 4f6fc19..4f0ac67 100644 --- a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistAdapter.kt +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistAdapter.kt @@ -1,15 +1,15 @@ -package com.ohdodok.catchytape.feature.playlist +package com.ohdodok.catchytape.core.ui import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import com.ohdodok.catchytape.core.domain.model.Playlist -import com.ohdodok.catchytape.feature.playlist.databinding.ItemPlaylistBinding +import com.ohdodok.catchytape.core.ui.databinding.ItemPlaylistBinding +import com.ohdodok.catchytape.core.ui.model.PlaylistUiModel class PlaylistAdapter : - ListAdapter(PlaylistItemDiffUtil) { + ListAdapter(PlaylistItemDiffUtil) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlaylistViewHolder { return PlaylistViewHolder.from(parent) @@ -22,7 +22,7 @@ class PlaylistAdapter : class PlaylistViewHolder private constructor(private val binding: ItemPlaylistBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: Playlist) { + fun bind(item: PlaylistUiModel) { binding.playlist = item } @@ -37,12 +37,12 @@ class PlaylistAdapter : } } - object PlaylistItemDiffUtil : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Playlist, newItem: Playlist): Boolean { + object PlaylistItemDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PlaylistUiModel, newItem: PlaylistUiModel): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean { + override fun areContentsTheSame(oldItem: PlaylistUiModel, newItem: PlaylistUiModel): Boolean { return oldItem == newItem } } diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistBottomSheet.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistBottomSheet.kt new file mode 100644 index 0000000..beefc37 --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistBottomSheet.kt @@ -0,0 +1,53 @@ +package com.ohdodok.catchytape.core.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.ohdodok.catchytape.core.ui.databinding.BottomSheetPlaylistBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class PlaylistBottomSheet : BottomSheetDialogFragment() { + val viewModel: PlaylistBottomSheetViewModel by viewModels() + + private var _binding: BottomSheetPlaylistBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetPlaylistBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + binding.rvPlaylists.adapter = PlaylistAdapter() + + observeEvent() + + return binding.root + } + + private fun observeEvent() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.closeEvent.collect { + findNavController().popBackStack() + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistBottomSheetViewModel.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistBottomSheetViewModel.kt new file mode 100644 index 0000000..32da440 --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/PlaylistBottomSheetViewModel.kt @@ -0,0 +1,69 @@ +package com.ohdodok.catchytape.core.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.model.Playlist +import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository +import com.ohdodok.catchytape.core.ui.model.PlaylistUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PlaylistBottomSheetViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val playlistRepository: PlaylistRepository, +) : ViewModel() { + + private val musicId: String = requireNotNull(savedStateHandle["musicId"]) { + "musicId 정보가 누락되었어요." + } + + private val _playlists = MutableStateFlow(emptyList()) + val playlists: StateFlow> = _playlists.asStateFlow() + + private val _closeEvent = MutableSharedFlow() + val closeEvent: SharedFlow = _closeEvent.asSharedFlow() + + init { + fetchPlaylists() + } + + private fun fetchPlaylists() { + viewModelScope.launch { + _playlists.value = playlistRepository.getPlaylists().first().toUiModels() + } + } + + private fun addMusicToPlaylist(playlistId: Int, musicId: String) { + viewModelScope.launch { + playlistRepository.addMusicToPlaylist(playlistId = playlistId, musicId = musicId) + _closeEvent.emit(Unit) + } + } + + private fun List.toUiModels(): List = this.map { it.toUiModel() } + + private fun Playlist.toUiModel(): PlaylistUiModel { + return PlaylistUiModel( + id = id, + title = title, + thumbnailUrl = thumbnailUrl, + trackSize = trackSize, + onClick = { + addMusicToPlaylist( + playlistId = id, + musicId = musicId + ) + } + ) + } +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/RootViewInsetsCallback.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/RootViewInsetsCallback.kt new file mode 100644 index 0000000..fb7f432 --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/RootViewInsetsCallback.kt @@ -0,0 +1,18 @@ +package com.ohdodok.catchytape.core.ui + +import android.os.Build +import android.view.View +import androidx.annotation.RequiresApi +import androidx.core.view.WindowInsetsCompat + +class RootViewInsetsCallback : androidx.core.view.OnApplyWindowInsetsListener { + @RequiresApi(Build.VERSION_CODES.R) + override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val systemBar = WindowInsetsCompat.Type.systemBars() + + val typeInsets = insets.getInsets(systemBar) + view.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, 0) + + return WindowInsetsCompat.CONSUMED + } +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/Extension.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/cterror/CtError.kt similarity index 78% rename from android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/Extension.kt rename to android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/cterror/CtError.kt index 2f87757..e30071a 100644 --- a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/Extension.kt +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/cterror/CtError.kt @@ -1,7 +1,7 @@ -package com.ohdodok.catchytape.core.ui +package com.ohdodok.catchytape.core.ui.cterror import com.ohdodok.catchytape.core.domain.model.CtErrorType - +import com.ohdodok.catchytape.core.ui.R fun CtErrorType.toMessageId(): Int { return when (this) { @@ -22,6 +22,10 @@ fun CtErrorType.toMessageId(): Int { CtErrorType.NOT_EXIST_GENRE -> R.string.error_message_not_exist_genre CtErrorType.EXPIRED_TOKEN -> R.string.error_message_expired_token CtErrorType.SERVICE -> R.string.error_message_service + CtErrorType.BAD_IMAGE -> R.string.error_message_bad_image + CtErrorType.ENCODING_FAILURE -> R.string.error_encoding_failure + CtErrorType.FAIL_GREEN_EYE_IMAGE_RECOGNITION -> R.string.error_message_server + CtErrorType.INVALID_GREEN_EYE_REQUEST -> R.string.error_message_server } } diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/model/PlaylistUiModel.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/model/PlaylistUiModel.kt new file mode 100644 index 0000000..40ddc9b --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/model/PlaylistUiModel.kt @@ -0,0 +1,9 @@ +package com.ohdodok.catchytape.core.ui.model + +data class PlaylistUiModel( + val id: Int, + val title: String, + val thumbnailUrl: String, + val trackSize: Int, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/android/core/ui/src/main/res/color/navigation_view_item_icon_tint.xml b/android/core/ui/src/main/res/color/navigation_view_item_icon_tint.xml new file mode 100644 index 0000000..d4f0de0 --- /dev/null +++ b/android/core/ui/src/main/res/color/navigation_view_item_icon_tint.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/android/core/ui/src/main/res/drawable/btn_circle_background.xml b/android/core/ui/src/main/res/drawable/btn_circle_background.xml new file mode 100644 index 0000000..3c03914 --- /dev/null +++ b/android/core/ui/src/main/res/drawable/btn_circle_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/core/ui/src/main/res/drawable/gradient_background.xml b/android/core/ui/src/main/res/drawable/gradient_background.xml new file mode 100644 index 0000000..1ffb949 --- /dev/null +++ b/android/core/ui/src/main/res/drawable/gradient_background.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/android/core/ui/src/main/res/drawable/ic_next.xml b/android/core/ui/src/main/res/drawable/ic_next.xml index a0f7a6f..e586993 100644 --- a/android/core/ui/src/main/res/drawable/ic_next.xml +++ b/android/core/ui/src/main/res/drawable/ic_next.xml @@ -1,9 +1,21 @@ - - - + + + + + + + + + + + + + diff --git a/android/core/ui/src/main/res/drawable/ic_play.xml b/android/core/ui/src/main/res/drawable/ic_play.xml index f711f9c..b32ab74 100644 --- a/android/core/ui/src/main/res/drawable/ic_play.xml +++ b/android/core/ui/src/main/res/drawable/ic_play.xml @@ -1,9 +1,25 @@ - - - + + + + + + + + + + + + + diff --git a/android/core/ui/src/main/res/drawable/ic_previous.xml b/android/core/ui/src/main/res/drawable/ic_previous.xml index 5e76bb2..dd9dc3a 100644 --- a/android/core/ui/src/main/res/drawable/ic_previous.xml +++ b/android/core/ui/src/main/res/drawable/ic_previous.xml @@ -1,9 +1,21 @@ - - - + + + + + + + + + + + + + diff --git a/android/core/ui/src/main/res/drawable/search_bar_background.xml b/android/core/ui/src/main/res/drawable/search_bar_background.xml new file mode 100644 index 0000000..dcb88e5 --- /dev/null +++ b/android/core/ui/src/main/res/drawable/search_bar_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/core/ui/src/main/res/layout/bottom_sheet_playlist.xml b/android/core/ui/src/main/res/layout/bottom_sheet_playlist.xml new file mode 100644 index 0000000..69a58d5 --- /dev/null +++ b/android/core/ui/src/main/res/layout/bottom_sheet_playlist.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/core/ui/src/main/res/layout/item_music_horizontal.xml b/android/core/ui/src/main/res/layout/item_music_horizontal.xml index 0c1edcb..8bb9a12 100644 --- a/android/core/ui/src/main/res/layout/item_music_horizontal.xml +++ b/android/core/ui/src/main/res/layout/item_music_horizontal.xml @@ -8,17 +8,23 @@ + + + + + android:onClick="@{() -> listener.onClick(music)}" + android:paddingVertical="@dimen/small"> + type="com.ohdodok.catchytape.core.ui.model.PlaylistUiModel" /> @color/primary @color/primary - @color/surface + @android:color/transparent false @color/surface @color/on_surface + + @color/primary_container + + - - \ No newline at end of file diff --git a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt index 1836d49..9c2c6d2 100644 --- a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt +++ b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt @@ -3,13 +3,16 @@ package com.ohdodok.catchytape.feature.home import android.os.Bundle import android.view.View import androidx.core.net.toUri +import androidx.core.view.ViewCompat import androidx.fragment.app.viewModels +import androidx.navigation.NavController import androidx.navigation.NavDeepLinkRequest import androidx.navigation.fragment.findNavController import com.ohdodok.catchytape.core.ui.BaseFragment import com.ohdodok.catchytape.core.ui.MusicAdapter import com.ohdodok.catchytape.core.ui.Orientation -import com.ohdodok.catchytape.core.ui.toMessageId +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import com.ohdodok.catchytape.feature.home.databinding.FragmentHomeBinding import dagger.hilt.android.AndroidEntryPoint @@ -20,24 +23,19 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) + binding.viewModel = viewModel - binding.rvRecentlyAddedSong.adapter = MusicAdapter(musicItemOrientation = Orientation.HORIZONTAL) + + binding.rvRecentlyAddedSong.adapter = MusicAdapter( + musicItemOrientation = Orientation.HORIZONTAL, + listener = viewModel, + ) observeEvents() viewModel.fetchUploadedMusics() - - binding.ibUpload.setOnClickListener { - val request = NavDeepLinkRequest.Builder - .fromUri("android-app://com.ohdodok.catchytape/upload_fragment".toUri()) - .build() - findNavController().navigate(request) - } - - binding.ivRecentlyPlayedSong.setOnClickListener { - val request = NavDeepLinkRequest.Builder - .fromUri("android-app://com.ohdodok.catchytape/player_fragment".toUri()) - .build() - findNavController().navigate(request) - } + viewModel.fetchRecentlyPlayedMusics() + setupButtons() } private fun observeEvents() { @@ -47,8 +45,29 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) { is HomeEvent.ShowMessage -> { showMessage(event.error.toMessageId()) } + + is HomeEvent.NavigateToPlayerScreen -> { + findNavController().navigateToPlayerScreen() + } } } } } + + private fun setupButtons() { + binding.ibUpload.setOnClickListener { + val request = + NavDeepLinkRequest.Builder.fromUri("android-app://com.ohdodok.catchytape/upload_fragment".toUri()) + .build() + findNavController().navigate(request) + } + } +} + +private fun NavController.navigateToPlayerScreen() { + val request = + NavDeepLinkRequest.Builder.fromUri("android-app://com.ohdodok.catchytape/player_fragment".toUri()) + .build() + + this.navigate(request) } \ No newline at end of file diff --git a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt index 8442230..eb778dc 100644 --- a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt +++ b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt @@ -6,6 +6,9 @@ import com.ohdodok.catchytape.core.domain.model.CtErrorType import com.ohdodok.catchytape.core.domain.model.CtException import com.ohdodok.catchytape.core.domain.model.Music import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository +import com.ohdodok.catchytape.core.domain.usecase.player.CurrentPlaylistUseCase +import com.ohdodok.catchytape.core.ui.MusicAdapter import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,13 +24,18 @@ import kotlinx.coroutines.plus import javax.inject.Inject data class HomeUiState( - val recentlyUploadedMusics: List = emptyList() -) + val recentlyUploadedMusics: List = emptyList(), + val recentlyPlayedMusics: List = emptyList(), +) { + val firstRecentlyPlayedMusicImageUrl: String? = recentlyPlayedMusics.firstOrNull()?.imageUrl +} @HiltViewModel class HomeViewModel @Inject constructor( - private val musicRepository: MusicRepository -) : ViewModel() { + private val musicRepository: MusicRepository, + private val playlistRepository: PlaylistRepository, + private val currentPlaylistUseCase: CurrentPlaylistUseCase, +) : ViewModel(), MusicAdapter.Listener { private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> viewModelScope.launch { @@ -55,8 +63,32 @@ class HomeViewModel @Inject constructor( } }.launchIn(viewModelScopeWithExceptionHandler) } + + fun fetchRecentlyPlayedMusics() { + playlistRepository.getRecentPlaylist() + .onEach { musics -> + _uiState.update { + it.copy(recentlyPlayedMusics = musics) + } + }.launchIn(viewModelScopeWithExceptionHandler) + } + + fun playRecentlyPlayedMusic() { + currentPlaylistUseCase.playMusics(uiState.value.recentlyPlayedMusics.first(), uiState.value.recentlyPlayedMusics) + viewModelScope.launch { + _events.emit(HomeEvent.NavigateToPlayerScreen) + } + } + + override fun onClick(music: Music) { + currentPlaylistUseCase.playMusics(music, uiState.value.recentlyUploadedMusics) + viewModelScope.launch { + _events.emit(HomeEvent.NavigateToPlayerScreen) + } + } } sealed interface HomeEvent { data class ShowMessage(val error: CtErrorType) : HomeEvent + data object NavigateToPlayerScreen : HomeEvent } \ No newline at end of file diff --git a/android/feature/home/src/main/res/layout/fragment_home.xml b/android/feature/home/src/main/res/layout/fragment_home.xml index de02019..a0ca57b 100644 --- a/android/feature/home/src/main/res/layout/fragment_home.xml +++ b/android/feature/home/src/main/res/layout/fragment_home.xml @@ -57,6 +57,9 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginTop="@dimen/small" + android:clickable="@{viewModel.uiState.firstRecentlyPlayedMusicImageUrl == null ? false : true}" + android:onClick="@{() -> viewModel.playRecentlyPlayedMusic()}" + app:imgUrl="@{viewModel.uiState.firstRecentlyPlayedMusicImageUrl}" app:layout_constraintDimensionRatio="1:1" app:layout_constraintEnd_toEndOf="@id/tv_recently_played_song" app:layout_constraintStart_toStartOf="@id/tv_recently_played_song" diff --git a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt index 7a724a6..a372d34 100644 --- a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt +++ b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt @@ -12,7 +12,7 @@ import androidx.navigation.fragment.findNavController import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.ohdodok.catchytape.core.ui.BaseFragment -import com.ohdodok.catchytape.core.ui.toMessageId +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import com.ohdodok.catchytape.feature.login.databinding.FragmentLoginBinding import dagger.hilt.android.AndroidEntryPoint diff --git a/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyMusicsFragment.kt b/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyMusicsFragment.kt index 9588630..9efafca 100644 --- a/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyMusicsFragment.kt +++ b/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyMusicsFragment.kt @@ -2,11 +2,13 @@ package com.ohdodok.catchytape.feature.mypage import android.os.Bundle import android.view.View +import androidx.core.view.ViewCompat import androidx.fragment.app.viewModels import com.ohdodok.catchytape.core.ui.BaseFragment import com.ohdodok.catchytape.core.ui.MusicAdapter import com.ohdodok.catchytape.core.ui.Orientation -import com.ohdodok.catchytape.core.ui.toMessageId +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import com.ohdodok.catchytape.feature.mypage.databinding.FragmentMyMusicsBinding import dagger.hilt.android.AndroidEntryPoint @@ -16,6 +18,8 @@ class MyMusicsFragment : BaseFragment(R.layout.fragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) binding.viewModel = viewModel setupRecyclerView() diff --git a/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyPageFragment.kt b/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyPageFragment.kt index 89803b7..ebb40c7 100644 --- a/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyPageFragment.kt +++ b/android/feature/mypage/src/main/java/com/ohdodok/catchytape/feature/mypage/MyPageFragment.kt @@ -2,12 +2,14 @@ package com.ohdodok.catchytape.feature.mypage import android.os.Bundle import android.view.View +import androidx.core.view.ViewCompat import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.ohdodok.catchytape.core.ui.BaseFragment import com.ohdodok.catchytape.core.ui.MusicAdapter import com.ohdodok.catchytape.core.ui.Orientation -import com.ohdodok.catchytape.core.ui.toMessageId +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import com.ohdodok.catchytape.feature.mypage.databinding.FragmentMyPageBinding import dagger.hilt.android.AndroidEntryPoint @@ -18,6 +20,7 @@ class MyPageFragment : BaseFragment(R.layout.fragment_my_ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) binding.viewModel = viewModel observeEvents() diff --git a/android/feature/mypage/src/main/res/layout/fragment_my_musics.xml b/android/feature/mypage/src/main/res/layout/fragment_my_musics.xml index d041418..6a75642 100644 --- a/android/feature/mypage/src/main/res/layout/fragment_my_musics.xml +++ b/android/feature/mypage/src/main/res/layout/fragment_my_musics.xml @@ -16,8 +16,9 @@ Unit) { binding.ibPlay.setOnClickListener { onPlayButtonClick() } @@ -77,4 +74,11 @@ class PlayerControlView(context: Context, attrs: AttributeSet) : ConstraintLayou else AppCompatResources.getDrawable(context, drawable.ic_play) } + fun setOnPreviousButtonClick(onPreviousButtonClick: () -> Unit) { + binding.ibPrevious.setOnClickListener { onPreviousButtonClick() } + } + + fun setOnNextButtonClick(onNextButtonClick: () -> Unit) { + binding.ibNext.setOnClickListener { onNextButtonClick() } + } } \ No newline at end of file diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerEventListener.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerEventListener.kt index 9ad914c..8a4451c 100644 --- a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerEventListener.kt +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerEventListener.kt @@ -4,5 +4,5 @@ interface PlayerEventListener { fun onPlayingChanged(isPlaying: Boolean) - fun onMediaItemChanged(duration: Int) + fun onMediaItemChanged(index: Int, duration: Int) } \ No newline at end of file diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt index b29608a..af9d085 100644 --- a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt @@ -3,15 +3,16 @@ package com.ohdodok.catchytape.feature.player import android.os.Bundle import android.view.View import android.widget.SeekBar +import androidx.core.view.ViewCompat import androidx.fragment.app.activityViewModels -import androidx.media3.common.MediaItem import androidx.media3.exoplayer.ExoPlayer import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import com.ohdodok.catchytape.core.ui.BaseFragment +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import com.ohdodok.catchytape.feature.player.databinding.FragmentPlayerBinding import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber import javax.inject.Inject const val millisecondsPerSecond = 1000 @@ -25,11 +26,11 @@ class PlayerFragment : BaseFragment(R.layout.fragment_pla override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) binding.viewModel = viewModel setUpSeekBar() - // todo : 실제 데이터로 변경 - setMedia("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8") setupButtons() collectEvents() } @@ -50,15 +51,8 @@ class PlayerFragment : BaseFragment(R.layout.fragment_pla }) } - private fun setMedia(url: String) { - val mediaItem = MediaItem.fromUri(url) - - player.setMediaItem(mediaItem) - player.play() - } - private fun setupButtons() { - binding.btnPlay.setOnClickListener { + binding.ibPlay.setOnClickListener { if (viewModel.uiState.value.isPlaying) player.pause() else player.play() } @@ -66,17 +60,37 @@ class PlayerFragment : BaseFragment(R.layout.fragment_pla binding.ibDown.setOnClickListener { findNavController().popBackStack() } + + binding.ibNext.setOnClickListener { + player.moveNextMedia() + } + + binding.ibPrevious.setOnClickListener { + player.onPreviousBtnClick() + } + + binding.btnAddToPlaylist.setOnClickListener { + findNavController().showPlaylistBottomSheet() + } } private fun collectEvents() { repeatOnStarted { viewModel.events.collect { event -> when (event) { - is PlayerEvent.ShowError -> Timber.d(event.error.message ?: "") + is PlayerEvent.ShowError -> showMessage(event.error.toMessageId()) + is PlayerEvent.PlaylistChanged -> { /* MainActivity에서 처리 됨 */ } } } } } + + private fun NavController.showPlaylistBottomSheet() { + val musicId = viewModel.uiState.value.currentMusic?.id ?: return + + val action = PlayerFragmentDirections.actionPlayerFragmentToPlaylistBottomSheet(musicId = musicId) + this.navigate(action) + } } fun NavController.navigateToPlayer() { diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerListener.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerListener.kt index e186770..3ebf41a 100644 --- a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerListener.kt +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerListener.kt @@ -13,9 +13,14 @@ class PlayerListener( listener.onPlayingChanged(player.isPlaying) } - events.contains(Player.EVENT_TIMELINE_CHANGED) -> { + events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) -> { val durationMs = player.duration.toInt() - listener.onMediaItemChanged(durationMs / millisecondsPerSecond) + listener.onMediaItemChanged(player.currentMediaItemIndex, durationMs / millisecondsPerSecond) + } + + events.contains(Player.EVENT_TRACKS_CHANGED) -> { + val durationMs = player.duration.toInt() + listener.onMediaItemChanged(player.currentMediaItemIndex, durationMs / millisecondsPerSecond) } } } diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerUtil.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerUtil.kt new file mode 100644 index 0000000..3ff5710 --- /dev/null +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerUtil.kt @@ -0,0 +1,55 @@ +package com.ohdodok.catchytape.feature.player + +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.ExoPlayer +import com.ohdodok.catchytape.core.domain.model.Music + + +private const val POSITION_MOVE_HEAD_STANDARD = 0.05 + +fun ExoPlayer.moveNextMedia() { + seekToNextMediaItem() + if (isPlaying) { + play() + } +} + +fun ExoPlayer.movePreviousMedia() { + seekToPreviousMediaItem() + if (isPlaying) { + play() + } +} + +fun ExoPlayer.moveHeadMedia() { + seekTo(0) + play() +} + +fun ExoPlayer.onPreviousBtnClick() { + if (currentPosition.toFloat() / duration.toFloat() < POSITION_MOVE_HEAD_STANDARD) { + movePreviousMedia() + } else { + moveHeadMedia() + } +} + +fun getMediasWithMetaData(musics: List): List { + val mediaItemBuilder = MediaItem.Builder() + val mediaMetadataBuilder = MediaMetadata.Builder() + return musics.map { music -> + mediaItemBuilder + .setMediaId(music.id) + .setUri(music.musicUrl) + .setMediaMetadata( + mediaMetadataBuilder + .setArtist(music.artist) + .setTitle(music.title) + .setArtworkUri(music.imageUrl.toUri()) + .build() + ) + .build() + } +} \ No newline at end of file diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt index 9a336e1..56d9f2a 100644 --- a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt @@ -2,7 +2,15 @@ package com.ohdodok.catchytape.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.model.CtErrorType +import com.ohdodok.catchytape.core.domain.model.CtException +import com.ohdodok.catchytape.core.domain.model.CurrentPlaylist +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import com.ohdodok.catchytape.core.domain.usecase.player.CurrentPlaylistUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -14,29 +22,53 @@ import kotlinx.coroutines.launch import javax.inject.Inject data class PlayerState( + val currentMusic: Music? = null, val isPlaying: Boolean = false, val currentPositionSecond: Int = 0, val duration: Int = 0, -) + val isNextEnable: Boolean = false, + val isPreviousEnable: Boolean = false + +) { + val isPlayEnable: Boolean + get() = currentMusic != null +} sealed interface PlayerEvent { - data class ShowError(val error: Exception) : PlayerEvent + data class ShowError(val error: CtErrorType) : PlayerEvent + data class PlaylistChanged(val currentPlaylist: CurrentPlaylist) : PlayerEvent } @HiltViewModel class PlayerViewModel @Inject constructor( - + private val currentPlaylistUseCase: CurrentPlaylistUseCase, + private val musicRepository: MusicRepository, ) : ViewModel(), PlayerEventListener { + private val _currentPlaylist = MutableStateFlow?>(null) + val currentPlaylist: StateFlow?> = _currentPlaylist.asStateFlow() + private val _uiState = MutableStateFlow(PlayerState()) val uiState: StateFlow = _uiState.asStateFlow() private val _events = MutableSharedFlow() val events: SharedFlow = _events.asSharedFlow() - private fun sendEvent(event: PlayerEvent) { - viewModelScope.launch { - _events.emit(event) + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + val errorType = if (throwable is CtException) throwable.ctError else CtErrorType.UN_KNOWN + viewModelScope.launch { _events.emit(PlayerEvent.ShowError(errorType)) } + } + + init { + observePlaylistChange() + } + + private fun observePlaylistChange() { + viewModelScope.launch(exceptionHandler) { + currentPlaylistUseCase.currentPlaylist.consumeEach { + _currentPlaylist.value = it.musics + _events.emit(PlayerEvent.PlaylistChanged(it)) + } } } @@ -52,7 +84,20 @@ class PlayerViewModel @Inject constructor( } } - override fun onMediaItemChanged(duration: Int) { - _uiState.update { it.copy(duration = duration) } + override fun onMediaItemChanged(index: Int, duration: Int) { + currentPlaylist.value?.let { playlist -> + _uiState.update { + it.copy( + duration = duration, + currentMusic = playlist[index], + currentPositionSecond = 0, + isNextEnable = playlist.lastIndex != index, + isPreviousEnable = index != 0 + ) + } + viewModelScope.launch { + musicRepository.updateRecentPlayedMusic(playlist[index].id) + } + } } } \ No newline at end of file diff --git a/android/feature/player/src/main/res/layout/fragment_player.xml b/android/feature/player/src/main/res/layout/fragment_player.xml index 8011f41..a42842b 100644 --- a/android/feature/player/src/main/res/layout/fragment_player.xml +++ b/android/feature/player/src/main/res/layout/fragment_player.xml @@ -42,6 +42,7 @@ android:layout_marginHorizontal="@dimen/margin_horizontal" android:layout_marginTop="@dimen/extra_large" android:background="@drawable/ic_view_radius_12" + app:imgUrl="@{viewModel.uiState.currentMusic.imageUrl}" app:layout_constraintDimensionRatio="1:1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -55,6 +56,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_horizontal" android:layout_marginTop="@dimen/medium" + android:text="@{viewModel.uiState.currentMusic.title}" android:textColor="@color/on_surface" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -67,6 +69,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_horizontal" + android:text="@{viewModel.uiState.currentMusic.artist}" android:textColor="@color/on_surface_variant" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -133,38 +136,41 @@ app:seconds="@{viewModel.uiState.duration}" tools:text="2:04" /> - - - - + + + android:background="@android:color/transparent" + android:enabled="@{viewModel.uiState.isNextEnable}" + android:src="@drawable/ic_next" + app:layout_constraintBottom_toBottomOf="@id/ib_play" + app:layout_constraintStart_toEndOf="@id/ib_play" + app:layout_constraintTop_toTopOf="@id/ib_play" /> + \ No newline at end of file diff --git a/android/feature/player/src/main/res/layout/view_player_control.xml b/android/feature/player/src/main/res/layout/view_player_control.xml index 64b5bb3..aa6bd4e 100644 --- a/android/feature/player/src/main/res/layout/view_player_control.xml +++ b/android/feature/player/src/main/res/layout/view_player_control.xml @@ -1,9 +1,9 @@ - + - @@ -19,16 +19,27 @@ app:layout_constraintTop_toTopOf="parent" tools:background="@color/on_surface_variant" /> + + + + + + diff --git a/android/feature/player/src/main/res/navigation/player_navigation.xml b/android/feature/player/src/main/res/navigation/player_navigation.xml index fa9cca1..140f260 100644 --- a/android/feature/player/src/main/res/navigation/player_navigation.xml +++ b/android/feature/player/src/main/res/navigation/player_navigation.xml @@ -12,8 +12,21 @@ tools:layout="@layout/fragment_player"> - + + + + + + \ No newline at end of file diff --git a/android/feature/player/src/main/res/values/strings.xml b/android/feature/player/src/main/res/values/strings.xml index 02d0582..b9df00e 100644 --- a/android/feature/player/src/main/res/values/strings.xml +++ b/android/feature/player/src/main/res/values/strings.xml @@ -1,5 +1,9 @@ %d:%02d - 재생하기 + + 이전 곡 + 다음 곡 + + 곡 목록이 없습니다. \ No newline at end of file diff --git a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistDetailFragment.kt b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistDetailFragment.kt new file mode 100644 index 0000000..3a76ce9 --- /dev/null +++ b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistDetailFragment.kt @@ -0,0 +1,57 @@ +package com.ohdodok.catchytape.feature.playlist + +import android.os.Bundle +import android.view.View +import androidx.core.net.toUri +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.NavDeepLinkRequest +import androidx.navigation.fragment.findNavController +import com.ohdodok.catchytape.core.ui.BaseFragment +import com.ohdodok.catchytape.core.ui.MusicAdapter +import com.ohdodok.catchytape.core.ui.Orientation +import com.ohdodok.catchytape.core.ui.cterror.toMessageId +import com.ohdodok.catchytape.feature.playlist.databinding.FragmentPlaylistDetailBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class PlaylistDetailFragment : + BaseFragment(R.layout.fragment_playlist_detail) { + + private val viewModel: PlaylistDetailViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.viewModel = viewModel + setupBackStack(binding.tbPlaylistDetail) + + setupPlaylist() + collectEvents() + } + + private fun setupPlaylist() { + binding.rvPlaylist.adapter = MusicAdapter( + musicItemOrientation = Orientation.VERTICAL, + listener = viewModel, + ) + } + + private fun collectEvents() { + repeatOnStarted { + viewModel.events.collect { event -> + when (event) { + is PlaylistDetailEvent.ShowMessage -> showMessage(event.error.toMessageId()) + is PlaylistDetailEvent.NavigateToPlayerScreen -> findNavController().navigateToPlayerScreen() + } + } + } + } +} + +private fun NavController.navigateToPlayerScreen() { + val targetUri = "android-app://com.ohdodok.catchytape/player_fragment".toUri() + val request = NavDeepLinkRequest.Builder.fromUri(targetUri).build() + + this.navigate(request) +} \ No newline at end of file diff --git a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistDetailViewModel.kt b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistDetailViewModel.kt new file mode 100644 index 0000000..fcd68c1 --- /dev/null +++ b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistDetailViewModel.kt @@ -0,0 +1,90 @@ +package com.ohdodok.catchytape.feature.playlist + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.model.CtErrorType +import com.ohdodok.catchytape.core.domain.model.CtException +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.domain.usecase.player.CurrentPlaylistUseCase +import com.ohdodok.catchytape.core.domain.usecase.playlist.GetPlaylistUseCase +import com.ohdodok.catchytape.core.ui.MusicAdapter +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import javax.inject.Inject + +data class PlaylistDetailUiState( + val musics: List = emptyList() +) + +sealed interface PlaylistDetailEvent { + data class ShowMessage(val error: CtErrorType) : PlaylistDetailEvent + data object NavigateToPlayerScreen : PlaylistDetailEvent +} + +@HiltViewModel +class PlaylistDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val currentPlaylistUseCase: CurrentPlaylistUseCase, + private val getPlaylistUseCase: GetPlaylistUseCase, +) : ViewModel(), MusicAdapter.Listener { + + val title: String = requireNotNull(savedStateHandle["title"]) { + "플레이리스트 제목이 반드시 전달 되어야 해요." + } + private val playlistId: Int = requireNotNull(savedStateHandle["playlistId"]) { + "playlistId가 반드시 전달 되어야 해요." + } + + private val _uiState = MutableStateFlow(PlaylistDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + val errorType = if (throwable is CtException) throwable.ctError else CtErrorType.UN_KNOWN + viewModelScope.launch { _events.emit(PlaylistDetailEvent.ShowMessage(errorType)) } + } + + private val viewModelScopeWithExceptionHandler = viewModelScope + exceptionHandler + + init { + fetchMusics() + } + + private fun fetchMusics() { + getPlaylistUseCase(playlistId).onEach { musics -> + _uiState.update { it.copy(musics = musics) } + }.launchIn(viewModelScopeWithExceptionHandler) + } + + fun playFromFirst() { + val musics = uiState.value.musics + if(musics.isEmpty()) return + + play(musics.first()) + } + + private fun play(music: Music) { + currentPlaylistUseCase.playMusics(music, uiState.value.musics) + viewModelScopeWithExceptionHandler.launch { + _events.emit(PlaylistDetailEvent.NavigateToPlayerScreen) + } + } + + override fun onClick(music: Music) { + play(music) + } +} \ No newline at end of file diff --git a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsFragment.kt b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsFragment.kt index 65c2c41..745c76d 100644 --- a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsFragment.kt +++ b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsFragment.kt @@ -2,10 +2,15 @@ package com.ohdodok.catchytape.feature.playlist import android.os.Bundle import android.view.View +import androidx.core.view.ViewCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController import com.ohdodok.catchytape.core.ui.BaseFragment -import com.ohdodok.catchytape.core.ui.toMessageId +import com.ohdodok.catchytape.core.ui.PlaylistAdapter +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import com.ohdodok.catchytape.feature.playlist.databinding.FragmentPlaylistsBinding import dagger.hilt.android.AndroidEntryPoint @@ -17,18 +22,19 @@ class PlaylistsFragment : BaseFragment(R.layout.fragme override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) binding.viewModel = viewModel binding.rvPlaylist.adapter = PlaylistAdapter() viewModel.fetchPlaylists() observeEvents() + val newPlaylistDialog = NewPlaylistDialog() binding.fabNewPlaylist.setOnClickListener { - NewPlaylistDialog().show(childFragmentManager, NewPlaylistDialog.TAG) + newPlaylistDialog.show(childFragmentManager, NewPlaylistDialog.TAG) } } - private fun observeEvents() { repeatOnStarted { viewModel.events.collect { event -> @@ -36,6 +42,13 @@ class PlaylistsFragment : BaseFragment(R.layout.fragme is PlaylistsEvent.ShowMessage -> { showMessage(event.error.toMessageId()) } + + is PlaylistsEvent.NavigateToPlaylistDetail -> { + findNavController().navigateToPlaylistDetail( + event.playlist.id, + event.playlist.title, + ) + } } } } @@ -44,4 +57,14 @@ class PlaylistsFragment : BaseFragment(R.layout.fragme override fun onPositiveButtonClicked(dialog: DialogFragment, title: String) { viewModel.createPlaylist(title) } + +} + +private fun NavController.navigateToPlaylistDetail(playlistId: Int, title: String) { + navigate( + PlaylistsFragmentDirections.actionPlaylistsFragmentToPlaylistDetailFragment( + playlistId = playlistId, + title = title, + ) + ) } \ No newline at end of file diff --git a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsViewModel.kt b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsViewModel.kt index 99f3b75..f667a82 100644 --- a/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsViewModel.kt +++ b/android/feature/playlist/src/main/java/com/ohdodok/catchytape/feature/playlist/PlaylistsViewModel.kt @@ -6,6 +6,8 @@ import com.ohdodok.catchytape.core.domain.model.CtErrorType import com.ohdodok.catchytape.core.domain.model.CtException import com.ohdodok.catchytape.core.domain.model.Playlist import com.ohdodok.catchytape.core.domain.repository.PlaylistRepository +import com.ohdodok.catchytape.core.ui.model.PlaylistUiModel +import com.ohdodok.catchytape.core.domain.usecase.playlist.GetPlaylistsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,11 +23,12 @@ import kotlinx.coroutines.plus import javax.inject.Inject data class PlaylistsUiState( - val playlists: List = emptyList() + val playlists: List = emptyList() ) @HiltViewModel class PlaylistViewModel @Inject constructor( + private val getPlaylistsUseCase: GetPlaylistsUseCase, private val playlistRepository: PlaylistRepository ) : ViewModel() { @@ -47,8 +50,20 @@ class PlaylistViewModel @Inject constructor( fun fetchPlaylists() { - playlistRepository.getPlaylists().onEach { playlists -> - _uiState.update { it.copy(playlists = playlists) } + getPlaylistsUseCase().onEach { playlists -> + _uiState.update { + it.copy( + playlists = playlists.map { playlist -> + PlaylistUiModel( + id = playlist.id, + title = playlist.title, + thumbnailUrl = playlist.thumbnailUrl, + trackSize = playlist.trackSize, + onClick = { onPlaylistClick(playlist) }, + ) + } + ) + } }.launchIn(viewModelScopeWithExceptionHandler) } @@ -57,9 +72,16 @@ class PlaylistViewModel @Inject constructor( playlistRepository.postPlaylist(playlistTitle) } } -} + private fun onPlaylistClick(playlist: Playlist) { + viewModelScope.launch { + _events.emit(PlaylistsEvent.NavigateToPlaylistDetail(playlist)) + } + } +} sealed interface PlaylistsEvent { + + data class NavigateToPlaylistDetail(val playlist: Playlist) : PlaylistsEvent data class ShowMessage(val error: CtErrorType) : PlaylistsEvent } diff --git a/android/feature/playlist/src/main/res/layout/fragment_playlist_detail.xml b/android/feature/playlist/src/main/res/layout/fragment_playlist_detail.xml new file mode 100644 index 0000000..4fc8430 --- /dev/null +++ b/android/feature/playlist/src/main/res/layout/fragment_playlist_detail.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/feature/playlist/src/main/res/navigation/playlist_navigation.xml b/android/feature/playlist/src/main/res/navigation/playlist_navigation.xml index 0b0f0f5..eebc713 100644 --- a/android/feature/playlist/src/main/res/navigation/playlist_navigation.xml +++ b/android/feature/playlist/src/main/res/navigation/playlist_navigation.xml @@ -3,12 +3,34 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/playlist_nav_graph" - app:startDestination="@id/playlist_fragment"> + app:startDestination="@id/playlists_fragment"> + android:label="playlists" + tools:layout="@layout/fragment_playlists"> + + + + + + + + + + + + \ No newline at end of file diff --git a/android/feature/playlist/src/main/res/values/strings.xml b/android/feature/playlist/src/main/res/values/strings.xml deleted file mode 100644 index 6fb9879..0000000 --- a/android/feature/playlist/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - 트랙 %d개 - \ No newline at end of file diff --git a/android/feature/search/.gitignore b/android/feature/search/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/feature/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/feature/search/build.gradle.kts b/android/feature/search/build.gradle.kts new file mode 100644 index 0000000..a93fd1e --- /dev/null +++ b/android/feature/search/build.gradle.kts @@ -0,0 +1,30 @@ +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + id("catchytape.android.feature") +} + +android { + namespace = "com.ohdodok.catchytape.feature.search" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + +} \ No newline at end of file diff --git a/android/feature/search/consumer-rules.pro b/android/feature/search/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/android/feature/search/proguard-rules.pro b/android/feature/search/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/android/feature/search/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/feature/search/src/main/AndroidManifest.xml b/android/feature/search/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/android/feature/search/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/feature/search/src/main/java/com/ohdodok/catchytape/feature/search/SearchFragment.kt b/android/feature/search/src/main/java/com/ohdodok/catchytape/feature/search/SearchFragment.kt new file mode 100644 index 0000000..204ccb7 --- /dev/null +++ b/android/feature/search/src/main/java/com/ohdodok/catchytape/feature/search/SearchFragment.kt @@ -0,0 +1,49 @@ +package com.ohdodok.catchytape.feature.search + +import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.fragment.app.viewModels +import com.ohdodok.catchytape.core.ui.BaseFragment +import com.ohdodok.catchytape.core.ui.MusicAdapter +import com.ohdodok.catchytape.core.ui.Orientation +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId +import com.ohdodok.catchytape.feature.search.databinding.FragmentSearchBinding +import dagger.hilt.android.AndroidEntryPoint + + +@AndroidEntryPoint +class SearchFragment : BaseFragment(R.layout.fragment_search) { + + private val viewModel: SearchViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) + + binding.viewModel = viewModel + + observeEvents() + setupRecyclerView() + + } + + private fun setupRecyclerView() { + binding.rvSearch.adapter = MusicAdapter(Orientation.VERTICAL) + } + + private fun observeEvents() { + repeatOnStarted { + viewModel.events.collect { event -> + when (event) { + is SearchEvent.ShowMessage -> { + showMessage(event.error.toMessageId()) + } + } + } + } + } + +} diff --git a/android/feature/search/src/main/java/com/ohdodok/catchytape/feature/search/SearchViewModel.kt b/android/feature/search/src/main/java/com/ohdodok/catchytape/feature/search/SearchViewModel.kt new file mode 100644 index 0000000..d210b29 --- /dev/null +++ b/android/feature/search/src/main/java/com/ohdodok/catchytape/feature/search/SearchViewModel.kt @@ -0,0 +1,79 @@ +package com.ohdodok.catchytape.feature.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.model.CtErrorType +import com.ohdodok.catchytape.core.domain.model.CtException +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import javax.inject.Inject + +data class SearchUiState( + val searchedMusics: List = emptyList() +) + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val musicRepository: MusicRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + private val _keyword = MutableStateFlow("") + val keyword: StateFlow = _keyword.asStateFlow() + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + val errorType = + if (throwable is CtException) throwable.ctError + else CtErrorType.UN_KNOWN + + viewModelScope.launch { _events.emit(SearchEvent.ShowMessage(errorType)) } + } + + private val viewModelScopeWithExceptionHandler = viewModelScope + exceptionHandler + + init { + observeKeyword() + } + + fun updateKeyword(newKeyword: String) { + _keyword.update { newKeyword } + } + + private fun observeKeyword() { + _keyword.filter { it.isNotBlank() } + .debounce(300) + .onEach { fetchSearchedMusics(it) } + .launchIn(viewModelScopeWithExceptionHandler) + } + + private fun fetchSearchedMusics(keyword: String) { + musicRepository.getSearchedMusics(keyword).onEach { musics -> + _uiState.update { it.copy(searchedMusics = musics) } + }.launchIn(viewModelScopeWithExceptionHandler) + } +} + + +sealed interface SearchEvent { + data class ShowMessage(val error: CtErrorType) : SearchEvent +} \ No newline at end of file diff --git a/android/feature/search/src/main/res/layout/fragment_search.xml b/android/feature/search/src/main/res/layout/fragment_search.xml new file mode 100644 index 0000000..65dc40f --- /dev/null +++ b/android/feature/search/src/main/res/layout/fragment_search.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/feature/search/src/main/res/navigation/search_navigation.xml b/android/feature/search/src/main/res/navigation/search_navigation.xml new file mode 100644 index 0000000..b035281 --- /dev/null +++ b/android/feature/search/src/main/res/navigation/search_navigation.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/android/feature/search/src/main/res/values/strings.xml b/android/feature/search/src/main/res/values/strings.xml new file mode 100644 index 0000000..3ea6048 --- /dev/null +++ b/android/feature/search/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 검색어를 입력하세요. + \ No newline at end of file diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt index cb8d139..f8d1654 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt @@ -7,6 +7,7 @@ import android.view.View import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.core.view.ViewCompat import androidx.databinding.BindingAdapter import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController @@ -14,7 +15,8 @@ import com.google.android.material.textfield.TextInputLayout import com.ohdodok.catchytape.catchytape.upload.R import com.ohdodok.catchytape.catchytape.upload.databinding.FragmentUploadBinding import com.ohdodok.catchytape.core.ui.BaseFragment -import com.ohdodok.catchytape.core.ui.toMessageId +import com.ohdodok.catchytape.core.ui.RootViewInsetsCallback +import com.ohdodok.catchytape.core.ui.cterror.toMessageId import dagger.hilt.android.AndroidEntryPoint import java.io.File import java.io.FileOutputStream @@ -37,6 +39,8 @@ class UploadFragment : BaseFragment(R.layout.fragment_upl override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, RootViewInsetsCallback()) binding.viewModel = viewModel observeEvents() diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt index 494791e..d6a1e4e 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt @@ -30,18 +30,16 @@ data class UploadUiState( val musicGenre: String = "", val imageState: UploadedFileState = UploadedFileState(), val audioState: UploadedFileState = UploadedFileState(), - val encoding: Boolean = false, val musicGenres: List = emptyList(), ) { val isLoading: Boolean - get() = imageState.isLoading || audioState.isLoading || encoding + get() = imageState.isLoading || audioState.isLoading val isUploadEnable: Boolean get() = musicTitleState.title.isNotBlank() && musicTitleState.isValid && musicGenre.isNotBlank() && imageState.url.isNotBlank() && audioState.url.isNotBlank() - && !encoding } data class UploadedFileState( @@ -139,12 +137,8 @@ class UploadViewModel @Inject constructor( audioUrl = uiState.value.audioState.url, title = uiState.value.musicTitleState.title, genre = uiState.value.musicGenre - ).onStart { - _uiState.update { it.copy(encoding = true) } - }.onEach { + ).onEach { _events.emit(UploadEvent.NavigateToBack) - }.onCompletion { - _uiState.update { it.copy(encoding = false) } }.launchIn(viewModelScopeWithExceptionHandler) } } diff --git a/android/feature/upload/src/main/res/layout/fragment_upload.xml b/android/feature/upload/src/main/res/layout/fragment_upload.xml index dc24130..e97df2a 100644 --- a/android/feature/upload/src/main/res/layout/fragment_upload.xml +++ b/android/feature/upload/src/main/res/layout/fragment_upload.xml @@ -113,7 +113,6 @@ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 7c22b98..b1b8a38 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -25,3 +25,4 @@ include(":feature:upload") include(":feature:player") include(":feature:playlist") include(":feature:mypage") +include(":feature:search") diff --git a/server/Dockerfile b/server/Dockerfile index 92b58b0..c4c38a5 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,7 @@ FROM node:20 +ENV TZ=Asia/Seoul + WORKDIR /catchy-tape COPY . . diff --git a/server/cloud-functions/Dockerfile b/server/cloud-functions/Dockerfile new file mode 100644 index 0000000..ec24523 --- /dev/null +++ b/server/cloud-functions/Dockerfile @@ -0,0 +1,10 @@ +FROM cloudfunctions.kr.ncr.ntruss.com/cloudfunctions-nodejs-16:latest + +RUN npm install fluent-ffmpeg +RUN npm install @ffmpeg-installer/ffmpeg +RUN npm install axios +RUN npm install aws-sdk + +WORKDIR /nodejsAction + +CMD node --expose-gc app.js \ No newline at end of file diff --git a/server/cloud-functions/app.js b/server/cloud-functions/app.js new file mode 100644 index 0000000..e5889cf --- /dev/null +++ b/server/cloud-functions/app.js @@ -0,0 +1,178 @@ +const AWS = require('aws-sdk'); +const ffmpeg = require('fluent-ffmpeg'); +const { path } = require('@ffmpeg-installer/ffmpeg'); +const fs = require('fs'); +const axios = require('axios'); +const Path = require('path'); + +function setObjectStorage(accessKey, secretKey) { + return new AWS.S3({ + endpoint: 'https://kr.object.ncloudstorage.com', + region: 'kr-standard', + credentials: { + accessKeyId: accessKey, + secretAccessKey: secretKey, + }, + signatureVersion: 'v4', + }); +} + +function separateMusicName(musicPath) { + const parsedPath = new URL(musicPath); + const pathNames = parsedPath.pathname.split('/'); + const musicName = pathNames[pathNames.length - 1]; + + return musicName; +} + +function getPath(option) { + return Path.resolve(`musics${option}`); +} + +function setEncodingPaths(musicPath) { + const musicName = separateMusicName(musicPath); + + return { + outputMusicPath: getPath('/output'), + entireMusicPath: getPath(''), + outputPath: getPath(`/output/${musicName.replace('.mp3', '')}.m3u8`), + tempFilePath: getPath(`/${musicName}`), + }; +} + +async function uploadToObjectStorage( + filePath, + musicId, + fileName, + objectStorage, +) { + const result = await objectStorage + .upload({ + Bucket: 'catchy-tape-bucket2', + Key: `music/${musicId}/${fileName}`, + Body: fs.createReadStream(filePath), + ACL: 'public-read', + }) + .promise(); + + return { url: result.Location }; +} + +async function uploadEncodedFile(filePath, musicId, fileName, objectStorage) { + try { + const { url } = await uploadToObjectStorage( + filePath, + musicId, + fileName, + objectStorage, + ); + return url; + } catch (err) { + console.log(err); + } +} + +async function saveMp3File(outputMusicPath, musicPath, tempFilePath) { + fs.mkdirSync(outputMusicPath, { recursive: true }); + + const musicFileResponse = await axios.get(musicPath, { + responseType: 'arraybuffer', + }); + + const musicBuffer = Buffer.from(musicFileResponse.data); + + fs.writeFile(tempFilePath, musicBuffer, (err) => { + if (err) throw new Error(); + }); +} + +async function encodeMusic(musicId, musicPath, objectStorage) { + try { + ffmpeg.setFfmpegPath(path); + + const { outputMusicPath, outputPath, tempFilePath } = + setEncodingPaths(musicPath); + + await saveMp3File(outputMusicPath, musicPath, tempFilePath); + + const encodedFileURL = await executeEncoding( + tempFilePath, + outputPath, + outputMusicPath, + musicId, + objectStorage, + ); + + return encodedFileURL; + } catch (err) { + console.log(err); + } +} + +async function executeEncoding( + tempFilePath, + outputPath, + outputMusicPath, + musicId, + objectStorage, +) { + let m3u8FileName = ''; + let m3u8Path = ''; + const watcher = fs.watch(outputMusicPath, async (eventType, fileName) => { + if (fileName.match(/.m3u8$/)) { + m3u8FileName = fileName; + } else if (!fileName.match(/\.tmp$/)) { + await uploadEncodedFile( + outputMusicPath + `/${fileName}`, + musicId, + fileName, + objectStorage, + ); + } + }); + + return await new Promise((resolve, reject) => { + ffmpeg(tempFilePath) + .addOption([ + '-map 0:a', + '-c:a aac', + '-b:a 192k', + '-hls_time 30', + '-hls_list_size 0', + '-f hls', + ]) + .output(outputPath) + .on('end', async () => { + watcher.close(); + m3u8Path = await uploadEncodedFile( + outputMusicPath + `/${m3u8FileName}`, + musicId, + m3u8FileName, + objectStorage, + ); + resolve(m3u8Path); + }) + .on('error', () => { + reject(new Error()); + }) + .run(); + }); +} + +async function main(params) { + const bucket = params.container_name; + const music_id = params.music_id; + + const musicPath = `https://kr.object.ncloudstorage.com/${bucket}/music/${music_id}/music.mp3`; + const objectStorage = setObjectStorage(params.accessKey, params.secretKey); + + const url = await encodeMusic(music_id, musicPath, objectStorage); + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: { + url, + }, + }; +} diff --git a/server/package-lock.json b/server/package-lock.json index 3bd2b28..c64ef25 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -24,20 +24,25 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cross-env": "^7.0.3", + "crypto-js": "^4.2.0", "fluent-ffmpeg": "^2.1.2", "mysql2": "^3.6.3", + "nest-winston": "^1.9.4", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "typeorm": "^0.3.17", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/crypto-js": "^4.2.1", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", @@ -885,6 +890,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2135,6 +2150,12 @@ "integrity": "sha512-b698BLJ6kPVd6uhHsY7wlebZdrWPXYied883PDSzpJZYOP97EOn/oGdLCH3jJf157srkFReIZY5v0H1s8Dozrg==", "dev": true }, + "node_modules/@types/crypto-js": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.1.tgz", + "integrity": "sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.44.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.7.tgz", @@ -2366,6 +2387,11 @@ "@types/superagent": "*" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, "node_modules/@types/uuid": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", @@ -3746,6 +3772,15 @@ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "dev": true }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3762,6 +3797,37 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3973,6 +4039,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -4375,6 +4446,11 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -4919,6 +4995,11 @@ "bser": "2.1.1" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -4955,6 +5036,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -5091,6 +5180,11 @@ "which": "bin/which" } }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "node_modules/follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -5874,7 +5968,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "engines": { "node": ">=8" }, @@ -6810,6 +6903,11 @@ "node": ">=6" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6935,6 +7033,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/logform": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", + "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -7143,6 +7265,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7257,6 +7387,18 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nest-winston": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/nest-winston/-/nest-winston-1.9.4.tgz", + "integrity": "sha512-ilEmHuuYSAI6aMNR120fLBl42EdY13QI9WRggHdEizt9M7qZlmXJwpbemVWKW/tqRmULjSx/otKNQ3GMQbfoUQ==", + "dependencies": { + "fast-safe-stringify": "^2.1.1" + }, + "peerDependencies": { + "@nestjs/common": "^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "winston": "^3.0.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -7337,6 +7479,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -7364,6 +7514,14 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -8316,6 +8474,14 @@ } ] }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8586,6 +8752,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -8643,6 +8822,14 @@ "node": ">= 0.6" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9012,6 +9199,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9116,6 +9308,14 @@ "tree-kill": "cli.js" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -9923,6 +10123,91 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/winston": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.11.0.tgz", + "integrity": "sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.4.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-4.7.1.tgz", + "integrity": "sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^2.0.1", + "triple-beam": "^1.3.0", + "winston-transport": "^4.4.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.6.0.tgz", + "integrity": "sha512-wbBA9PbPAHxKiygo7ub7BYRiKxms0tpfU2ljtWzb3SjRjv5yl6Ozuy/TkXf00HTAt+Uylo3gSkNwzc4ME0wiIg==", + "dependencies": { + "logform": "^2.3.2", + "readable-stream": "^3.6.0", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/server/package.json b/server/package.json index 0970803..fd12c00 100644 --- a/server/package.json +++ b/server/package.json @@ -35,20 +35,25 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cross-env": "^7.0.3", + "crypto-js": "^4.2.0", "fluent-ffmpeg": "^2.1.2", "mysql2": "^3.6.3", + "nest-winston": "^1.9.4", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "typeorm": "^0.3.17", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/crypto-js": "^4.2.1", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", @@ -88,7 +93,8 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "^src/(.*)": "/$1" + "^src/(.*)": "/$1", + "^test/(.*)": "/../test/$1" } } } diff --git a/server/src/app.controller.spec.ts b/server/src/app.controller.spec.ts index d22f389..1a80a78 100644 --- a/server/src/app.controller.spec.ts +++ b/server/src/app.controller.spec.ts @@ -16,7 +16,7 @@ describe('AppController', () => { describe('root', () => { it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + expect(appController.getHello()).toBe('Catchy Tape!'); }); }); }); diff --git a/server/src/app.module.ts b/server/src/app.module.ts index d6b78c8..737d876 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -7,7 +7,6 @@ import { TypeOrmConfigService } from 'src/config/typeorm.config'; import { ConfigModule } from '@nestjs/config'; import { UploadModule } from './upload/upload.module'; import { MusicModule } from './music/music.module'; -import { PlaylistController } from './playlist/playlist.controller'; import { PlaylistModule } from './playlist/playlist.module'; @Module({ diff --git a/server/src/app.service.ts b/server/src/app.service.ts index 927d7cc..6af0e16 100644 --- a/server/src/app.service.ts +++ b/server/src/app.service.ts @@ -3,6 +3,6 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { - return 'Hello World!'; + return 'Catchy Tape!'; } } diff --git a/server/src/auth/auth.controller.spec.ts b/server/src/auth/auth.controller.spec.ts index 0c0504e..af190b8 100644 --- a/server/src/auth/auth.controller.spec.ts +++ b/server/src/auth/auth.controller.spec.ts @@ -5,10 +5,11 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { User } from 'src/entity/user.entity'; import { Repository } from 'typeorm'; import { JwtModule, JwtService } from '@nestjs/jwt'; -import { PlaylistService } from 'src/playlist/playlist.service'; import { Playlist } from 'src/entity/playlist.entity'; import { Music } from 'src/entity/music.entity'; import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { Logger } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; describe('AuthController', () => { let controller: AuthController; @@ -18,9 +19,10 @@ describe('AuthController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [JwtModule], + imports: [PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule], controllers: [AuthController], providers: [ + Logger, AuthService, { provide: getRepositoryToken(User), @@ -38,7 +40,6 @@ describe('AuthController', () => { provide: getRepositoryToken(Music_Playlist), useClass: Repository, }, - PlaylistService, ], }).compile(); diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index a8a3519..d926eb3 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + Logger, Post, Req, UseGuards, @@ -18,13 +19,17 @@ import { User } from 'src/entity/user.entity'; @Controller('users') export class AuthController { - constructor(private authService: AuthService) {} + private readonly logger = new Logger('Auth'); + constructor( + private authService: AuthService, + ) {} @Post('login') @HttpCode(HTTP_STATUS_CODE.SUCCESS) async login( @Body('idToken') googleIdToken: string, ): Promise<{ accessToken: string }> { + this.logger.log(`POST /users/login - idToken=${googleIdToken}`); const email: string = await this.authService.getGoogleEmail(googleIdToken); return await this.authService.login(email); } @@ -35,6 +40,7 @@ export class AuthController { async signup( @Body() userCreateDto: UserCreateDto, ): Promise<{ accessToken: string }> { + this.logger.log(`POST /users/signup - body=${userCreateDto}`); return this.authService.signup(userCreateDto); } @@ -43,14 +49,18 @@ export class AuthController { @HttpCode(HTTP_STATUS_CODE.SUCCESS) verifyToken(@Req() req): { userId: string } { const user: User = req.user; + this.logger.log( + `GET /users/verify - nickname=${user.nickname} : response - userId=${user.user_id}`, + ); return { userId: user.user_id }; } @Delete() @UseGuards(AuthGuard()) @HttpCode(HTTP_STATUS_CODE.SUCCESS) - async deleteUser(@Req() req): Promise<{userId: string}> { + async deleteUser(@Req() req): Promise<{ userId: string }> { const user: User = req.user; + this.logger.log(`DELETE /users - nickname=${user.nickname}`); return await this.authService.deleteUser(user); } } diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index 4095a88..2df4e86 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -7,10 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/entity/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; -import { PlaylistService } from 'src/playlist/playlist.service'; -import { Playlist } from 'src/entity/playlist.entity'; -import { Music } from 'src/entity/music.entity'; -import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { Logger } from 'winston'; @Module({ imports: [ @@ -23,9 +20,9 @@ import { Music_Playlist } from 'src/entity/music_playlist.entity'; }), inject: [ConfigService], }), - TypeOrmModule.forFeature([User, Playlist, Music, Music_Playlist]), + TypeOrmModule.forFeature([User]), ], - providers: [JwtStrategy, AuthService, PlaylistService], + providers: [JwtStrategy, AuthService, Logger], exports: [JwtStrategy, PassportModule], controllers: [AuthController], }) diff --git a/server/src/auth/auth.service.spec.ts b/server/src/auth/auth.service.spec.ts index 26cc20e..6c63367 100644 --- a/server/src/auth/auth.service.spec.ts +++ b/server/src/auth/auth.service.spec.ts @@ -8,6 +8,7 @@ import { PlaylistService } from 'src/playlist/playlist.service'; import { Playlist } from 'src/entity/playlist.entity'; import { Music } from 'src/entity/music.entity'; import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { PassportModule } from '@nestjs/passport'; describe('AuthService', () => { let service: AuthService; @@ -17,7 +18,7 @@ describe('AuthService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [JwtModule], + imports: [PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule], providers: [ AuthService, { diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 596f7de..c2ee415 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,22 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { CatchyException } from 'src/config/catchyException'; import { ERROR_CODE } from 'src/config/errorCode.enum'; -import { RECENT_PLAYLIST_NAME } from 'src/constants'; import { UserCreateDto } from 'src/dto/userCreate.dto'; import { User } from 'src/entity/user.entity'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; -import { PlaylistService } from 'src/playlist/playlist.service'; import { Repository } from 'typeorm'; import { v4 as uuid } from 'uuid'; @Injectable() export class AuthService { + private readonly logger = new Logger('AuthService'); constructor( @InjectRepository(User) private userRepository: Repository, private jwtService: JwtService, - private readonly playlistService: PlaylistService, ) {} async login(email: string): Promise<{ accessToken: string }> { @@ -30,6 +28,7 @@ export class AuthService { return { accessToken }; } else { + this.logger.error(`auth.service - login : NOT_EXIST_USER`); throw new CatchyException( 'NOT_EXIST_USER', HTTP_STATUS_CODE['WRONG_TOKEN'], @@ -43,6 +42,7 @@ export class AuthService { const email: string = await this.getGoogleEmail(idToken); if (await this.isExistEmail(email)) { + this.logger.error(`auth.service - signup : ALREADY_EXIST_EMAIL`); throw new CatchyException( 'ALREADY_EXIST_EMAIL', HTTP_STATUS_CODE.BAD_REQUEST, @@ -59,11 +59,9 @@ export class AuthService { }); await this.userRepository.save(newUser); - this.playlistService.createPlaylist(newUser.user_id, { - title: RECENT_PLAYLIST_NAME, - }); return this.login(email); } + this.logger.error(`auth.service - signup : WRONG_TOKEN`); throw new CatchyException( 'WRONG_TOKEN', HTTP_STATUS_CODE.WRONG_TOKEN, @@ -79,6 +77,7 @@ export class AuthService { }).then((res) => res.json()); if (!userInfo.email) { + this.logger.log(`auth.service - getGoogleEmail : EXPIRED_TOKEN`); throw new CatchyException( 'EXPIRED_TOKEN', HTTP_STATUS_CODE.WRONG_TOKEN, diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index ff3191c..013a435 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Logger, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { + private readonly logger = new Logger('JwtLogger'); constructor( @InjectRepository(User) private userRepository: Repository, private configService: ConfigService, @@ -29,6 +30,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); if (!user || !user_id) { + this.logger.error(`request user_id=${user_id} : NOT_EXIST_USER`); throw new CatchyException( 'NOT_EXIST_USER', HTTP_STATUS_CODE['WRONG_TOKEN'], diff --git a/server/src/config/errorCode.enum.ts b/server/src/config/errorCode.enum.ts index f779c42..9255723 100644 --- a/server/src/config/errorCode.enum.ts +++ b/server/src/config/errorCode.enum.ts @@ -5,6 +5,7 @@ export enum ERROR_CODE { 'SERVICE_ERROR' = 5001, 'MUSIC_ENCODE_ERROR' = 5002, 'ENCODED_MUSIC_UPLOAD_ERROR' = 5003, + 'QUERY_ERROR' = 5004, 'NOT_EXIST_PLAYLIST_ON_USER' = 4001, 'NOT_EXIST_MUSIC' = 4002, 'ALREADY_ADDED' = 4003, @@ -15,6 +16,9 @@ export enum ERROR_CODE { 'INVALID_INPUT_TYPE_VALUE' = 4008, 'NOT_EXIST_MUSIC_ID' = 4009, 'NOT_EXIST_TS_IN_BUCKET' = 4010, + 'INVALID_GREEN_EYE_REQUEST' = 4011, + 'FAIL_GREEN_EYE_IMAGE_RECOGNITION' = 4012, + 'BAD_IMAGE' = 4013, 'WRONG_TOKEN' = 4100, 'EXPIRED_TOKEN' = 4101, } diff --git a/server/src/config/greenEye.service.ts b/server/src/config/greenEye.service.ts new file mode 100644 index 0000000..aff7375 --- /dev/null +++ b/server/src/config/greenEye.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; +import { GreenEyeResponseDto } from 'src/dto/greenEye.response.dto'; +import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { Logger } from '@nestjs/common'; + +@Injectable() +export class GreenEyeService { + private readonly logger = new Logger('GreenEyeService'); + greenEyeSecretKey: string; + greenEyeRequestUrl: string; + + contentType: string = 'application/json'; + + constructor(private readonly configService: ConfigService) { + this.greenEyeSecretKey = configService.get('GREEN_EYE_SECRET_KEY'); + this.greenEyeRequestUrl = configService.get( + 'GREEN_EYE_REQUEST_URL', + ); + } + + private getTimeStamp(): number { + return new Date().getTime(); + } + + private getRequestInit(imageUrl: string): RequestInit { + return { + method: 'POST', + headers: { + 'X-GREEN-EYE-SECRET': this.greenEyeSecretKey, + 'Content-Type': this.contentType, + }, + body: JSON.stringify({ + version: 'V1', + requestId: 'requestId', + timestamp: this.getTimeStamp(), + images: [ + { + name: 'image', + url: imageUrl, + }, + ], + }), + }; + } + + async getResultOfNormalImage(imageUrl: string): Promise { + return await fetch(this.greenEyeRequestUrl, this.getRequestInit(imageUrl)) + .then((res) => { + if (res.ok) { + return res.json(); + } + + throw new Error(); + }) + .catch((err) => { + this.logger.error( + `greenEye.service - getResultOfNormalImage : INVALID_GREEN_EYE_REQUEST`, + ); + throw new CatchyException( + 'INVALID_GREEN_EYE_REQUEST', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.INVALID_GREEN_EYE_REQUEST, + ); + }); + } +} diff --git a/server/src/config/logger.config.ts b/server/src/config/logger.config.ts new file mode 100644 index 0000000..7a0df9b --- /dev/null +++ b/server/src/config/logger.config.ts @@ -0,0 +1,43 @@ +import { WinstonModule, utilities } from 'nest-winston'; +import * as winston from 'winston'; +import * as winstonDaily from 'winston-daily-rotate-file'; + +const colors = { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + silly: 'blue', +}; +winston.addColors(colors); + +const dailyOption = () => { + return { + datePattern: 'YYYY-MM-DD', + dirname: `logs/`, + filename: `%DATE%.log`, + maxFiles: 7, + zippedArchive: true, + format: winston.format.combine( + winston.format.timestamp(), + utilities.format.nestLike(process.env.NODE_ENV, { + colors: false, + prettyPrint: true, + }), + ), + }; +}; + +export const winstonLogger = WinstonModule.createLogger({ + transports: [ + new winston.transports.Console({ + level: process.env.NODE_ENV === 'prod' ? 'info' : 'silly', + format: winston.format.combine( + winston.format.colorize({ all: true }), + winston.format.timestamp(), + utilities.format.nestLike('CatchyTape', { prettyPrint: true }), + ), + }), + new winstonDaily(dailyOption()), + ], +}); diff --git a/server/src/config/ncloud.config.ts b/server/src/config/ncloud.config.ts index 47cf405..2b69b98 100644 --- a/server/src/config/ncloud.config.ts +++ b/server/src/config/ncloud.config.ts @@ -1,20 +1,70 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as AWS from 'aws-sdk'; +import { HmacSHA256, enc } from 'crypto-js'; +import { getTimeStamp } from 'src/constants'; @Injectable() export class NcloudConfigService { - constructor(private readonly configService: ConfigService) {} + private readonly accessKey: string; + private readonly secretKey: string; + private readonly gatewayKey: string; + private readonly executeActionsUrl: string; + private readonly requestActionsUrl: string; - createObjectStorageOption() { + constructor(private readonly configService: ConfigService) { + this.accessKey = this.configService.get('ACCESS_ID'); + this.secretKey = this.configService.get('SECRET_ACCESS_KEY'); + this.gatewayKey = this.configService.get('API_GW_ACCESS_KEY'); + this.executeActionsUrl = this.configService.get( + 'CLOUD_FUNCTIONS_EXECUTE_URL', + ); + this.requestActionsUrl = this.configService.get( + 'CLOUD_FUNCTIONS_REQUEST_URL', + ); + } + + createObjectStorageOption(): AWS.S3 { return new AWS.S3({ endpoint: 'https://kr.object.ncloudstorage.com', region: 'kr-standard', credentials: { - accessKeyId: this.configService.get('ACCESS_ID'), - secretAccessKey: this.configService.get('SECRET_ACCESS_KEY'), + accessKeyId: this.accessKey, + secretAccessKey: this.secretKey, }, signatureVersion: 'v4', }); } + + makeSignature(curTimeStamp: number): string { + const method = 'POST'; + const blank = ' '; + const url = this.requestActionsUrl; + const line = '\n'; + const timestamp = `${curTimeStamp}`; + const accessKey = this.accessKey; + + const message = `${method}${blank}${url}${line}${timestamp}${line}${accessKey}`; + + const hmac = HmacSHA256(message, this.secretKey); + + return enc.Base64.stringify(hmac); + } + + getExecuteActionsUrl(): string { + return this.executeActionsUrl; + } + + getRequestActionUrlHeaders(): { headers: Record } { + const curTimeStamp = getTimeStamp(); + return { + headers: { + 'Content-Type': 'application/json', + 'x-ncp-iam-access-key': this.accessKey, + 'x-ncp-apigw-timestamp': `${curTimeStamp}`, + 'x-ncp-apigw-api-key': this.gatewayKey, + 'x-ncp-apigw-signature-v2': this.makeSignature(curTimeStamp), + }, + }; + } } diff --git a/server/src/constants.ts b/server/src/constants.ts index c14d409..a187e17 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -14,10 +14,13 @@ export enum Genres { 'etc' = 'etc', } -export const RECENT_PLAYLIST_NAME = '최근 재생 목록'; export const keyFlags = ['user', 'cover']; export const contentTypeHandler: Record = { image: 'image/png', music: 'audio/mpeg', }; + +export const getTimeStamp = (): number => { + return new Date().getTime(); +}; diff --git a/server/src/dto/cloudFunctions.response.dto.ts b/server/src/dto/cloudFunctions.response.dto.ts new file mode 100644 index 0000000..a3e597a --- /dev/null +++ b/server/src/dto/cloudFunctions.response.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty } from 'class-validator'; + +export class CloudFunctionsResponseDto { + body: { + url: string; + }; + + @IsNotEmpty() + headers: { + 'Content-Type': string; + }; + + @IsNotEmpty() + statusCode: number; +} diff --git a/server/src/dto/greenEye.response.dto.ts b/server/src/dto/greenEye.response.dto.ts new file mode 100644 index 0000000..0f8ca21 --- /dev/null +++ b/server/src/dto/greenEye.response.dto.ts @@ -0,0 +1,39 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GreenEyeResponseDto { + @IsString() + @IsNotEmpty() + version: string; + + @IsString() + @IsNotEmpty() + requestId: string; + + @IsString() + @IsNotEmpty() + timestamp: number; + + @IsNotEmpty() + images: [ + { + result: { + adult: { + confidence: number; + }; + normal: { + confidence: number; + }; + porn: { + confidence: number; + }; + sexy: { + confidence: number; + }; + }; + latency: number; + confidence: number; + message: string; + name: string; + }, + ]; +} diff --git a/server/src/entity/music.entity.ts b/server/src/entity/music.entity.ts index c6e3cb2..ecb07dc 100644 --- a/server/src/entity/music.entity.ts +++ b/server/src/entity/music.entity.ts @@ -4,14 +4,16 @@ import { CreateDateColumn, BaseEntity, JoinColumn, - PrimaryGeneratedColumn, ManyToOne, OneToMany, PrimaryColumn, + ILike, + Index, } from 'typeorm'; import { User } from './user.entity'; import { Genres } from 'src/constants'; import { Music_Playlist } from './music_playlist.entity'; +import { Recent_Played } from './recent_played.entity'; @Entity({ name: 'music' }) export class Music extends BaseEntity { @@ -34,6 +36,7 @@ export class Music extends BaseEntity { genre: Genres; @CreateDateColumn() + @Index() created_at: Date; @ManyToOne(() => User, (user) => user.musics) @@ -43,6 +46,9 @@ export class Music extends BaseEntity { @OneToMany(() => Music_Playlist, (music_playlist) => music_playlist.music) music_playlist: Music_Playlist[]; + @OneToMany(() => Recent_Played, (recent_played) => recent_played.music) + recent_played: Recent_Played[]; + static async getMusicListByUserId( userId: string, count: number, @@ -103,4 +109,31 @@ export class Music extends BaseEntity { where: { music_id }, }); } + + static async getCertainMusicByTitle(keyword: string): Promise { + return await this.find({ + relations: { + user: true, + music_playlist: false, + }, + select: { + music_id: true, + lyrics: true, + title: true, + cover: true, + music_file: true, + genre: true, + user: { + user_id: true, + nickname: true, + }, + }, + where: { + title: ILike(`%${keyword}%`), + }, + order: { + created_at: 'DESC', + }, + }); + } } diff --git a/server/src/entity/music_playlist.entity.ts b/server/src/entity/music_playlist.entity.ts index fd471ed..90e6a7c 100644 --- a/server/src/entity/music_playlist.entity.ts +++ b/server/src/entity/music_playlist.entity.ts @@ -1,6 +1,8 @@ import { BaseEntity, + Column, Entity, + Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -19,8 +21,13 @@ export class Music_Playlist extends BaseEntity { @ManyToOne(() => Playlist, (playlist) => playlist.music_playlist) @JoinColumn({ name: 'playlist_id' }) + @Index() playlist: Playlist; + @Column() + @Index() + created_at: Date; + static async getMusicListByPlaylistId(playlistId: number): Promise { return this.find({ relations: { @@ -41,40 +48,8 @@ export class Music_Playlist extends BaseEntity { music_playlist_id: false, }, order: { - music_playlist_id: 'DESC', - }, - }).then((a: Music_Playlist[]) => a.map((b) => b.music)); - } - - static async getRecentPlayedMusicByUserId(userId: string): Promise { - return await this.find({ - relations: { - music: true, - }, - where: { - playlist: { - playlist_title: '최근 재생 목록', - }, - music: { - user: { - user_id: userId, - }, - }, - }, - select: { - music_playlist_id: false, - music: { - music_id: true, - title: true, - music_file: true, - cover: true, - genre: true, - }, - }, - order: { - music_playlist_id: 'DESC', + created_at: 'DESC', }, - take: 10, }).then((a: Music_Playlist[]) => a.map((b) => b.music)); } @@ -89,7 +64,7 @@ export class Music_Playlist extends BaseEntity { relations: { music: true }, select: { music: { cover: true } }, where: { playlist: { playlist_id } }, - order: { music_playlist_id: 'DESC' }, + order: { created_at: 'DESC' }, }); } } diff --git a/server/src/entity/playlist.entity.ts b/server/src/entity/playlist.entity.ts index 291b847..7af127b 100644 --- a/server/src/entity/playlist.entity.ts +++ b/server/src/entity/playlist.entity.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, ManyToOne, OneToMany, @@ -27,6 +28,7 @@ export class Playlist extends BaseEntity { @ManyToOne(() => User, (user) => user.playlists) @JoinColumn({ name: 'user_id' }) + @Index() user: User; @OneToMany(() => Music_Playlist, (music_playlist) => music_playlist.playlist) diff --git a/server/src/entity/recent_played.entity.ts b/server/src/entity/recent_played.entity.ts new file mode 100644 index 0000000..dbc4f66 --- /dev/null +++ b/server/src/entity/recent_played.entity.ts @@ -0,0 +1,62 @@ +import { + BaseEntity, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Music } from './music.entity'; +import { User } from './user.entity'; + +@Entity({ name: 'recent_played' }) +@Index(['user', 'music']) +export class Recent_Played extends BaseEntity { + @PrimaryGeneratedColumn() + recent_played_id: number; + + @ManyToOne(() => Music, (music) => music.recent_played) + @JoinColumn({ name: 'music_id' }) + music: Music; + + @ManyToOne(() => User, (user) => user.recent_played) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column() + @Index() + played_at: Date; + + static async getRecentPlayedMusicByUserId( + user_id: string, + count: number, + ): Promise { + return await this.find({ + relations: { + music: { user: true }, + user: true, + }, + where: { + user: { user_id }, + }, + select: { + recent_played_id: false, + music: { + music_id: true, + title: true, + music_file: true, + cover: true, + genre: true, + user: { user_id: true, nickname: true }, + }, + }, + order: { + played_at: 'DESC', + }, + take: count, + }).then((recent_played: Recent_Played[]) => + recent_played.map((recent) => recent.music), + ); + } +} diff --git a/server/src/entity/user.entity.ts b/server/src/entity/user.entity.ts index e0d5ae6..895d1d9 100644 --- a/server/src/entity/user.entity.ts +++ b/server/src/entity/user.entity.ts @@ -5,9 +5,11 @@ import { BaseEntity, PrimaryColumn, OneToMany, + ILike, } from 'typeorm'; import { Playlist } from './playlist.entity'; import { Music } from './music.entity'; +import { Recent_Played } from './recent_played.entity'; @Entity({ name: 'user' }) export class User extends BaseEntity { @@ -31,4 +33,26 @@ export class User extends BaseEntity { @OneToMany(() => Playlist, (playlist) => playlist.user) playlists: Playlist[]; + + @OneToMany(() => Recent_Played, (recent_played) => recent_played.user) + recent_played: Recent_Played[]; + + static async getCertainUserByNickname(keyword: string): Promise { + return this.find({ + relations: { + musics: false, + playlists: false, + }, + select: { + user_id: true, + nickname: true, + user_email: true, + photo: true, + created_at: true, + }, + where: { + nickname: ILike(`%${keyword}%`), + }, + }); + } } diff --git a/server/src/main.ts b/server/src/main.ts index 13cad38..c52769d 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,8 +1,9 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { winstonLogger } from './config/logger.config'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { logger: winstonLogger }); await app.listen(3000); } bootstrap(); diff --git a/server/src/music/music.controller.spec.ts b/server/src/music/music.controller.spec.ts new file mode 100644 index 0000000..c5f7513 --- /dev/null +++ b/server/src/music/music.controller.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { ExecutionContext } from '@nestjs/common/interfaces'; +import { MusicService } from './music.service'; +import { UploadService } from 'src/upload/upload.service'; +import { NcloudConfigService } from 'src/config/ncloud.config'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Music } from 'src/entity/music.entity'; +import { user } from 'test/constants/music.mockData'; +import { MusicController } from './music.controller'; +import { User } from 'src/entity/user.entity'; +import { AuthService } from 'src/auth/auth.service'; +import { JwtService } from '@nestjs/jwt'; +import { AuthGuard } from '@nestjs/passport'; +import { GreenEyeService } from 'src/config/greenEye.service'; + +describe('UploadController', () => { + let app: INestApplication; + let musicController: MusicController; + let uploadService: UploadService; + let cloudService: NcloudConfigService; + let configService: ConfigService; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + controllers: [MusicController], + providers: [ + MusicService, + UploadService, + NcloudConfigService, + ConfigService, + GreenEyeService, + AuthService, + JwtService, + { + provide: getRepositoryToken(Music), + useClass: Repository, + }, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + req.user = user; + return true; + }, + }) + .compile(); + + configService = module.get(ConfigService); + cloudService = module.get(NcloudConfigService); + uploadService = module.get(UploadService); + authService = module.get(AuthService); + musicController = module.get(MusicController); + + app = module.createNestApplication(); + await app.init(); + }); + + describe('MusicController 환경 확인', () => { + it('MusicController should be defined', () => { + expect(musicController).toBeDefined(); + }); + }); +}); diff --git a/server/src/music/music.controller.ts b/server/src/music/music.controller.ts index 4bbb80b..de8bb9e 100644 --- a/server/src/music/music.controller.ts +++ b/server/src/music/music.controller.ts @@ -9,6 +9,7 @@ import { UsePipes, ValidationPipe, Query, + Logger, } from '@nestjs/common'; import { MusicService } from './music.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; @@ -19,8 +20,11 @@ import { Music } from 'src/entity/music.entity'; @Controller('musics') export class MusicController { + private readonly logger = new Logger('Music'); private objectStorage: AWS.S3; - constructor(private readonly musicService: MusicService) {} + constructor( + private readonly musicService: MusicService, + ) {} @Post() @UsePipes(ValidationPipe) @@ -31,6 +35,7 @@ export class MusicController { @Req() req, ): Promise<{ music_id: string }> { const userId = req.user.user_id; + this.logger.log(`POST /musics - nickname=${req.user.nickname}`); const savedMusicId: string = await this.musicService.createMusic( musicCreateDto, userId, @@ -41,16 +46,16 @@ export class MusicController { @Get('recent-uploads') @HttpCode(HTTP_STATUS_CODE.SUCCESS) async getRecentMusics(): Promise { + this.logger.log(`GET /musics/recent-uploads`); const musics = this.musicService.getRecentMusic(); - return musics; } @Get('genres') @HttpCode(HTTP_STATUS_CODE.SUCCESS) getGenres(): { genres: string[] } { + this.logger.log(`GET /musics/genres`); const genreName: string[] = Object.keys(Genres); - return { genres: genreName }; } @@ -61,6 +66,9 @@ export class MusicController { @Req() req, @Query('count') count: number, ): Promise { + this.logger.log( + `GET /musics/my-uploads - nickname=${req.user.nickname}, count=${count}`, + ); const userId: string = req.user.user_id; return this.musicService.getMyUploads(userId, count); } @@ -68,6 +76,7 @@ export class MusicController { @Get('info') @HttpCode(HTTP_STATUS_CODE.SUCCESS) async getMusicInfo(@Query('music_id') music_id: string): Promise { + this.logger.log(`GET /musics/info - music_id=${music_id}`); return this.musicService.getMusicInfo(music_id); } @@ -77,8 +86,21 @@ export class MusicController { @Query('music_id') music_id: string, @Query('fileName') fileName: string, ): Promise<{ file: AWS.S3.Body }> { + this.logger.log( + `GET /musics/ts - music_id=${music_id}, fileName=${fileName}`, + ); return { file: await this.musicService.getEncodedChunkFiles(music_id, fileName), }; } + + @Get('search') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getCertainTitleMusic( + @Query('keyword') keyword: string, + ): Promise { + this.logger.log(`GET /musics/search - keyword=${keyword}`); + return this.musicService.getCertainKeywordNicknameUser(keyword); + } } diff --git a/server/src/music/music.module.ts b/server/src/music/music.module.ts index 48b8a2c..2a27f84 100644 --- a/server/src/music/music.module.ts +++ b/server/src/music/music.module.ts @@ -6,10 +6,18 @@ import { Music } from 'src/entity/music.entity'; import { AuthModule } from 'src/auth/auth.module'; import { UploadService } from 'src/upload/upload.service'; import { NcloudConfigService } from 'src/config/ncloud.config'; +import { Logger } from 'winston'; +import { GreenEyeService } from 'src/config/greenEye.service'; @Module({ imports: [TypeOrmModule.forFeature([Music]), AuthModule], controllers: [MusicController], - providers: [MusicService, UploadService, NcloudConfigService], + providers: [ + MusicService, + UploadService, + NcloudConfigService, + Logger, + GreenEyeService, + ], }) export class MusicModule {} diff --git a/server/src/music/music.service.spec.ts b/server/src/music/music.service.spec.ts new file mode 100644 index 0000000..2f01b83 --- /dev/null +++ b/server/src/music/music.service.spec.ts @@ -0,0 +1,78 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { MusicService } from './music.service'; +import { UploadService } from 'src/upload/upload.service'; +import { NcloudConfigService } from 'src/config/ncloud.config'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Music } from 'src/entity/music.entity'; +import { MusicCreateDto } from 'src/dto/musicCreate.dto'; +import { CatchyException } from 'src/config/catchyException'; +import { + faultGenreMusicCreateInfo, + musicCreateInfo, + newMusicData, +} from 'test/constants/music.mockData'; +import { GreenEyeService } from 'src/config/greenEye.service'; +import { Recent_Played } from 'src/entity/recent_played.entity'; + +describe('UploadController', () => { + let app: INestApplication; + let musicService: MusicService; + let uploadService: UploadService; + let cloudService: NcloudConfigService; + let configService: ConfigService; + let mockRepository: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + controllers: [], + providers: [ + MusicService, + UploadService, + NcloudConfigService, + ConfigService, + GreenEyeService, + { + provide: getRepositoryToken(Music), + useClass: Repository, + }, + { + provide: getRepositoryToken(Recent_Played), + useClass: Repository, + }, + ], + }).compile(); + + configService = module.get(ConfigService); + cloudService = module.get(NcloudConfigService); + uploadService = module.get(UploadService); + mockRepository = { + create: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked>; + musicService = new MusicService( + mockRepository, + uploadService, + cloudService, + ); + }); + + describe('MusicService 환경 확인', () => { + it('MusicService should be defined', () => { + expect(musicService).toBeDefined(); + }); + }); + + it('잘못된 장르로 요청을 보내면 CatchyException이 발생한다.', async () => { + const musicInfo: MusicCreateDto = faultGenreMusicCreateInfo; + + const userId: string = 'user_id'; + + await expect(async () => { + await musicService.createMusic(musicInfo, userId); + }).rejects.toThrow(CatchyException); + }); +}); diff --git a/server/src/music/music.service.ts b/server/src/music/music.service.ts index 4723a40..9c12f3f 100644 --- a/server/src/music/music.service.ts +++ b/server/src/music/music.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; @@ -7,17 +7,13 @@ import { Music } from 'src/entity/music.entity'; import { Genres } from 'src/constants'; import { CatchyException } from 'src/config/catchyException'; import { ERROR_CODE } from 'src/config/errorCode.enum'; -import * as fs from 'fs'; -import * as ffmpeg from 'fluent-ffmpeg'; -import * as ffmpegInstaller from '@ffmpeg-installer/ffmpeg'; -import * as path from 'path'; -import axios from 'axios'; import { UploadService } from 'src/upload/upload.service'; import { NcloudConfigService } from 'src/config/ncloud.config'; import { AWSError } from 'aws-sdk'; @Injectable() export class MusicService { + private readonly logger = new Logger('MusicService'); private objectStorage: AWS.S3; constructor( @InjectRepository(Music) private musicRepository: Repository, @@ -35,142 +31,6 @@ export class MusicService { return false; } - private separateMusicName(musicPath: string): string { - const parsedPath = new URL(musicPath); - const pathNames = parsedPath.pathname.split('/'); - const musicName = pathNames[pathNames.length - 1]; - - return musicName; - } - - private getPath(option: string): string { - return path.resolve(__dirname, `musics${option}`); - } - - private setEncodingPaths(musicPath: string) { - const musicName: string = this.separateMusicName(musicPath); - - return { - outputMusicPath: this.getPath('/output'), - entireMusicPath: this.getPath(''), - outputPath: this.getPath(`/output/${musicName.replace('.mp3', '')}.m3u8`), - tempFilePath: this.getPath(`/${musicName}`), - }; - } - - async encodeMusic(musicId: string, musicPath: string): Promise { - try { - ffmpeg.setFfmpegPath(ffmpegInstaller.path); - - const { outputMusicPath, entireMusicPath, outputPath, tempFilePath } = - this.setEncodingPaths(musicPath); - - fs.mkdirSync(outputMusicPath, { recursive: true }); - - const musicFileResponse = await axios.get(musicPath, { - responseType: 'arraybuffer', - }); - - const musicBuffer = Buffer.from(musicFileResponse.data); - - fs.writeFile(tempFilePath, musicBuffer, (err) => { - if (err) throw new Error(); - }); - - const encodedFileURL = await this.executeEncoding( - tempFilePath, - outputPath, - outputMusicPath, - musicId, - ); - - fs.rmdirSync(entireMusicPath, { recursive: true }); - - return encodedFileURL; - } catch (err) { - if (err instanceof CatchyException) { - throw err; - } - - throw new CatchyException( - 'MUSIC_ENCODE_ERROR', - HTTP_STATUS_CODE.SERVER_ERROR, - ERROR_CODE.SERVER_ERROR, - ); - } - } - - async executeEncoding( - tempFilePath: string, - outputPath: string, - outputMusicPath: string, - musicId: string, - ): Promise { - let m3u8FileName; - let m3u8Path: string; - const watcher = fs.watch(outputMusicPath, (eventType, fileName) => { - if (fileName.match(/.m3u8$/)) { - m3u8FileName = fileName; - } else if (!fileName.match(/\.tmp$/)) { - this.uploadEncodedFile( - outputMusicPath + `/${fileName}`, - musicId, - fileName, - ); - } - }); - return await new Promise((resolve, reject) => { - ffmpeg(tempFilePath) - .addOption([ - '-map 0:a', - '-c:a aac', - '-b:a 192k', - '-hls_time 30', - '-hls_list_size 0', - '-f hls', - ]) - .output(outputPath) - .on('end', async () => { - watcher.close(); - m3u8Path = await this.uploadEncodedFile( - outputMusicPath + `/${m3u8FileName}`, - musicId, - m3u8FileName, - ); - resolve(m3u8Path); - }) - .on('error', () => { - reject(new Error()); - }) - .run(); - }); - } - - async uploadEncodedFile( - file: string, - musicId: string, - fileName: string, - ): Promise { - try { - const { url } = await this.uploadService.uploadEncodedFile( - file, - musicId, - fileName, - ); - return url; - } catch (err) { - if (err instanceof CatchyException) { - throw err; - } - - throw new CatchyException( - 'SERVICE_ERROR', - HTTP_STATUS_CODE.SERVER_ERROR, - ERROR_CODE.SERVICE_ERROR, - ); - } - } - async createMusic( musicCreateDto: MusicCreateDto, user_id: string, @@ -185,6 +45,7 @@ export class MusicService { } = musicCreateDto; if (!this.isValidGenre(genre)) { + this.logger.error(`music.service - createMusic : NOT_EXIST_GENRE`); throw new CatchyException( 'NOT_EXIST_GENRE', HTTP_STATUS_CODE.BAD_REQUEST, @@ -192,13 +53,11 @@ export class MusicService { ); } - const encodedFileURL = await this.encodeMusic(music_id, music_file); - const newMusic: Music = this.musicRepository.create({ music_id, title, cover, - music_file: encodedFileURL, + music_file, created_at: new Date(), genre, user: { user_id }, @@ -212,6 +71,7 @@ export class MusicService { throw err; } + this.logger.error(`music.service - createMusic : SERVICE_ERROR`); throw new CatchyException( 'SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -224,6 +84,7 @@ export class MusicService { try { return Music.getRecentMusic(); } catch { + this.logger.error(`music.service - getRecentMusic : SERVICE_ERROR`); throw new CatchyException( 'SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -236,6 +97,7 @@ export class MusicService { try { return Music.getMusicListByUserId(userId, count); } catch { + this.logger.error(`music.service - getMyUploads : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -249,6 +111,7 @@ export class MusicService { const targetMusic: Music = await Music.getMusicById(music_id); if (!targetMusic) { + this.logger.error(`music.service - getMusicInfo : NOT_EXIST_MUSIC`); throw new CatchyException( 'NOT_EXIST_MUSIC', HTTP_STATUS_CODE.BAD_REQUEST, @@ -258,6 +121,8 @@ export class MusicService { return targetMusic; } catch (err) { if (err instanceof CatchyException) throw err; + + this.logger.error(`music.service - getMusicInfo : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -285,6 +150,9 @@ export class MusicService { const awsError = err as AWSError; if (awsError && awsError.code === 'NoSuchKey') { + this.logger.error( + `music.service - getEncodedChunkFiles : NOT_EXIST_TS_IN_BUCKET`, + ); throw new CatchyException( 'NOT_EXIST_TS_IN_BUCKET', HTTP_STATUS_CODE.NOT_FOUND, @@ -292,6 +160,7 @@ export class MusicService { ); } + this.logger.error(`music.service - getEncodedChunkFiles : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -299,4 +168,19 @@ export class MusicService { ); } } + + async getCertainKeywordNicknameUser(keyword: string): Promise { + try { + return await Music.getCertainMusicByTitle(keyword); + } catch { + this.logger.error( + `music.service - getCertainKeywordNicknameUser : QUERY_ERROR`, + ); + throw new CatchyException( + 'QUERY_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.QUERY_ERROR, + ); + } + } } diff --git a/server/src/playlist/playlist.controller.ts b/server/src/playlist/playlist.controller.ts index 1aa7ddd..3283902 100644 --- a/server/src/playlist/playlist.controller.ts +++ b/server/src/playlist/playlist.controller.ts @@ -3,9 +3,10 @@ import { Controller, Get, HttpCode, + Logger, Param, - Patch, Post, + Put, Req, UseGuards, UsePipes, @@ -20,6 +21,7 @@ import { Music } from 'src/entity/music.entity'; @Controller('playlists') export class PlaylistController { + private readonly logger = new Logger('Playlist'); constructor(private playlistService: PlaylistService) {} @Post() @@ -30,6 +32,9 @@ export class PlaylistController { @Req() req, @Body() playlistCreateDto: PlaylistCreateDto, ): Promise<{ playlist_id: number }> { + this.logger.log( + `POST /playlists - nickname=${req.user.nickname}, body=${playlistCreateDto}`, + ); const userId: string = req.user.user_id; const playlistId: number = await this.playlistService.createPlaylist( userId, @@ -46,6 +51,9 @@ export class PlaylistController { @Param('playlistId') playlistId: number, @Body('musicId') music_id: string, ): Promise<{ music_playlist_id: number }> { + this.logger.log( + `POST /playlists/${playlistId} - nickname=${req.user.nickname}, musicId=${music_id}`, + ); const userId: string = req.user.user_id; const music_playlist_id: number = await this.playlistService.addMusicToPlaylist( @@ -53,13 +61,14 @@ export class PlaylistController { playlistId, music_id, ); - return { music_playlist_id}; + return { music_playlist_id }; } @Get() @UseGuards(AuthGuard()) @HttpCode(HTTP_STATUS_CODE.SUCCESS) async getUserPlaylists(@Req() req): Promise { + this.logger.log(`GET /playlists - nickname=${req.user.nickname}`); const userId: string = req.user.user_id; const playlists: Playlist[] = await this.playlistService.getUserPlaylists(userId); @@ -73,6 +82,9 @@ export class PlaylistController { @Req() req, @Param('playlistId') playlistId: number, ): Promise { + this.logger.log( + `GET /playlists/${playlistId} - nickname=${req.user.nickname}`, + ); const userId: string = req.user.user_id; return await this.playlistService.getPlaylistMusics(userId, playlistId); } diff --git a/server/src/playlist/playlist.module.ts b/server/src/playlist/playlist.module.ts index b458076..76fcebb 100644 --- a/server/src/playlist/playlist.module.ts +++ b/server/src/playlist/playlist.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from 'src/auth/auth.module'; import { Music_Playlist } from 'src/entity/music_playlist.entity'; import { Music } from 'src/entity/music.entity'; +import { Logger } from 'winston'; @Module({ imports: [ @@ -13,7 +14,7 @@ import { Music } from 'src/entity/music.entity'; AuthModule, ], controllers: [PlaylistController], - providers: [PlaylistService], + providers: [PlaylistService, Logger], exports: [PlaylistService], }) export class PlaylistModule {} diff --git a/server/src/playlist/playlist.service.ts b/server/src/playlist/playlist.service.ts index 2907606..4e9c1a5 100644 --- a/server/src/playlist/playlist.service.ts +++ b/server/src/playlist/playlist.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { CatchyException } from 'src/config/catchyException'; import { ERROR_CODE } from 'src/config/errorCode.enum'; @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; @Injectable() export class PlaylistService { + private readonly logger = new Logger('PlaylistService'); constructor( @InjectRepository(Playlist) private playlistRepository: Repository, @@ -37,6 +38,7 @@ export class PlaylistService { const playlistId: number = result.playlist_id; return playlistId; } catch { + this.logger.error(`playlist.service - createPlaylist : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -52,6 +54,9 @@ export class PlaylistService { ): Promise { // 사용자 플리가 있는지 확인 if (!(await this.isExistPlaylistOnUser(playlistId, userId))) { + this.logger.error( + `playlist.service - addMusicToPlaylist : NOT_EXIST_PLAYLIST_ON_USER`, + ); throw new CatchyException( 'NOT_EXIST_PLAYLIST_ON_USER', HTTP_STATUS_CODE.BAD_REQUEST, @@ -60,6 +65,9 @@ export class PlaylistService { } // 음악 있는지 확인 if (!(await this.isExistMusic(musicId))) { + this.logger.error( + `playlist.service - addMusicToPlaylist : NOT_EXIST_MUSIC`, + ); throw new CatchyException( 'NOT_EXIST_MUSIC', HTTP_STATUS_CODE.BAD_REQUEST, @@ -69,6 +77,9 @@ export class PlaylistService { // 이미 추가된 음악인지 확인 if (await this.isAlreadyAdded(playlistId, musicId)) { + this.logger.error( + `playlist.service - addMusicToPlaylist : ALREADY_ADDED`, + ); throw new CatchyException( 'ALREADY_ADDED', HTTP_STATUS_CODE.BAD_REQUEST, @@ -82,6 +93,7 @@ export class PlaylistService { this.music_playlistRepository.create({ music: { music_id: musicId }, playlist: { playlist_id: playlistId }, + created_at: new Date(), }); const result: Music_Playlist = @@ -89,6 +101,9 @@ export class PlaylistService { this.setUpdatedAtNow(playlistId); return result.music_playlist_id; } catch { + this.logger.error( + `playlist.service - addMusicToPlaylist : SERVICE_ERROR`, + ); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -105,6 +120,7 @@ export class PlaylistService { }); return count !== 0; } catch { + this.logger.error(`playlist.service - isAlreadyAdded : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -124,6 +140,9 @@ export class PlaylistService { }); return playlistCount !== 0; } catch { + this.logger.error( + `playlist.service - isExistPlaylistOnUser : SERVICE_ERROR`, + ); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -140,6 +159,7 @@ export class PlaylistService { return musicCount !== 0; } catch { + this.logger.error(`playlist.service - isExistMusic : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -156,6 +176,7 @@ export class PlaylistService { targetPlaylist.updated_at = new Date(); this.playlistRepository.save(targetPlaylist); } catch { + this.logger.error(`playlist.service - setUpdatedAtNow : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -181,6 +202,7 @@ export class PlaylistService { await Promise.all(thumbnailPromises); return playlists; } catch { + this.logger.error(`playlist.service - getUserPlaylists : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -194,6 +216,9 @@ export class PlaylistService { playlistId: number, ): Promise { if (!(await this.isExistPlaylistOnUser(playlistId, userId))) { + this.logger.error( + `playlist.service - getPlaylistMusics : NOT_EXIST_PLAYLIST_ON_USER`, + ); throw new CatchyException( 'NOT_EXIST_PLAYLIST_ON_USER', HTTP_STATUS_CODE.BAD_REQUEST, @@ -203,6 +228,7 @@ export class PlaylistService { try { return Music_Playlist.getMusicListByPlaylistId(playlistId); } catch { + this.logger.error(`playlist.service - getPlaylistMusics : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -210,16 +236,4 @@ export class PlaylistService { ); } } - - async getRecentMusicsByUserId(userId: string) { - try { - return Music_Playlist.getRecentPlayedMusicByUserId(userId); - } catch { - throw new CatchyException( - 'SERVER ERROR', - HTTP_STATUS_CODE.SERVER_ERROR, - ERROR_CODE.SERVER_ERROR, - ); - } - } } diff --git a/server/src/upload/upload.controller.ts b/server/src/upload/upload.controller.ts index fa99c96..19fe4a8 100644 --- a/server/src/upload/upload.controller.ts +++ b/server/src/upload/upload.controller.ts @@ -13,6 +13,7 @@ import { MaxFileSizeValidator, ValidationPipe, UsePipes, + Logger, } from '@nestjs/common'; import { UploadService } from './upload.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; @@ -25,15 +26,20 @@ import { fileSize } from 'src/constants'; @Controller('upload') export class UploadController { - constructor(private uploadService: UploadService) {} + private readonly logger = new Logger('Upload'); + constructor( + private uploadService: UploadService, + ) {} @Get('uuid') @UseGuards(AuthGuard()) @HttpCode(HTTP_STATUS_CODE.SUCCESS) getMusicUUID(): { uuid: string } { try { + this.logger.log(`GET /upload/uuid`); return { uuid: v4() }; } catch (err) { + this.logger.error(`upload.controller - getMusicUUID : SERVER_ERROR`); throw new CatchyException( 'SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -59,6 +65,7 @@ export class UploadController { file: Express.Multer.File, @Body('uuid') uuid: string, ): Promise<{ url: string }> { + this.logger.log(`POST /upload/music - uuid=${uuid}`); const { url } = await this.uploadService.uploadMusic(file, uuid); return { url }; } @@ -82,6 +89,9 @@ export class UploadController { @Body('type') type: string, @Body('uuid') uuid: string | null, ): Promise<{ url: string }> { + this.logger.log( + `POST /upload/image - nickname=${req.user.nickname}, type=${type}, uuid=${uuid}`, + ); try { const userId = req.user.user_id; const id = type === 'user' ? userId : uuid; @@ -95,6 +105,7 @@ export class UploadController { } catch (err) { if (err instanceof CatchyException) throw err; + this.logger.error(`upload.controller - uploadImage : NOT_EXIST_MUSIC_ID`); throw new CatchyException( 'NOT_EXIST_MUSIC_ID', HTTP_STATUS_CODE.BAD_REQUEST, diff --git a/server/src/upload/upload.module.ts b/server/src/upload/upload.module.ts index 2fed54e..a1dd5be 100644 --- a/server/src/upload/upload.module.ts +++ b/server/src/upload/upload.module.ts @@ -3,10 +3,12 @@ import { UploadController } from './upload.controller'; import { UploadService } from './upload.service'; import { NcloudConfigService } from 'src/config/ncloud.config'; import { AuthModule } from 'src/auth/auth.module'; +import { Logger } from 'winston'; +import { GreenEyeService } from 'src/config/greenEye.service'; @Module({ imports: [AuthModule], controllers: [UploadController], - providers: [UploadService, NcloudConfigService], + providers: [UploadService, NcloudConfigService, Logger, GreenEyeService], }) export class UploadModule {} diff --git a/server/src/upload/upload.service.ts b/server/src/upload/upload.service.ts index 8245f17..45f9f80 100644 --- a/server/src/upload/upload.service.ts +++ b/server/src/upload/upload.service.ts @@ -1,18 +1,28 @@ -import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import * as fs from 'fs'; +import { Injectable, Logger } from '@nestjs/common'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { NcloudConfigService } from './../config/ncloud.config'; import { S3 } from 'aws-sdk'; import { contentTypeHandler, keyFlags } from './../constants'; import { CatchyException } from 'src/config/catchyException'; import { ERROR_CODE } from 'src/config/errorCode.enum'; -import * as fs from 'fs'; import { Readable } from 'stream'; +import { GreenEyeService } from '../config/greenEye.service'; +import { DeleteObjectOutput } from 'aws-sdk/clients/s3'; +import { CloudFunctionsResponseDto } from 'src/dto/cloudFunctions.response.dto'; @Injectable() export class UploadService { + private readonly logger = new Logger('UploadService'); private objectStorage: S3; - constructor(private readonly nCloudConfigService: NcloudConfigService) { + private encodingActionUrl: string; + constructor( + private readonly nCloudConfigService: NcloudConfigService, + private readonly greenEyeService: GreenEyeService, + ) { this.objectStorage = nCloudConfigService.createObjectStorageOption(); + this.encodingActionUrl = nCloudConfigService.getExecuteActionsUrl(); } private isValidType(flag: string): boolean { @@ -30,12 +40,56 @@ export class UploadService { return false; } + private async deleteObjectStorageImage( + path: string, + ): Promise { + return await this.objectStorage + .deleteObject({ + Bucket: 'catchy-tape-bucket2', + Key: path, + }) + .promise(); + } + + async checkImageNormal( + message: string, + confidence: number, + keyPath: string, + ): Promise { + if (message !== 'SUCCESS') { + await this.deleteObjectStorageImage(keyPath); + + this.logger.error( + `upload.service - checkImageNormal : FAIL_GREEN_EYE_IMAGE_RECOGNITION`, + ); + throw new CatchyException( + 'FAIL_GREEN_EYE_IMAGE_RECOGNITION', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.FAIL_GREEN_EYE_IMAGE_RECOGNITION, + ); + } + + if (confidence < 0.7) { + await this.deleteObjectStorageImage(keyPath); + + this.logger.error(`upload.service - checkImageNormal : BAD_IMAGE`); + throw new CatchyException( + 'BAD_IMAGE', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.BAD_IMAGE, + ); + } + } + async uploadMusic( file: Express.Multer.File, musicId: string, ): Promise<{ url: string }> { try { if (!this.isValidUUIDPattern(musicId)) { + this.logger.error( + `upload.service - uploadMusic : INVALID_INPUT_UUID_VALUE`, + ); throw new CatchyException( 'INVALID_INPUT_UUID_VALUE', HTTP_STATUS_CODE.BAD_REQUEST, @@ -43,7 +97,7 @@ export class UploadService { ); } - const uploadResult = await this.objectStorage + await this.objectStorage .upload({ Bucket: 'catchy-tape-bucket2', Key: `music/${musicId}/music.mp3`, @@ -53,8 +107,46 @@ export class UploadService { }) .promise(); - return { url: uploadResult.Location }; - } catch { + const params = { + container_name: 'catchy-tape-bucket2', + music_id: musicId, + }; + + const result: CloudFunctionsResponseDto = await axios + .post( + this.encodingActionUrl, + params, + this.nCloudConfigService.getRequestActionUrlHeaders(), + ) + .then((response) => response.data) + .catch((err) => { + this.logger.error( + `upload.service - uploadMusic : MUSIC_ENCODE_REQUEST_ERROR`, + ); + throw new CatchyException( + 'MUSIC_ENCODE_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.MUSIC_ENCODE_ERROR, + ); + }); + + if (!result.body.url) { + this.logger.error(`upload.service - uploadMusic : MUSIC_ENCODE_ERROR`); + throw new CatchyException( + 'MUSIC_ENCODE_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.MUSIC_ENCODE_ERROR, + ); + } + + return { url: result.body.url }; + } catch (err) { + console.log(err); + if (err instanceof CatchyException) { + throw err; + } + + this.logger.error(`upload.service - uploadMusic : SERVICE_ERROR`); throw new CatchyException( 'SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -70,6 +162,9 @@ export class UploadService { ): Promise<{ url: string }> { try { if (!this.isValidUUIDPattern(id)) { + this.logger.error( + `upload.service - uploadImage : INVALID_INPUT_UUID_VALUE`, + ); throw new CatchyException( 'INVALID_INPUT_UUID_VALUE', HTTP_STATUS_CODE.BAD_REQUEST, @@ -78,6 +173,9 @@ export class UploadService { } if (!this.isValidType(type)) { + this.logger.error( + `upload.service - uploadImage : INVALID_INPUT_TYPE_VALUE`, + ); throw new CatchyException( 'INVALID_INPUT_TYPE_VALUE', HTTP_STATUS_CODE.BAD_REQUEST, @@ -85,8 +183,6 @@ export class UploadService { ); } - const encodedFileName = encodeURIComponent(file.originalname); - const keyPath = type === 'user' ? `image/user/${id}/image.png` @@ -102,8 +198,23 @@ export class UploadService { }) .promise(); + const { images } = await this.greenEyeService.getResultOfNormalImage( + uploadResult.Location, + ); + + await this.checkImageNormal( + images[0].message, + images[0].confidence, + keyPath, + ); + return { url: uploadResult.Location }; - } catch { + } catch (err) { + if (err instanceof CatchyException) { + throw err; + } + + this.logger.error(`upload.service - uploadImage : SERVICE_ERROR`); throw new CatchyException( 'SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -128,8 +239,11 @@ export class UploadService { .promise(); return { url: uploadResult.Location }; - } catch(err) { + } catch (err) { console.log(err); + this.logger.error( + `upload.service - uploadEncodedFile : NCP_UPLOAD_ERROR`, + ); throw new CatchyException( 'NCP_UPLOAD_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, diff --git a/server/src/user/user.controller.spec.ts b/server/src/user/user.controller.spec.ts index 275f626..868d4b0 100644 --- a/server/src/user/user.controller.spec.ts +++ b/server/src/user/user.controller.spec.ts @@ -4,15 +4,11 @@ import { UserService } from './user.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { User } from 'src/entity/user.entity'; import { Repository } from 'typeorm'; -import { PlaylistService } from 'src/playlist/playlist.service'; -import { Music } from 'src/entity/music.entity'; -import { Music_Playlist } from 'src/entity/music_playlist.entity'; -import { Playlist } from 'src/entity/playlist.entity'; +import { Recent_Played } from 'src/entity/recent_played.entity'; describe('UserController', () => { let controller: UserController; let userService: UserService; - let playlistService: PlaylistService; let userRepository: Repository; beforeEach(async () => { @@ -20,21 +16,12 @@ describe('UserController', () => { controllers: [UserController], providers: [ UserService, - PlaylistService, { provide: getRepositoryToken(User), useClass: Repository, }, { - provide: getRepositoryToken(Playlist), - useClass: Repository, - }, - { - provide: getRepositoryToken(Music), - useClass: Repository, - }, - { - provide: getRepositoryToken(Music_Playlist), + provide: getRepositoryToken(Recent_Played), useClass: Repository, }, ], @@ -42,7 +29,6 @@ describe('UserController', () => { controller = module.get(UserController); userService = module.get(UserService); - playlistService = module.get(PlaylistService); userRepository = module.get(getRepositoryToken(User)); }); diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 1a8a145..5ddced6 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -7,6 +7,9 @@ import { UseGuards, Patch, Body, + Query, + Logger, + Put, } from '@nestjs/common'; import { UserService } from './user.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; @@ -18,6 +21,7 @@ import { User } from 'src/entity/user.entity'; @Controller('users') export class UserController { + private readonly logger = new Logger('User'); constructor(private userService: UserService) {} @Get('duplicate/:name') @@ -25,6 +29,7 @@ export class UserController { async checkDuplicateNickname( @Param('name') name: string, ): Promise<{ nickname: string }> { + this.logger.log(`GET /users/duplicate/${name}`); if (await this.userService.isDuplicatedUserEmail(name)) { throw new CatchyException( 'DUPLICATED_NICKNAME', @@ -39,10 +44,16 @@ export class UserController { @Get('recent-played') @UseGuards(AuthGuard()) @HttpCode(HTTP_STATUS_CODE.SUCCESS) - async getUserRecentPlayedMusics(@Req() req): Promise { - const userId = req.user.userId; - const userMusicData = - await this.userService.getRecentPlayedMusicByUserId(userId); + async getUserRecentPlayedMusics( + @Req() req, + @Query('count') count: number, + ): Promise { + this.logger.log(`GET /users/recent-played - nickname=${req.user.nickname}`); + const userId = req.user.user_id; + const userMusicData = await this.userService.getRecentPlayedMusicByUserId( + userId, + count, + ); return userMusicData; } @@ -54,7 +65,10 @@ export class UserController { @Req() req, @Body('image_url') image_url, ): Promise<{ user_id: string }> { - const user_id = req.user.userId; + this.logger.log( + `PATCH /users/image - nickname=${req.user.nickname}, image_url=${image_url}`, + ); + const user_id = req.user.user_id; return { user_id: await this.userService.updateUserImage(user_id, image_url), }; @@ -64,7 +78,51 @@ export class UserController { @UseGuards(AuthGuard()) @HttpCode(HTTP_STATUS_CODE.SUCCESS) async getMyInformation(@Req() req): Promise { - const user_id = req.user.userId; + this.logger.log(`GET /users/my-info - nickname=${req.user.nickname}`); + const user_id = req.user.user_id; return this.userService.getUserInformation(user_id); } + + @Get('search') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getCertainNicknameUser( + @Query('keyword') keyword: string, + ): Promise { + this.logger.log(`GET /users/search - keyword=${keyword}`); + return this.userService.getCertainKeywordNicknameUser(keyword); + } + + @Put('recent-played') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async updateRecentPlayMusic( + @Req() req, + @Body('musicId') music_id: string, + ): Promise<{ recent_played_id: number }> { + this.logger.log( + `PUT /user/recent-played - nickname=${req.user.nickname}, music_id=${music_id}`, + ); + const user_id: string = req.user.user_id; + const recent_played_id: number = await this.userService.updateRecentMusic( + music_id, + user_id, + ); + return { recent_played_id }; + } + + @Get('recent-info') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getRecentPlaylistInfo( + @Req() req, + ): Promise<{ music_count: number; thumbnail: string }> { + const user_id: string = req.user.user_id; + this.logger.log(`GET /user/recent-info - nickname=${req.user.nickname}`); + const music_count: number = + await this.userService.getRecentPlaylistMusicCount(user_id); + const thumbnail: string = + await this.userService.getRecentPlaylistThumbnail(user_id); + return { music_count, thumbnail }; + } } diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index 3e27e0e..ecaff11 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -4,17 +4,12 @@ import { UserService } from './user.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/entity/user.entity'; import { AuthModule } from 'src/auth/auth.module'; -import { PlaylistService } from 'src/playlist/playlist.service'; -import { Playlist } from 'src/entity/playlist.entity'; -import { Music_Playlist } from 'src/entity/music_playlist.entity'; -import { Music } from 'src/entity/music.entity'; +import { Logger } from 'winston'; +import { Recent_Played } from 'src/entity/recent_played.entity'; @Module({ - imports: [ - TypeOrmModule.forFeature([User, Playlist, Music_Playlist, Music]), - AuthModule, - ], + imports: [TypeOrmModule.forFeature([User, Recent_Played]), AuthModule], controllers: [UserController], - providers: [UserService, PlaylistService], + providers: [UserService, Logger], }) export class UserModule {} diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 93b2135..33aa0ed 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -3,42 +3,30 @@ import { UserService } from './user.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../entity/user.entity'; -import { Playlist } from 'src/entity/playlist.entity'; -import { Music } from 'src/entity/music.entity'; -import { Music_Playlist } from 'src/entity/music_playlist.entity'; -import { PlaylistService } from 'src/playlist/playlist.service'; +import { Recent_Played } from 'src/entity/recent_played.entity'; +import { PassportModule } from '@nestjs/passport'; describe('UserService', () => { let service: UserService; - let playlistService: PlaylistService; let userRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [PassportModule.register({ defaultStrategy: 'jwt' })], providers: [ UserService, - PlaylistService, { provide: getRepositoryToken(User), useClass: Repository, }, { - provide: getRepositoryToken(Playlist), - useClass: Repository, - }, - { - provide: getRepositoryToken(Music), - useClass: Repository, - }, - { - provide: getRepositoryToken(Music_Playlist), + provide: getRepositoryToken(Recent_Played), useClass: Repository, }, ], }).compile(); service = module.get(UserService); - playlistService = module.get(PlaylistService); userRepository = module.get(getRepositoryToken(User)); }); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index a66cea9..825ed58 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,18 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { User } from 'src/entity/user.entity'; import { Music } from 'src/entity/music.entity'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; -import { PlaylistService } from 'src/playlist/playlist.service'; import { CatchyException } from 'src/config/catchyException'; import { ERROR_CODE } from 'src/config/errorCode.enum'; +import { Recent_Played } from 'src/entity/recent_played.entity'; @Injectable() export class UserService { + private readonly logger = new Logger('UserService'); constructor( @InjectRepository(User) private userRepository: Repository, - private playlistService: PlaylistService, + @InjectRepository(Recent_Played) + private recentPlayedRepository: Repository, ) {} async isDuplicatedUserEmail(userNickname: string): Promise { @@ -27,6 +29,7 @@ export class UserService { return false; } catch { + this.logger.error(`user.service - isDuplicatedUserEmail : SERVICE_ERROR`); throw new CatchyException( 'SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -35,8 +38,22 @@ export class UserService { } } - async getRecentPlayedMusicByUserId(userId: string): Promise { - return await this.playlistService.getRecentMusicsByUserId(userId); + async getRecentPlayedMusicByUserId( + userId: string, + count: number, + ): Promise { + try { + return await Recent_Played.getRecentPlayedMusicByUserId(userId, count); + } catch { + this.logger.error( + `user.service - getRecentPlayedMusicByUserId : QUERY_ERROR`, + ); + throw new CatchyException( + 'QUERY_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.QUERY_ERROR, + ); + } } async updateUserImage(user_id: string, image_url: string): Promise { @@ -46,6 +63,7 @@ export class UserService { }); if (!targetUser) { + this.logger.error(`user.service - updateUserImage : NOT_EXIST_USER`); throw new CatchyException( 'NOT_EXIST_USER', HTTP_STATUS_CODE.BAD_REQUEST, @@ -61,6 +79,7 @@ export class UserService { throw err; } + this.logger.error(`user.service - updateUserImage : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -75,6 +94,7 @@ export class UserService { where: { user_id }, }); } catch { + this.logger.error(`user.service - getUserInfomation : SERVICE_ERROR`); throw new CatchyException( 'SERVER_ERROR', HTTP_STATUS_CODE.SERVER_ERROR, @@ -82,4 +102,123 @@ export class UserService { ); } } + + async getCertainKeywordNicknameUser(keyword: string): Promise { + try { + return User.getCertainUserByNickname(keyword); + } catch { + this.logger.error( + `user.service - getCertainKeywordNicknameUser : QUERY_ERROR`, + ); + throw new CatchyException( + 'QUERY_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.QUERY_ERROR, + ); + } + } + + async isExistMusicInRecentPlaylist( + music_id: string, + user_id: string, + ): Promise { + try { + const musicCount: number = await this.recentPlayedRepository.count({ + where: { music: { music_id }, user: { user_id } }, + }); + return musicCount != 0; + } catch { + this.logger.error( + `user.service - isExistMusicInRecentPlaylist : QUERY_ERROR`, + ); + throw new CatchyException( + 'QUERY_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.QUERY_ERROR, + ); + } + } + + async updateRecentMusic(music_id: string, user_id: string): Promise { + try { + if (!(await Music.getMusicById(music_id))) { + this.logger.error( + `user.service - updateRecentMusic : NOT_EXIST_MUSIC_ID`, + ); + throw new CatchyException( + 'NOT_EXIST_MUSIC_ID', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.NOT_EXIST_MUSIC_ID, + ); + } + if (!(await this.isExistMusicInRecentPlaylist(music_id, user_id))) { + const newRow: Recent_Played = this.recentPlayedRepository.create({ + music: { music_id }, + user: { user_id }, + played_at: new Date(), + }); + const addedRow: Recent_Played = + await this.recentPlayedRepository.save(newRow); + return addedRow.recent_played_id; + } + + const targetRow: Recent_Played = + await this.recentPlayedRepository.findOne({ + where: { music: { music_id }, user: { user_id } }, + }); + targetRow.played_at = new Date(); + const updatedRow: Recent_Played = + await this.recentPlayedRepository.save(targetRow); + return updatedRow.recent_played_id; + } catch (err) { + if (err instanceof CatchyException) throw err; + + this.logger.error(`user.service - updateRecentMusic : SERVICE_ERROR`); + throw new CatchyException( + 'SERVICE_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async getRecentPlaylistMusicCount(user_id: string): Promise { + try { + return await this.recentPlayedRepository.count({ + where: { user: { user_id } }, + }); + } catch { + this.logger.error( + `user.service - getRecentPlaylistMusicCount : QUERY_ERROR`, + ); + throw new CatchyException( + 'QUERY_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.QUERY_ERROR, + ); + } + } + + async getRecentPlaylistThumbnail(user_id: string): Promise { + try { + const recentMusic: Recent_Played = + await this.recentPlayedRepository.findOne({ + relations: { music: true, user: true }, + select: { music: { cover: true } }, + where: { user: { user_id } }, + order: { played_at: 'DESC' }, + }); + + return recentMusic.music.cover; + } catch { + this.logger.error( + `user.service - getRecentPlaylistThumbnail : QUERY_ERROR`, + ); + throw new CatchyException( + 'QUERY_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.QUERY_ERROR, + ); + } + } } diff --git a/server/test/app.e2e-spec.ts b/server/test/app.e2e-spec.ts index 50cda62..e79d6c6 100644 --- a/server/test/app.e2e-spec.ts +++ b/server/test/app.e2e-spec.ts @@ -19,6 +19,6 @@ describe('AppController (e2e)', () => { return request(app.getHttpServer()) .get('/') .expect(200) - .expect('Hello World!'); + .expect('Catchy-Tape!'); }); }); diff --git a/server/test/constants/music.mockData.ts b/server/test/constants/music.mockData.ts new file mode 100644 index 0000000..618766f --- /dev/null +++ b/server/test/constants/music.mockData.ts @@ -0,0 +1,62 @@ +import { MusicCreateDto } from 'src/dto/musicCreate.dto'; +import { Music } from 'src/entity/music.entity'; +import { Genres } from 'src/constants'; +import { User } from 'src/entity/user.entity'; + +export const user: User = { + user_id: '2d4b574c-b370-4e2e-ac4f-e1eca8eea090', + nickname: 'master2', + photo: null, + user_email: 'sugamypapa@gmail.com', + created_at: new Date('2023-12-04 23:57:50.693000'), +} as User; + +export const realMusicCreateInfo: MusicCreateDto = { + music_id: '6bcd415d-4a22-43f0-9daa-64199442ff2d', + title: 'mysongone', + cover: + 'https://kr.object.ncloudstorage.com/catchy-tape-bucket2/image/cover/6bcd415d-4a22-43f0-9daa-64199442ff2d/image.png', + file: 'https://kr.object.ncloudstorage.com/catchy-tape-bucket2/music/6bcd415d-4a22-43f0-9daa-64199442ff2d/music.mp3', + genre: 'acoustic' as Genres, +}; + +export const musicCreateInfo: MusicCreateDto = { + music_id: 'music_id', + title: '내가부른노래예시1', + cover: 'cover이미지.png', + file: 'https://kr.object.ncloudstorage.com/catchy-tape-bucket2/music/6bcd415d-4a22-43f0-9daa-64199442ff2d/music.mp3', + genre: 'dance' as Genres, +}; + +export const faultGenreMusicCreateInfo: MusicCreateDto = { + music_id: 'music_id', + title: '내가부른노래예시1', + cover: 'cover이미지.png', + file: 'https://kr.object.ncloudstorage.com/catchy-tape-bucket2/music/6bcd415d-4a22-43f0-9daa-64199442ff2d/music.mp3', + genre: 'pop-pin' as Genres, +}; + +export const newMusicData: Music = { + music_id: 'music_id', + title: '내가부른노래예시1', + cover: 'cover이미지.png', + genre: 'dance' as Genres, + music_file: 'encodedURL', + created_at: new Date(), + lyrics: null, + user: { + user_id: '아이디', + nickname: '나', + photo: '이미지', + user_email: '이메일', + created_at: new Date(), + } as User, + music_playlist: [], + recent_played: [], + hasId: () => true, + save: async () => newMusicData, + remove: async () => newMusicData, + softRemove: async () => newMusicData, + recover: async () => newMusicData, + reload: async () => {}, +}; diff --git a/server/test/utils/lovelovelove.mp3 b/server/test/utils/lovelovelove.mp3 new file mode 100644 index 0000000..b894ff7 Binary files /dev/null and b/server/test/utils/lovelovelove.mp3 differ diff --git "a/server/test/utils/\355\225\230\355\225\230.png" "b/server/test/utils/\355\225\230\355\225\230.png" new file mode 100644 index 0000000..533f6d2 Binary files /dev/null and "b/server/test/utils/\355\225\230\355\225\230.png" differ diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json index 64f86c6..7d3fffd 100644 --- a/server/tsconfig.build.json +++ b/server/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "cloud-functions"] }