Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: post recommendation API #508

Merged
merged 17 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9692bd6
:heavy_plus_sign: config(api): add spring cloud dependency
siyeonSon Sep 8, 2024
8ba6e7c
:sparkles: feat(api): get 50 popular songs using Apple Music API
siyeonSon Sep 8, 2024
77ceeb9
:sparkles: chore(api): change songs limit 50 -> 30
siyeonSon Sep 8, 2024
c4be97f
Merge branch 'dev' into feat/drop-recommend
siyeonSon Sep 8, 2024
3621126
:sparkles: feat(api): get recent posted songs
siyeonSon Sep 11, 2024
818872f
:sparkles: feat(api): recommend artists
siyeonSon Sep 11, 2024
397c293
:sparkles: feat(api): recommend artists
siyeonSon Sep 11, 2024
0aaa7c4
:sparkles: feat(api): recommend recent posted songs and popular songs…
siyeonSon Sep 11, 2024
23e0767
:sparkles: refactor(api): manage constants in RecommendType enum class
siyeonSon Sep 11, 2024
41da990
:sparkles: refactor(api): refactor the getCategoryChart()
siyeonSon Sep 18, 2024
a178ae0
:sparkles: refactor(api): refactor query of findRecentSongs()
siyeonSon Sep 22, 2024
3b72796
:sparkles: refactor(api): refactor query of findRecentSongs() with JPQL
siyeonSon Sep 22, 2024
bcac59c
:sparkles: refactor(api): get only one duplicate music
siyeonSon Sep 23, 2024
8e5c57c
:sparkles: refactor(api): change RecommendType name CHART_SONGS -> PO…
siyeonSon Sep 23, 2024
5438995
:sparkles: refactor(api): change RecommendType title according to the…
siyeonSon Sep 23, 2024
4836bd6
:sparkles: refactor(api): disable logging
siyeonSon Sep 23, 2024
70e439a
:recycle: refactor(api): refactor if-else -> switch case
siyeonSon Sep 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/streetdrop-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ jar {
enabled = false
}

ext {
set('springCloudVersion', "2022.0.3")
}

dependencies {
implementation project(':streetdrop-domain')
implementation project(':streetdrop-common')
Expand All @@ -39,6 +43,13 @@ dependencies {

implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}

dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ public enum CommonErrorCode implements ErrorCodeInterface {
* Basic Server Error
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_INTERNAL_SERVER_ERROR", "Internal Server Error", "An unexpected error occurred"),
NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "COMMON_NOT_IMPLEMENTED", "Not Implemented", "The server does not support the functionality required to fulfill the request.");

NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "COMMON_NOT_IMPLEMENTED", "Not Implemented", "The server does not support the functionality required to fulfill the request."),
UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "COMMON_UNSUPPORTED_TYPE", "Unsupported Type", "The type specified is not supported.");
siyeonSon marked this conversation as resolved.
Show resolved Hide resolved

private final HttpStatus status;
private final String errorResponseCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import com.depromeet.domains.music.dto.response.MusicResponseDto;
import com.depromeet.domains.music.event.CreateSongGenreEvent;
import com.depromeet.domains.music.song.repository.SongRepository;
import com.depromeet.domains.recommend.dto.response.MusicInfoResponseDto;
import com.depromeet.domains.recommend.dto.response.RecommendCategoryDto;
import com.depromeet.domains.recommend.constant.RecommendType;
import com.depromeet.music.album.Album;
import com.depromeet.music.album.AlbumCover;
import com.depromeet.music.artist.Artist;
Expand Down Expand Up @@ -120,4 +123,13 @@ public MusicResponseDto getMusic(Long songId) {
.map(MusicResponseDto::new)
.orElseThrow(() -> new NotFoundException(CommonErrorCode.NOT_FOUND, songId));
}

@Transactional(readOnly = true)
public RecommendCategoryDto getRecentMusic(RecommendType recommendType) {
var recentSongs = songRepository.findRecentSongs(recommendType.getLimit());
List<MusicInfoResponseDto> musicInfoResponseDtos = recentSongs.stream()
.map(MusicInfoResponseDto::ofSong)
.toList();
return RecommendCategoryDto.ofMusicInfoResponseDto(recommendType, musicInfoResponseDtos);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import com.depromeet.music.song.Song;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface SongRepository extends JpaRepository<Song, Long> {
Expand All @@ -16,4 +18,14 @@ public interface SongRepository extends JpaRepository<Song, Long> {
"JOIN FETCH s.album.artist " +
"JOIN FETCH s.album.albumCover WHERE s.id = :id")
Optional<Song> findSongById(Long id);

@Query(nativeQuery = true,
value = "SELECT DISTINCT s.* FROM (" +
" SELECT s.* FROM song s " +
" JOIN item i ON s.song_id = i.song_id " +
" ORDER BY i.created_at DESC " +
" LIMIT :count" +
") s")
List<Song> findRecentSongs(@Param("count") int count);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.depromeet.domains.recommend.constant;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum RecommendType {
POPULAR_CHART_SONG("지금 인기 있는 음악", 30, true),
RECENT_SONGS("최근 드랍된 음악", 15, true),
CHART_ARTIST("아티스트", 10, false);

private final String title;
private final int limit;
private final boolean nextPage;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.domains.recommend.controller;

import com.depromeet.common.dto.ResponseDto;
import com.depromeet.domains.recommend.dto.response.RecommendResponseDto;
import com.depromeet.domains.recommend.dto.response.SearchTermRecommendResponseDto;
import com.depromeet.domains.recommend.service.SearchRecommendService;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -12,17 +13,25 @@
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/search-term/recommend")
@RequestMapping
@RequiredArgsConstructor
@Tag(name = "💁Search Recommend", description = "Search Recommend API")
public class SearchRecommendController {

private final SearchRecommendService searchRecommendService;

@Operation(summary = "검색어 추천")
@GetMapping
@GetMapping("/search-term/recommend")
siyeonSon marked this conversation as resolved.
Show resolved Hide resolved
public ResponseEntity<SearchTermRecommendResponseDto> recommendSearchTerm() {
var response = searchRecommendService.recommendSearchTerm();
return ResponseDto.ok(response);
}

@Operation(summary = "검색어 추천 v2")
@GetMapping("/v2/search-term/recommend")
public ResponseEntity<RecommendResponseDto> recommendSearchTerm2() {
var response = searchRecommendService.recommendSearchSongs();
return ResponseDto.ok(response);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.depromeet.domains.recommend.dto.response;

import com.depromeet.external.applemusic.dto.response.catalogchart.AppleMusicAlbumChartResponseDto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ArtistInfoResponseDto {

private static final int MINUTES_PER_HOUR = 60;
private static final int TO_SEC = 1000;
private static final int ALBUM_IMAGE_SIZE = 500;
private static final int ALBUM_THUMBNAIL_IMAGE_SIZE = 100;

private String artistName;
private String albumImage;
private String albumThumbnailImage;

private static String fillSize(String url, int size) {
return url.replace("{w}", String.valueOf(size)).replace("{h}", String.valueOf(size));
}

private static String convertToTimeFormat(int totalMilliseconds) {
int totalSeconds = totalMilliseconds / TO_SEC;
int minutes = totalSeconds / MINUTES_PER_HOUR;
int seconds = totalSeconds % MINUTES_PER_HOUR;
return String.format("%d:%02d", minutes, seconds);
}

public static ArtistInfoResponseDto fromAppleMusicResponse(AppleMusicAlbumChartResponseDto.AlbumData data) {
return new ArtistInfoResponseDto(
data.attributes.artistName,
fillSize(data.attributes.artwork.url, ALBUM_IMAGE_SIZE),
fillSize(data.attributes.artwork.url, ALBUM_THUMBNAIL_IMAGE_SIZE)
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.depromeet.domains.recommend.dto.response;

import com.depromeet.external.applemusic.dto.response.catalogchart.AppleMusicSongChartResponseDto;
import com.depromeet.music.genre.Genre;
import com.depromeet.music.song.Song;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MusicInfoResponseDto {
private String albumName;
private String artistName;
private String songName;
private String durationTime;
private String albumImage;
private String albumThumbnailImage;
private List<String> genre;

public static MusicInfoResponseDto ofSong(Song song) {
return new MusicInfoResponseDto(
song.getAlbum().getName(),
song.getAlbum().getArtist().getName(),
song.getName(),
"",
song.getAlbum().getAlbumCover().getAlbumImage(),
song.getAlbum().getAlbumCover().getAlbumThumbnail(),
song.getGenres()
.stream()
.map(Genre::getName)
.toList()
);
}

public static MusicInfoResponseDto fromAppleMusicResponse(AppleMusicSongChartResponseDto.SongData data) {
final int MINUTES_PER_HOUR = 60;
final int TO_SEC = 1000;
final int ALBUM_IMAGE_SIZE = 500;
final int ALBUM_THUMBNAIL_IMAGE_SIZE = 100;

BiFunction<String, Integer, String> fillSize = (s, size) ->
s.replace("{w}", String.valueOf(size)).replace("{h}", String.valueOf(size));

Function<Integer, String> totalSecondsToTime = totalSeconds -> {
totalSeconds = totalSeconds / TO_SEC;
int minutes = totalSeconds / MINUTES_PER_HOUR;
int seconds = totalSeconds % MINUTES_PER_HOUR;
return String.format("%d:%02d", minutes, seconds);
};

MusicInfoResponseDto musicInfo = new MusicInfoResponseDto();
musicInfo.albumName = data.attributes.albumName;
musicInfo.artistName = data.attributes.artistName;
musicInfo.songName = data.attributes.name;
musicInfo.durationTime = totalSecondsToTime.apply(data.attributes.durationInMillis);
musicInfo.albumImage = fillSize.apply(data.attributes.artwork.url, ALBUM_IMAGE_SIZE);
musicInfo.albumThumbnailImage = fillSize.apply(data.attributes.artwork.url, ALBUM_THUMBNAIL_IMAGE_SIZE);
musicInfo.genre = data.attributes.genreNames;
return musicInfo;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.depromeet.domains.recommend.dto.response;

import com.depromeet.domains.recommend.constant.RecommendType;
import com.depromeet.external.applemusic.dto.response.catalogchart.AppleMusicAlbumChartResponseDto;
import com.depromeet.external.applemusic.dto.response.catalogchart.AppleMusicSongChartResponseDto;
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

@Getter
@AllArgsConstructor
public class RecommendCategoryDto {
private String title;
private List<?> content;
private boolean nextPage;

public static RecommendCategoryDto ofMusicInfoResponseDto(RecommendType recommendType, List<MusicInfoResponseDto> musicInfoResponseDto) {
return new RecommendCategoryDto(recommendType.getTitle(), musicInfoResponseDto, recommendType.isNextPage());
}


public static RecommendCategoryDto ofAppleMusicResponseDto(RecommendType recommendType, AppleMusicSongChartResponseDto appleMusicSongChartResponseDto) {
List<MusicInfoResponseDto> musicInfoList = Optional.ofNullable(appleMusicSongChartResponseDto.results.songs)
.filter(songs -> !songs.isEmpty())
.map(songs -> songs.get(0).data.stream()
.map(MusicInfoResponseDto::fromAppleMusicResponse)
.toList()
)
.orElse(Collections.emptyList());
return new RecommendCategoryDto(recommendType.getTitle(), musicInfoList, recommendType.isNextPage());
}

public static RecommendCategoryDto ofAppleMusicResponseDto(RecommendType recommendType, AppleMusicAlbumChartResponseDto appleMusicAlbumChartResponseDto) {
List<ArtistInfoResponseDto> artistInfoList =
Optional.ofNullable(appleMusicAlbumChartResponseDto.results.albums)
.filter(albums -> !albums.isEmpty())
.map(albums -> albums.get(0).data.stream()
.map(ArtistInfoResponseDto::fromAppleMusicResponse)
.toList()
)
.orElse(Collections.emptyList());
return new RecommendCategoryDto(recommendType.getTitle(), artistInfoList, recommendType.isNextPage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.depromeet.domains.recommend.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter
@AllArgsConstructor
public class RecommendResponseDto {
private List<RecommendCategoryDto> data;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

import com.depromeet.common.error.dto.CommonErrorCode;
import com.depromeet.common.error.exception.internal.BusinessException;
import com.depromeet.domains.recommend.dto.response.SearchTermRecommendResponseDto;
import com.depromeet.domains.recommend.dto.response.TextColorDto;
import com.depromeet.domains.music.service.MusicService;
import com.depromeet.domains.recommend.constant.RecommendType;
import com.depromeet.domains.recommend.dto.response.*;
import com.depromeet.domains.recommend.repository.SearchRecommendTermRepository;
import com.depromeet.external.applemusic.service.AppleMusicService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class SearchRecommendService {

private final MusicService musicService;
private final AppleMusicService appleMusicService;
private final SearchRecommendTermRepository searchRecommendTermRepository;

public SearchTermRecommendResponseDto recommendSearchTerm() {
Expand All @@ -30,4 +36,15 @@ public SearchTermRecommendResponseDto recommendSearchTerm() {

return new SearchTermRecommendResponseDto(description, termList);
}

public RecommendResponseDto recommendSearchSongs() {
return new RecommendResponseDto(
List.of(
siyeonSon marked this conversation as resolved.
Show resolved Hide resolved
appleMusicService.getCategoryChart(RecommendType.POPULAR_CHART_SONG),
musicService.getRecentMusic(RecommendType.RECENT_SONGS),
appleMusicService.getCategoryChart(RecommendType.CHART_ARTIST)
)
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.depromeet.external.applemusic.config;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppleMusicAuthConfig implements RequestInterceptor {

private static final String HEADER_NAME ="Authorization";
private static final String AUTH_TYPE = "Bearer";

@Value("${apple.music.api.key}")
private String value;

@Override
public void apply(RequestTemplate requestTemplate) {
requestTemplate.header(HEADER_NAME, AUTH_TYPE + " " + value);
}

}
Comment on lines +8 to +22
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apple Music API와 통신하는 코드가 서로 통일되어 있지 않아요.

음악 검색 Apple Music API 통신은 Web Client로 작성되어 있고, 검색어 추천 Apple Music API 통신은 Feign Client로 작성했습니다.
이로 인해 설정 부분에도 차이가 존재해요. 현재 코드에 작성된 AppleMusicAuthConfig와 기존 코드의 AppleMusicConfig를 비교해보시면 좋을 것 같아요.

이전에 작성된 코드와 싱크를 맞추는 작업이 필요해요. 다만 AppleMusic API 호출 부분, 설정 부분, DTO 등 작업량이 많아질 것이라 생각해요. 별도의 브랜치에 작업하여 반영하겠습니다.

참고
Feign Client를 사용한 이유: #472 (comment)

Loading
Loading