Compare commits
2 Commits
49f3e43d40
...
9b2d84ff33
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2d84ff33 | |||
| 1f958fb7c0 |
193
src/main/java/com/domain/api/SpotifyController.java
Normal file
193
src/main/java/com/domain/api/SpotifyController.java
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package com.domain.api;
|
||||||
|
|
||||||
|
import com.domain.request.SpotifyAddTracksRequest;
|
||||||
|
import com.domain.request.SpotifyTokenRequest;
|
||||||
|
import com.domain.request.VibeToSpotifySearchRequest;
|
||||||
|
import com.domain.response.SpotifyAddTracksResponse;
|
||||||
|
import com.domain.response.SpotifyPlaylistResponse;
|
||||||
|
import com.domain.response.VibeToSpotifySearchResponse;
|
||||||
|
import com.domain.service.SpotifyService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/spotify")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Spotify", description = "Spotify 플레이리스트 API")
|
||||||
|
public class SpotifyController {
|
||||||
|
|
||||||
|
private final SpotifyService spotifyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify Access Token을 사용하여 사용자의 플레이리스트를 조회합니다.
|
||||||
|
*
|
||||||
|
* @param request Spotify Access Token
|
||||||
|
* @return Spotify 플레이리스트 목록
|
||||||
|
*/
|
||||||
|
@PostMapping("/playlists")
|
||||||
|
@Operation(summary = "Spotify 플레이리스트 조회", description = "Spotify Access Token으로 사용자의 플레이리스트를 조회합니다.")
|
||||||
|
public ResponseEntity<SpotifyPlaylistResponse> getSpotifyPlaylists(@RequestBody SpotifyTokenRequest request) {
|
||||||
|
log.info("Spotify 플레이리스트 조회 요청");
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(SpotifyPlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("Access Token이 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SpotifyPlaylistResponse response = spotifyService.getUserPlaylists(request.getAccessToken());
|
||||||
|
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
log.info("Spotify 플레이리스트 조회 성공 - 플레이리스트 개수: {}",
|
||||||
|
response.getTotalCount());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
log.warn("Spotify 플레이리스트 조회 실패 - 메시지: {}", response.getMessage());
|
||||||
|
return ResponseEntity.status(401).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Spotify 플레이리스트 조회 중 예외 발생", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(SpotifyPlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다.
|
||||||
|
*
|
||||||
|
* @param request 트랙 추가 요청 (access_token, playlist_id, track_uris 등)
|
||||||
|
* @return 트랙 추가 결과
|
||||||
|
*/
|
||||||
|
@PostMapping("/playlists/tracks")
|
||||||
|
@Operation(summary = "Spotify 트랙 추가", description = "기존 플레이리스트에 트랙을 추가하거나 새 플레이리스트를 생성합니다. playlist_id가 없으면 새로 생성합니다.")
|
||||||
|
public ResponseEntity<SpotifyAddTracksResponse> addTracksToPlaylist(@RequestBody SpotifyAddTracksRequest request) {
|
||||||
|
log.info("Spotify 트랙 추가 요청 - 플레이리스트 ID: {}", request.getPlaylistId());
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(SpotifyAddTracksResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("Access Token이 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getTracks() == null || request.getTracks().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(SpotifyAddTracksResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("추가할 트랙 목록이 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 플레이리스트 생성 시 이름 확인
|
||||||
|
if ((request.getPlaylistId() == null || request.getPlaylistId().trim().isEmpty()) &&
|
||||||
|
(request.getPlaylistName() == null || request.getPlaylistName().trim().isEmpty())) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(SpotifyAddTracksResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("새 플레이리스트 생성 시 이름이 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SpotifyAddTracksResponse response = spotifyService.addTracksToPlaylist(request);
|
||||||
|
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
log.info("Spotify 트랙 추가 성공 - 플레이리스트 ID: {}, 추가된 트랙 수: {}",
|
||||||
|
response.getPlaylistId(), response.getTracksAdded());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
log.warn("Spotify 트랙 추가 실패 - 메시지: {}", response.getMessage());
|
||||||
|
return ResponseEntity.status(400).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Spotify 트랙 추가 중 예외 발생", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(SpotifyAddTracksResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe 트랙 정보를 Spotify에서 검색하여 매칭합니다.
|
||||||
|
*
|
||||||
|
* @param request Vibe 트랙 정보 리스트 (가수, 제목)
|
||||||
|
* @return Spotify 검색 결과 (track_uri 포함)
|
||||||
|
*/
|
||||||
|
@PostMapping("/search/vibe-tracks")
|
||||||
|
@Operation(summary = "Vibe 트랙 Spotify 검색", description = "Vibe에서 받은 트랙 정보(가수, 제목)를 Spotify에서 검색하여 track_uri를 반환합니다.")
|
||||||
|
public ResponseEntity<VibeToSpotifySearchResponse> searchVibeTracksInSpotify(
|
||||||
|
@RequestBody VibeToSpotifySearchRequest request) {
|
||||||
|
|
||||||
|
log.info("Vibe 트랙 Spotify 검색 요청 - 트랙 개수: {}",
|
||||||
|
request.getTracks() != null ? request.getTracks().size() : 0);
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(VibeToSpotifySearchResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("Access Token이 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getTracks() == null || request.getTracks().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(VibeToSpotifySearchResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("검색할 트랙 목록이 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
VibeToSpotifySearchResponse response = spotifyService.searchTracksFromVibe(request);
|
||||||
|
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
log.info("Vibe 트랙 Spotify 검색 완료 - 전체: {}, 매칭: {}, 미매칭: {}",
|
||||||
|
response.getTotalSearched(),
|
||||||
|
response.getMatchedCount(),
|
||||||
|
response.getUnmatchedCount());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
log.warn("Vibe 트랙 Spotify 검색 실패 - 메시지: {}", response.getMessage());
|
||||||
|
return ResponseEntity.status(400).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 트랙 Spotify 검색 중 예외 발생", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(VibeToSpotifySearchResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify API 연동 상태를 확인합니다.
|
||||||
|
*
|
||||||
|
* @return 헬스체크 응답
|
||||||
|
*/
|
||||||
|
@GetMapping("/health")
|
||||||
|
@Operation(summary = "Spotify API 헬스체크", description = "Spotify API 연동 상태를 확인합니다.")
|
||||||
|
public ResponseEntity<String> healthCheck() {
|
||||||
|
return ResponseEntity.ok("Spotify API is ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/main/java/com/domain/api/VibeController.java
Normal file
137
src/main/java/com/domain/api/VibeController.java
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package com.domain.api;
|
||||||
|
|
||||||
|
import com.domain.request.VibeLoginRequest;
|
||||||
|
import com.domain.response.VibePlaylistResponse;
|
||||||
|
import com.domain.response.VibeTrackResponse;
|
||||||
|
import com.domain.service.VibeService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/vibe")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Vibe", description = "Vibe 플레이리스트 API")
|
||||||
|
public class VibeController {
|
||||||
|
|
||||||
|
private final VibeService vibeService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네이버 로그인을 통해 Vibe 플레이리스트를 조회합니다.
|
||||||
|
*
|
||||||
|
* @param request 네이버 아이디/비밀번호
|
||||||
|
* @return Vibe 플레이리스트 목록
|
||||||
|
*/
|
||||||
|
@PostMapping("/playlists")
|
||||||
|
@Operation(summary = "Vibe 플레이리스트 조회", description = "네이버 계정으로 로그인하여 Vibe 플레이리스트를 조회합니다.")
|
||||||
|
public ResponseEntity<VibePlaylistResponse> getVibePlaylists(@RequestBody VibeLoginRequest request) {
|
||||||
|
log.info("Vibe 플레이리스트 조회 요청 - 네이버 ID: {}", request.getId());
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (request.getId() == null || request.getId().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(VibePlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("네이버 아이디를 입력해주세요.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getPassword() == null || request.getPassword().trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(VibePlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("네이버 비밀번호를 입력해주세요.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
VibePlaylistResponse response = vibeService.getVibePlaylistsWithLogin(request);
|
||||||
|
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
log.info("Vibe 플레이리스트 조회 성공 - 플레이리스트 개수: {}",
|
||||||
|
response.getPlaylists() != null ? response.getPlaylists().size() : 0);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
log.warn("Vibe 플레이리스트 조회 실패 - 메시지: {}", response.getMessage());
|
||||||
|
return ResponseEntity.status(401).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 플레이리스트 조회 중 예외 발생", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(VibePlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 ID를 사용하여 플레이리스트의 트랙 목록을 조회합니다.
|
||||||
|
*
|
||||||
|
* @param sessionId 세션 ID (헤더)
|
||||||
|
* @param playlistId 플레이리스트 ID
|
||||||
|
* @return 트랙 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/playlists/{playlistId}/tracks")
|
||||||
|
@Operation(summary = "Vibe 트랙 목록 조회", description = "세션 ID를 사용하여 플레이리스트의 트랙 목록을 조회합니다.")
|
||||||
|
public ResponseEntity<VibeTrackResponse> getVibeTracks(
|
||||||
|
@RequestHeader("X-Session-Id") String sessionId,
|
||||||
|
@PathVariable("playlistId") String playlistId) {
|
||||||
|
|
||||||
|
log.info("Vibe 트랙 목록 조회 요청 - 플레이리스트 ID: {}, 세션 ID: {}", playlistId, sessionId);
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (sessionId == null || sessionId.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(VibeTrackResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("세션 ID가 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistId == null || playlistId.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(VibeTrackResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("플레이리스트 ID가 필요합니다.")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
VibeTrackResponse response = vibeService.getVibeTracks(sessionId, playlistId);
|
||||||
|
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
log.info("Vibe 트랙 목록 조회 성공 - 트랙 개수: {}",
|
||||||
|
response.getTracks() != null ? response.getTracks().size() : 0);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
log.warn("Vibe 트랙 목록 조회 실패 - 메시지: {}", response.getMessage());
|
||||||
|
return ResponseEntity.status(401).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 트랙 목록 조회 중 예외 발생", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(VibeTrackResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("서버 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe API 연동 상태를 확인합니다.
|
||||||
|
*
|
||||||
|
* @return 헬스체크 응답
|
||||||
|
*/
|
||||||
|
@GetMapping("/health")
|
||||||
|
@Operation(summary = "Vibe API 헬스체크", description = "Vibe API 연동 상태를 확인합니다.")
|
||||||
|
public ResponseEntity<String> healthCheck() {
|
||||||
|
return ResponseEntity.ok("Vibe API is ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.domain.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SpotifyAddTracksRequest {
|
||||||
|
|
||||||
|
@JsonProperty("access_token")
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
@JsonProperty("playlist_id")
|
||||||
|
private String playlistId; // null이면 새 플레이리스트 생성
|
||||||
|
|
||||||
|
@JsonProperty("playlist_name")
|
||||||
|
private String playlistName; // 새 플레이리스트 생성 시 이름
|
||||||
|
|
||||||
|
@JsonProperty("playlist_description")
|
||||||
|
private String playlistDescription; // 새 플레이리스트 생성 시 설명
|
||||||
|
|
||||||
|
@JsonProperty("is_public")
|
||||||
|
private Boolean isPublic; // 새 플레이리스트 공개 여부 (기본값: false)
|
||||||
|
|
||||||
|
private List<TrackInfo> tracks; // Vibe에서 받은 트랙 정보 리스트
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class TrackInfo {
|
||||||
|
@JsonProperty("track_title")
|
||||||
|
private String trackTitle;
|
||||||
|
|
||||||
|
@JsonProperty("artist_name")
|
||||||
|
private String artistName;
|
||||||
|
|
||||||
|
@JsonProperty("album_title")
|
||||||
|
private String albumTitle; // 선택적, 더 정확한 매칭을 위해
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main/java/com/domain/request/SpotifyTokenRequest.java
Normal file
17
src/main/java/com/domain/request/SpotifyTokenRequest.java
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.domain.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SpotifyTokenRequest {
|
||||||
|
|
||||||
|
@JsonProperty("access_token")
|
||||||
|
private String accessToken;
|
||||||
|
}
|
||||||
15
src/main/java/com/domain/request/VibeLoginRequest.java
Normal file
15
src/main/java/com/domain/request/VibeLoginRequest.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.domain.request;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class VibeLoginRequest {
|
||||||
|
private String id;
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.domain.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class VibeToSpotifySearchRequest {
|
||||||
|
|
||||||
|
@JsonProperty("access_token")
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
private List<TrackInfo> tracks;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class TrackInfo {
|
||||||
|
@JsonProperty("track_title")
|
||||||
|
private String trackTitle;
|
||||||
|
|
||||||
|
@JsonProperty("artist_name")
|
||||||
|
private String artistName;
|
||||||
|
|
||||||
|
@JsonProperty("album_title")
|
||||||
|
private String albumTitle; // 선택적, 더 정확한 매칭을 위해
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.domain.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class SpotifyAddTracksResponse {
|
||||||
|
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@JsonProperty("playlist_id")
|
||||||
|
private String playlistId;
|
||||||
|
|
||||||
|
@JsonProperty("playlist_name")
|
||||||
|
private String playlistName;
|
||||||
|
|
||||||
|
@JsonProperty("playlist_url")
|
||||||
|
private String playlistUrl;
|
||||||
|
|
||||||
|
@JsonProperty("tracks_added")
|
||||||
|
private Integer tracksAdded;
|
||||||
|
|
||||||
|
@JsonProperty("is_new_playlist")
|
||||||
|
private Boolean isNewPlaylist;
|
||||||
|
|
||||||
|
// 매칭 통계 추가
|
||||||
|
@JsonProperty("total_searched")
|
||||||
|
private Integer totalSearched;
|
||||||
|
|
||||||
|
@JsonProperty("matched_count")
|
||||||
|
private Integer matchedCount;
|
||||||
|
|
||||||
|
@JsonProperty("unmatched_count")
|
||||||
|
private Integer unmatchedCount;
|
||||||
|
|
||||||
|
@JsonProperty("unmatched_tracks")
|
||||||
|
private List<String> unmatchedTracks; // 매칭 실패한 트랙 목록 (가수 - 제목)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.domain.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class SpotifyPlaylistResponse {
|
||||||
|
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@JsonProperty("total_count")
|
||||||
|
private Integer totalCount;
|
||||||
|
|
||||||
|
private List<Playlist> playlists;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class Playlist {
|
||||||
|
@JsonProperty("playlist_id")
|
||||||
|
private String playlistId;
|
||||||
|
|
||||||
|
@JsonProperty("playlist_name")
|
||||||
|
private String playlistName;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@JsonProperty("track_count")
|
||||||
|
private Integer trackCount;
|
||||||
|
|
||||||
|
@JsonProperty("image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
@JsonProperty("is_public")
|
||||||
|
private Boolean isPublic;
|
||||||
|
|
||||||
|
@JsonProperty("owner_name")
|
||||||
|
private String ownerName;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_url")
|
||||||
|
private String spotifyUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/main/java/com/domain/response/VibePlaylistResponse.java
Normal file
80
src/main/java/com/domain/response/VibePlaylistResponse.java
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package com.domain.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class VibePlaylistResponse {
|
||||||
|
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@JsonProperty("session_id")
|
||||||
|
private String sessionId;
|
||||||
|
|
||||||
|
private List<Playlist> playlists;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class Playlist {
|
||||||
|
@JsonProperty("playlist_id")
|
||||||
|
private String playlistId;
|
||||||
|
|
||||||
|
@JsonProperty("playlist_name")
|
||||||
|
private String playlistName;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@JsonProperty("track_count")
|
||||||
|
private Integer trackCount;
|
||||||
|
|
||||||
|
@JsonProperty("image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
@JsonProperty("created_at")
|
||||||
|
private String createdAt;
|
||||||
|
|
||||||
|
@JsonProperty("updated_at")
|
||||||
|
private String updatedAt;
|
||||||
|
|
||||||
|
private List<Track> tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class Track {
|
||||||
|
@JsonProperty("track_id")
|
||||||
|
private String trackId;
|
||||||
|
|
||||||
|
@JsonProperty("track_name")
|
||||||
|
private String trackName;
|
||||||
|
|
||||||
|
@JsonProperty("artist_name")
|
||||||
|
private String artistName;
|
||||||
|
|
||||||
|
@JsonProperty("album_name")
|
||||||
|
private String albumName;
|
||||||
|
|
||||||
|
@JsonProperty("album_image_url")
|
||||||
|
private String albumImageUrl;
|
||||||
|
|
||||||
|
@JsonProperty("duration_ms")
|
||||||
|
private Long durationMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.domain.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class VibeToSpotifySearchResponse {
|
||||||
|
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@JsonProperty("total_searched")
|
||||||
|
private Integer totalSearched;
|
||||||
|
|
||||||
|
@JsonProperty("matched_count")
|
||||||
|
private Integer matchedCount;
|
||||||
|
|
||||||
|
@JsonProperty("unmatched_count")
|
||||||
|
private Integer unmatchedCount;
|
||||||
|
|
||||||
|
private List<SearchResult> results;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class SearchResult {
|
||||||
|
// 원본 Vibe 정보
|
||||||
|
@JsonProperty("original_track_title")
|
||||||
|
private String originalTrackTitle;
|
||||||
|
|
||||||
|
@JsonProperty("original_artist_name")
|
||||||
|
private String originalArtistName;
|
||||||
|
|
||||||
|
// Spotify 매칭 결과
|
||||||
|
private boolean matched;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_track_uri")
|
||||||
|
private String spotifyTrackUri;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_track_id")
|
||||||
|
private String spotifyTrackId;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_track_name")
|
||||||
|
private String spotifyTrackName;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_artist_name")
|
||||||
|
private String spotifyArtistName;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_album_name")
|
||||||
|
private String spotifyAlbumName;
|
||||||
|
|
||||||
|
@JsonProperty("spotify_track_url")
|
||||||
|
private String spotifyTrackUrl;
|
||||||
|
|
||||||
|
@JsonProperty("album_image_url")
|
||||||
|
private String albumImageUrl;
|
||||||
|
|
||||||
|
@JsonProperty("preview_url")
|
||||||
|
private String previewUrl;
|
||||||
|
|
||||||
|
@JsonProperty("error_message")
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/main/java/com/domain/response/VibeTrackResponse.java
Normal file
87
src/main/java/com/domain/response/VibeTrackResponse.java
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package com.domain.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class VibeTrackResponse {
|
||||||
|
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@JsonProperty("total_count")
|
||||||
|
private Integer totalCount;
|
||||||
|
|
||||||
|
private List<Track> tracks;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class Track {
|
||||||
|
@JsonProperty("track_id")
|
||||||
|
private Long trackId;
|
||||||
|
|
||||||
|
@JsonProperty("track_title")
|
||||||
|
private String trackTitle;
|
||||||
|
|
||||||
|
@JsonProperty("play_time")
|
||||||
|
private String playTime;
|
||||||
|
|
||||||
|
private List<Artist> artists;
|
||||||
|
|
||||||
|
private Album album;
|
||||||
|
|
||||||
|
@JsonProperty("has_lyric")
|
||||||
|
private Boolean hasLyric;
|
||||||
|
|
||||||
|
@JsonProperty("is_streaming")
|
||||||
|
private Boolean isStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class Artist {
|
||||||
|
@JsonProperty("artist_id")
|
||||||
|
private Long artistId;
|
||||||
|
|
||||||
|
@JsonProperty("artist_name")
|
||||||
|
private String artistName;
|
||||||
|
|
||||||
|
@JsonProperty("image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class Album {
|
||||||
|
@JsonProperty("album_id")
|
||||||
|
private Long albumId;
|
||||||
|
|
||||||
|
@JsonProperty("album_title")
|
||||||
|
private String albumTitle;
|
||||||
|
|
||||||
|
@JsonProperty("release_date")
|
||||||
|
private String releaseDate;
|
||||||
|
|
||||||
|
@JsonProperty("image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
834
src/main/java/com/domain/service/SpotifyService.java
Normal file
834
src/main/java/com/domain/service/SpotifyService.java
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
package com.domain.service;
|
||||||
|
|
||||||
|
import com.domain.request.SpotifyAddTracksRequest;
|
||||||
|
import com.domain.request.VibeToSpotifySearchRequest;
|
||||||
|
import com.domain.response.SpotifyAddTracksResponse;
|
||||||
|
import com.domain.response.SpotifyPlaylistResponse;
|
||||||
|
import com.domain.response.VibeToSpotifySearchResponse;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
|
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||||
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.core5.http.ContentType;
|
||||||
|
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||||
|
import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SpotifyService {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final TranslationService translationService;
|
||||||
|
|
||||||
|
private static final String SPOTIFY_API_BASE_URL = "https://api.spotify.com/v1";
|
||||||
|
private static final String SPOTIFY_ME_PLAYLISTS_URL = SPOTIFY_API_BASE_URL + "/me/playlists?limit=50";
|
||||||
|
private static final String SPOTIFY_ME_URL = SPOTIFY_API_BASE_URL + "/me";
|
||||||
|
private static final String SPOTIFY_CREATE_PLAYLIST_URL = SPOTIFY_API_BASE_URL + "/users/%s/playlists";
|
||||||
|
private static final String SPOTIFY_ADD_TRACKS_URL = SPOTIFY_API_BASE_URL + "/playlists/%s/tracks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify Access Token을 사용하여 사용자의 플레이리스트를 조회합니다.
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @return 플레이리스트 목록 응답
|
||||||
|
*/
|
||||||
|
public SpotifyPlaylistResponse getUserPlaylists(String accessToken) {
|
||||||
|
try {
|
||||||
|
List<SpotifyPlaylistResponse.Playlist> playlists = fetchSpotifyPlaylists(accessToken);
|
||||||
|
|
||||||
|
return SpotifyPlaylistResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message("플레이리스트 조회 성공")
|
||||||
|
.totalCount(playlists.size())
|
||||||
|
.playlists(playlists)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Spotify 플레이리스트 조회 중 오류 발생", e);
|
||||||
|
return SpotifyPlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("플레이리스트 조회 중 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify API를 호출하여 플레이리스트 목록을 조회합니다.
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @return 플레이리스트 목록
|
||||||
|
*/
|
||||||
|
private List<SpotifyPlaylistResponse.Playlist> fetchSpotifyPlaylists(String accessToken) {
|
||||||
|
List<SpotifyPlaylistResponse.Playlist> playlists = new ArrayList<>();
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpGet getRequest = new HttpGet(SPOTIFY_ME_PLAYLISTS_URL);
|
||||||
|
getRequest.setHeader("Authorization", "Bearer " + accessToken);
|
||||||
|
getRequest.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.info("Spotify Playlist API 응답 코드: {}", statusCode);
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
playlists = parseSpotifyPlaylistResponse(responseBody);
|
||||||
|
log.info("파싱된 플레이리스트 개수: {}", playlists.size());
|
||||||
|
} else {
|
||||||
|
log.error("Spotify Playlist API 호출 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Spotify 플레이리스트 조회 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify Playlist API 응답을 파싱합니다.
|
||||||
|
*
|
||||||
|
* @param responseBody JSON 응답 본문
|
||||||
|
* @return 플레이리스트 목록
|
||||||
|
*/
|
||||||
|
private List<SpotifyPlaylistResponse.Playlist> parseSpotifyPlaylistResponse(String responseBody) {
|
||||||
|
List<SpotifyPlaylistResponse.Playlist> playlists = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(responseBody);
|
||||||
|
JsonNode itemsNode = root.path("items");
|
||||||
|
|
||||||
|
if (itemsNode.isArray()) {
|
||||||
|
for (JsonNode playlistNode : itemsNode) {
|
||||||
|
// 이미지 URL 추출 (첫 번째 이미지)
|
||||||
|
String imageUrl = null;
|
||||||
|
JsonNode imagesNode = playlistNode.path("images");
|
||||||
|
if (imagesNode.isArray() && imagesNode.size() > 0) {
|
||||||
|
imageUrl = imagesNode.get(0).path("url").asText(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
SpotifyPlaylistResponse.Playlist playlist = SpotifyPlaylistResponse.Playlist.builder()
|
||||||
|
.playlistId(playlistNode.path("id").asText())
|
||||||
|
.playlistName(playlistNode.path("name").asText())
|
||||||
|
.description(playlistNode.path("description").asText(null))
|
||||||
|
.trackCount(playlistNode.path("tracks").path("total").asInt(0))
|
||||||
|
.imageUrl(imageUrl)
|
||||||
|
.isPublic(playlistNode.path("public").asBoolean(false))
|
||||||
|
.ownerName(playlistNode.path("owner").path("display_name").asText(null))
|
||||||
|
.spotifyUrl(playlistNode.path("external_urls").path("spotify").asText(null))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
playlists.add(playlist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Spotify Playlist 응답 파싱 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다.
|
||||||
|
* Vibe 트랙 정보(가수, 제목)를 받아서 Spotify에서 자동으로 검색하여 추가합니다.
|
||||||
|
*
|
||||||
|
* @param request 트랙 추가 요청 (tracks: 가수, 제목 정보)
|
||||||
|
* @return 트랙 추가 응답
|
||||||
|
*/
|
||||||
|
public SpotifyAddTracksResponse addTracksToPlaylist(SpotifyAddTracksRequest request) {
|
||||||
|
try {
|
||||||
|
String playlistId = request.getPlaylistId();
|
||||||
|
boolean isNewPlaylist = false;
|
||||||
|
|
||||||
|
// 1. Vibe 트랙 정보로 Spotify에서 검색
|
||||||
|
log.info("Vibe 트랙 정보로 Spotify 검색 시작 - 트랙 개수: {}", request.getTracks().size());
|
||||||
|
|
||||||
|
List<String> matchedTrackUris = new ArrayList<>();
|
||||||
|
List<String> unmatchedTracks = new ArrayList<>();
|
||||||
|
int totalSearched = request.getTracks().size();
|
||||||
|
|
||||||
|
for (SpotifyAddTracksRequest.TrackInfo trackInfo : request.getTracks()) {
|
||||||
|
// 단일 트랙 검색
|
||||||
|
VibeToSpotifySearchRequest.TrackInfo searchTrackInfo = VibeToSpotifySearchRequest.TrackInfo.builder()
|
||||||
|
.trackTitle(trackInfo.getTrackTitle())
|
||||||
|
.artistName(trackInfo.getArtistName())
|
||||||
|
.albumTitle(trackInfo.getAlbumTitle())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
VibeToSpotifySearchResponse.SearchResult result = searchSingleTrack(
|
||||||
|
request.getAccessToken(),
|
||||||
|
searchTrackInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isMatched() && result.getSpotifyTrackUri() != null) {
|
||||||
|
matchedTrackUris.add(result.getSpotifyTrackUri());
|
||||||
|
} else {
|
||||||
|
String unmatchedInfo = String.format("%s - %s",
|
||||||
|
trackInfo.getArtistName(),
|
||||||
|
trackInfo.getTrackTitle());
|
||||||
|
unmatchedTracks.add(unmatchedInfo);
|
||||||
|
log.warn("매칭 실패: {}", unmatchedInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int matchedCount = matchedTrackUris.size();
|
||||||
|
int unmatchedCount = unmatchedTracks.size();
|
||||||
|
|
||||||
|
log.info("Spotify 검색 완료 - 전체: {}, 매칭: {}, 미매칭: {}",
|
||||||
|
totalSearched, matchedCount, unmatchedCount);
|
||||||
|
|
||||||
|
// 매칭된 트랙이 없으면 에러
|
||||||
|
if (matchedTrackUris.isEmpty()) {
|
||||||
|
return SpotifyAddTracksResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("Spotify에서 매칭되는 트랙을 찾을 수 없습니다.")
|
||||||
|
.totalSearched(totalSearched)
|
||||||
|
.matchedCount(0)
|
||||||
|
.unmatchedCount(unmatchedCount)
|
||||||
|
.unmatchedTracks(unmatchedTracks)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 플레이리스트 ID가 없으면 새로 생성
|
||||||
|
if (playlistId == null || playlistId.trim().isEmpty()) {
|
||||||
|
log.info("새 플레이리스트 생성 요청 - 이름: {}", request.getPlaylistName());
|
||||||
|
playlistId = createNewPlaylist(
|
||||||
|
request.getAccessToken(),
|
||||||
|
request.getPlaylistName(),
|
||||||
|
request.getPlaylistDescription(),
|
||||||
|
request.getIsPublic() != null ? request.getIsPublic() : false
|
||||||
|
);
|
||||||
|
isNewPlaylist = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 매칭된 트랙들을 플레이리스트에 추가
|
||||||
|
int tracksAdded = addTracksToExistingPlaylist(
|
||||||
|
request.getAccessToken(),
|
||||||
|
playlistId,
|
||||||
|
matchedTrackUris
|
||||||
|
);
|
||||||
|
|
||||||
|
// 플레이리스트 정보 조회
|
||||||
|
String playlistUrl = String.format("https://open.spotify.com/playlist/%s", playlistId);
|
||||||
|
|
||||||
|
return SpotifyAddTracksResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message(String.format("트랙 추가 완료 (매칭: %d, 미매칭: %d)", matchedCount, unmatchedCount))
|
||||||
|
.playlistId(playlistId)
|
||||||
|
.playlistName(request.getPlaylistName())
|
||||||
|
.playlistUrl(playlistUrl)
|
||||||
|
.tracksAdded(tracksAdded)
|
||||||
|
.isNewPlaylist(isNewPlaylist)
|
||||||
|
.totalSearched(totalSearched)
|
||||||
|
.matchedCount(matchedCount)
|
||||||
|
.unmatchedCount(unmatchedCount)
|
||||||
|
.unmatchedTracks(unmatchedTracks.isEmpty() ? null : unmatchedTracks)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Spotify 트랙 추가 중 오류 발생", e);
|
||||||
|
return SpotifyAddTracksResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("트랙 추가 중 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 플레이리스트를 생성합니다.
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @param name 플레이리스트 이름
|
||||||
|
* @param description 플레이리스트 설명
|
||||||
|
* @param isPublic 공개 여부
|
||||||
|
* @return 생성된 플레이리스트 ID
|
||||||
|
*/
|
||||||
|
private String createNewPlaylist(String accessToken, String name, String description, boolean isPublic) {
|
||||||
|
try {
|
||||||
|
// 1. 사용자 ID 조회
|
||||||
|
String userId = getUserId(accessToken);
|
||||||
|
if (userId == null) {
|
||||||
|
throw new RuntimeException("사용자 ID를 조회할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 플레이리스트 생성
|
||||||
|
String createPlaylistUrl = String.format(SPOTIFY_CREATE_PLAYLIST_URL, userId);
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpPost postRequest = new HttpPost(createPlaylistUrl);
|
||||||
|
postRequest.setHeader("Authorization", "Bearer " + accessToken);
|
||||||
|
postRequest.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Request body 생성
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("name", name != null ? name : "New Playlist");
|
||||||
|
requestBody.put("description", description != null ? description : "");
|
||||||
|
requestBody.put("public", isPublic);
|
||||||
|
|
||||||
|
String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||||
|
postRequest.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(postRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.info("Spotify Create Playlist API 응답 코드: {}", statusCode);
|
||||||
|
|
||||||
|
if (statusCode == 201) {
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(responseBody);
|
||||||
|
String playlistId = jsonNode.path("id").asText();
|
||||||
|
log.info("플레이리스트 생성 성공 - ID: {}", playlistId);
|
||||||
|
return playlistId;
|
||||||
|
} else {
|
||||||
|
log.error("플레이리스트 생성 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||||
|
throw new RuntimeException("플레이리스트 생성 실패: " + responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("플레이리스트 생성 중 오류 발생", e);
|
||||||
|
throw new RuntimeException("플레이리스트 생성 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID를 조회합니다.
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @return 사용자 ID
|
||||||
|
*/
|
||||||
|
private String getUserId(String accessToken) {
|
||||||
|
try {
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpGet getRequest = new HttpGet(SPOTIFY_ME_URL);
|
||||||
|
getRequest.setHeader("Authorization", "Bearer " + accessToken);
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(responseBody);
|
||||||
|
return jsonNode.path("id").asText();
|
||||||
|
} else {
|
||||||
|
log.error("사용자 정보 조회 실패. 상태 코드: {}", statusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("사용자 ID 조회 중 오류 발생", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 플레이리스트에 트랙을 추가합니다.
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @param playlistId 플레이리스트 ID
|
||||||
|
* @param trackUris 트랙 URI 목록
|
||||||
|
* @return 추가된 트랙 개수
|
||||||
|
*/
|
||||||
|
private int addTracksToExistingPlaylist(String accessToken, String playlistId, List<String> trackUris) {
|
||||||
|
try {
|
||||||
|
String addTracksUrl = String.format(SPOTIFY_ADD_TRACKS_URL, playlistId);
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpPost postRequest = new HttpPost(addTracksUrl);
|
||||||
|
postRequest.setHeader("Authorization", "Bearer " + accessToken);
|
||||||
|
postRequest.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Request body 생성
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("uris", trackUris);
|
||||||
|
|
||||||
|
String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||||
|
postRequest.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(postRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.info("Spotify Add Tracks API 응답 코드: {}", statusCode);
|
||||||
|
|
||||||
|
if (statusCode == 201) {
|
||||||
|
log.info("트랙 추가 성공 - {} 개", trackUris.size());
|
||||||
|
return trackUris.size();
|
||||||
|
} else {
|
||||||
|
log.error("트랙 추가 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||||
|
throw new RuntimeException("트랙 추가 실패: " + responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("트랙 추가 중 오류 발생", e);
|
||||||
|
throw new RuntimeException("트랙 추가 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe 트랙 정보를 기반으로 Spotify에서 검색하여 매칭합니다.
|
||||||
|
*
|
||||||
|
* @param request Vibe 트랙 정보 리스트
|
||||||
|
* @return 검색 결과 (Spotify track URI 포함)
|
||||||
|
*/
|
||||||
|
public VibeToSpotifySearchResponse searchTracksFromVibe(VibeToSpotifySearchRequest request) {
|
||||||
|
try {
|
||||||
|
List<VibeToSpotifySearchResponse.SearchResult> results = new ArrayList<>();
|
||||||
|
int matchedCount = 0;
|
||||||
|
|
||||||
|
for (VibeToSpotifySearchRequest.TrackInfo trackInfo : request.getTracks()) {
|
||||||
|
VibeToSpotifySearchResponse.SearchResult result = searchSingleTrack(
|
||||||
|
request.getAccessToken(),
|
||||||
|
trackInfo
|
||||||
|
);
|
||||||
|
results.add(result);
|
||||||
|
|
||||||
|
if (result.isMatched()) {
|
||||||
|
matchedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int unmatchedCount = results.size() - matchedCount;
|
||||||
|
|
||||||
|
return VibeToSpotifySearchResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message("검색 완료")
|
||||||
|
.totalSearched(results.size())
|
||||||
|
.matchedCount(matchedCount)
|
||||||
|
.unmatchedCount(unmatchedCount)
|
||||||
|
.results(results)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe to Spotify 검색 중 오류 발생", e);
|
||||||
|
return VibeToSpotifySearchResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("검색 중 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 트랙을 Spotify에서 검색합니다.
|
||||||
|
* 5단계 검색 전략:
|
||||||
|
* 1) track + artist (원본)
|
||||||
|
* 2) track + artist (괄호 안 영문) - artist의 괄호가 있는 경우
|
||||||
|
* 2.5) 괄호 안 track명으로 검색 - track의 괄호가 있는 경우
|
||||||
|
* 3) track + album
|
||||||
|
* 4) 영문 번역 후 track + artist
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @param trackInfo Vibe 트랙 정보
|
||||||
|
* @return 검색 결과
|
||||||
|
*/
|
||||||
|
private VibeToSpotifySearchResponse.SearchResult searchSingleTrack(
|
||||||
|
String accessToken,
|
||||||
|
VibeToSpotifySearchRequest.TrackInfo trackInfo) {
|
||||||
|
|
||||||
|
// 1단계: track + artist (원본)로 검색
|
||||||
|
log.debug("1단계 검색: track + artist (원본)");
|
||||||
|
VibeToSpotifySearchResponse.SearchResult result =
|
||||||
|
performSpotifySearch(accessToken,
|
||||||
|
trackInfo.getTrackTitle(),
|
||||||
|
trackInfo.getArtistName(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
result.setOriginalTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
result.setOriginalArtistName(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (result.isMatched()) {
|
||||||
|
log.info("매칭 성공 (1단계 - track+artist 원본): {} - {}",
|
||||||
|
trackInfo.getArtistName(), trackInfo.getTrackTitle());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: artist에 괄호가 있으면 괄호 안 영문으로 검색
|
||||||
|
String englishArtistInParentheses = extractEnglishFromParentheses(trackInfo.getArtistName());
|
||||||
|
if (englishArtistInParentheses != null) {
|
||||||
|
log.debug("2단계 검색: track + artist (괄호 안 영문: {})", englishArtistInParentheses);
|
||||||
|
|
||||||
|
VibeToSpotifySearchResponse.SearchResult parenthesesResult =
|
||||||
|
performSpotifySearch(accessToken,
|
||||||
|
trackInfo.getTrackTitle(),
|
||||||
|
englishArtistInParentheses,
|
||||||
|
null);
|
||||||
|
|
||||||
|
parenthesesResult.setOriginalTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
parenthesesResult.setOriginalArtistName(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (parenthesesResult.isMatched()) {
|
||||||
|
log.info("매칭 성공 (2단계 - track+artist 괄호 영문): {} ({}) - {}",
|
||||||
|
trackInfo.getArtistName(), englishArtistInParentheses, trackInfo.getTrackTitle());
|
||||||
|
return parenthesesResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5단계: track에 괄호가 있으면 괄호 안 영문 track명으로 검색
|
||||||
|
String englishTrackInParentheses = extractEnglishTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
if (englishTrackInParentheses != null) {
|
||||||
|
log.debug("2.5단계 검색: 괄호 안 track명 ({})", englishTrackInParentheses);
|
||||||
|
|
||||||
|
// 2.5-a: 괄호 안 track명 + artist (괄호 제거)
|
||||||
|
String artistWithoutParentheses = removeParentheses(trackInfo.getArtistName());
|
||||||
|
VibeToSpotifySearchResponse.SearchResult trackParenthesesResult1 =
|
||||||
|
performSpotifySearch(accessToken,
|
||||||
|
englishTrackInParentheses,
|
||||||
|
artistWithoutParentheses,
|
||||||
|
null);
|
||||||
|
|
||||||
|
trackParenthesesResult1.setOriginalTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
trackParenthesesResult1.setOriginalArtistName(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (trackParenthesesResult1.isMatched()) {
|
||||||
|
log.info("매칭 성공 (2.5-a - 괄호 안 track+artist 괄호제거): {} -> {} - {} ({})",
|
||||||
|
trackInfo.getArtistName(), artistWithoutParentheses,
|
||||||
|
trackInfo.getTrackTitle(), englishTrackInParentheses);
|
||||||
|
return trackParenthesesResult1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5-b: 괄호 안 track명 + 괄호 안 artist (artist 괄호가 있는 경우만)
|
||||||
|
if (englishArtistInParentheses != null) {
|
||||||
|
VibeToSpotifySearchResponse.SearchResult trackParenthesesResult2 =
|
||||||
|
performSpotifySearch(accessToken,
|
||||||
|
englishTrackInParentheses,
|
||||||
|
englishArtistInParentheses,
|
||||||
|
null);
|
||||||
|
|
||||||
|
trackParenthesesResult2.setOriginalTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
trackParenthesesResult2.setOriginalArtistName(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (trackParenthesesResult2.isMatched()) {
|
||||||
|
log.info("매칭 성공 (2.5-b - 괄호 안 track+괄호 안 artist): {} ({}) - {} ({})",
|
||||||
|
trackInfo.getArtistName(), englishArtistInParentheses,
|
||||||
|
trackInfo.getTrackTitle(), englishTrackInParentheses);
|
||||||
|
return trackParenthesesResult2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3단계: track + album으로 검색 (앨범 정보가 있는 경우에만)
|
||||||
|
if (trackInfo.getAlbumTitle() != null && !trackInfo.getAlbumTitle().trim().isEmpty()) {
|
||||||
|
log.debug("3단계 검색: track + album");
|
||||||
|
|
||||||
|
VibeToSpotifySearchResponse.SearchResult albumResult =
|
||||||
|
performSpotifySearch(accessToken,
|
||||||
|
trackInfo.getTrackTitle(),
|
||||||
|
null, // artist 제외
|
||||||
|
trackInfo.getAlbumTitle());
|
||||||
|
|
||||||
|
albumResult.setOriginalTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
albumResult.setOriginalArtistName(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (albumResult.isMatched()) {
|
||||||
|
log.info("매칭 성공 (3단계 - track+album): {} - {} (앨범: {})",
|
||||||
|
trackInfo.getArtistName(), trackInfo.getTrackTitle(), trackInfo.getAlbumTitle());
|
||||||
|
return albumResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4단계: 한글이 포함되어 있으면 영문 번역 후 재시도
|
||||||
|
if (translationService.containsKorean(trackInfo.getTrackTitle())) {
|
||||||
|
log.info("4단계 검색: 한글 제목 감지, 영문 번역 시도 - {}", trackInfo.getTrackTitle());
|
||||||
|
|
||||||
|
String translatedTitle = translationService.translateKoreanToEnglish(trackInfo.getTrackTitle());
|
||||||
|
|
||||||
|
// Artist는 괄호 영문이 있으면 사용, 없으면 번역
|
||||||
|
String artistForTranslatedSearch = englishArtistInParentheses != null ?
|
||||||
|
englishArtistInParentheses :
|
||||||
|
translationService.translateKoreanToEnglish(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (translatedTitle != null && !translatedTitle.isEmpty()) {
|
||||||
|
log.info("번역 완료: {} -> {}", trackInfo.getTrackTitle(), translatedTitle);
|
||||||
|
|
||||||
|
// 번역된 제목으로 재검색 (track + artist)
|
||||||
|
VibeToSpotifySearchResponse.SearchResult translatedResult =
|
||||||
|
performSpotifySearch(accessToken, translatedTitle, artistForTranslatedSearch, null);
|
||||||
|
|
||||||
|
translatedResult.setOriginalTrackTitle(trackInfo.getTrackTitle());
|
||||||
|
translatedResult.setOriginalArtistName(trackInfo.getArtistName());
|
||||||
|
|
||||||
|
if (translatedResult.isMatched()) {
|
||||||
|
log.info("매칭 성공 (4단계 - 영문 번역): {} - {} -> {} - {}",
|
||||||
|
trackInfo.getArtistName(), trackInfo.getTrackTitle(),
|
||||||
|
artistForTranslatedSearch, translatedTitle);
|
||||||
|
return translatedResult;
|
||||||
|
} else {
|
||||||
|
log.warn("4단계 실패: 영문 번역 후에도 매칭 실패 - {} - {}",
|
||||||
|
artistForTranslatedSearch, translatedTitle);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn("제목 번역 실패: {}", trackInfo.getTrackTitle());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 단계 실패
|
||||||
|
log.warn("최종 매칭 실패 (모든 단계 실패): {} - {}", trackInfo.getArtistName(), trackInfo.getTrackTitle());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 괄호 안의 영문을 추출합니다.
|
||||||
|
* 예: "아이유(IU)" -> "IU"
|
||||||
|
* "럼블피쉬 (Rumblefish)" -> "Rumblefish"
|
||||||
|
*
|
||||||
|
* @param text 괄호가 포함된 텍스트
|
||||||
|
* @return 괄호 안의 영문 (없으면 null)
|
||||||
|
*/
|
||||||
|
private String extractEnglishFromParentheses(String text) {
|
||||||
|
if (text == null || text.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 괄호 안 내용 추출: ( ) 또는 ( )
|
||||||
|
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\(([^)]+)\\)|\\(([^)]+)\\)");
|
||||||
|
java.util.regex.Matcher matcher = pattern.matcher(text);
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
String inParentheses = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
|
||||||
|
if (inParentheses != null) {
|
||||||
|
inParentheses = inParentheses.trim();
|
||||||
|
|
||||||
|
// 영문/숫자로만 구성되어 있는지 확인 (공백, 하이픈, 점 허용)
|
||||||
|
if (inParentheses.matches("[a-zA-Z0-9\\s\\-\\.]+")) {
|
||||||
|
log.debug("괄호 안 영문 추출: {} -> {}", text, inParentheses);
|
||||||
|
return inParentheses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* track 제목의 괄호 안에서 영문 track명을 추출합니다.
|
||||||
|
* "Feat.", "Prod.", "From"으로 시작하거나 "Ver.", "삽입곡"이 포함되는 경우는 제외합니다.
|
||||||
|
* 예: "사랑 (Love)" -> "Love"
|
||||||
|
* "사랑 (Feat. IU)" -> null (Feat.로 시작하므로 제외)
|
||||||
|
* "사랑 (리믹스 Ver.)" -> null (Ver. 포함하므로 제외)
|
||||||
|
*
|
||||||
|
* @param trackTitle track 제목
|
||||||
|
* @return 괄호 안의 영문 track명 (조건에 맞지 않으면 null)
|
||||||
|
*/
|
||||||
|
private String extractEnglishTrackTitle(String trackTitle) {
|
||||||
|
if (trackTitle == null || trackTitle.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 괄호 안 내용 추출
|
||||||
|
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\(([^)]+)\\)|\\(([^)]+)\\)");
|
||||||
|
java.util.regex.Matcher matcher = pattern.matcher(trackTitle);
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
String inParentheses = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
|
||||||
|
if (inParentheses != null) {
|
||||||
|
inParentheses = inParentheses.trim();
|
||||||
|
|
||||||
|
// 제외 조건 체크
|
||||||
|
String upperInParentheses = inParentheses.toUpperCase();
|
||||||
|
if (upperInParentheses.startsWith("FEAT.") ||
|
||||||
|
upperInParentheses.startsWith("PROD.") ||
|
||||||
|
upperInParentheses.startsWith("FROM") ||
|
||||||
|
inParentheses.contains("Ver.") ||
|
||||||
|
inParentheses.contains("VER.") ||
|
||||||
|
inParentheses.contains("ver.") ||
|
||||||
|
inParentheses.contains("삽입곡")) {
|
||||||
|
log.debug("track 괄호 내용이 제외 조건에 해당: {}", inParentheses);
|
||||||
|
continue; // 다음 괄호 확인
|
||||||
|
}
|
||||||
|
|
||||||
|
// 영문/숫자로만 구성되어 있는지 확인 (공백, 하이픈, 점, 쉼표, 어포스트로피 허용)
|
||||||
|
if (inParentheses.matches("[a-zA-Z0-9\\s\\-\\.,'\\\\']+")) {
|
||||||
|
log.debug("track 괄호 안 영문 추출: {} -> {}", trackTitle, inParentheses);
|
||||||
|
return inParentheses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트에서 괄호 부분을 제거합니다.
|
||||||
|
* 예: "아이유(IU)" -> "아이유"
|
||||||
|
* "럼블피쉬 (Rumblefish)" -> "럼블피쉬"
|
||||||
|
*
|
||||||
|
* @param text 원본 텍스트
|
||||||
|
* @return 괄호가 제거된 텍스트
|
||||||
|
*/
|
||||||
|
private String removeParentheses(String text) {
|
||||||
|
if (text == null || text.trim().isEmpty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 괄호 및 괄호 안 내용 제거: ( ) 또는 ( )
|
||||||
|
String result = text.replaceAll("\\(([^)]+)\\)", "")
|
||||||
|
.replaceAll("\\(([^)]+)\\)", "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
log.debug("괄호 제거: {} -> {}", text, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify Search API를 호출하여 트랙을 검색합니다.
|
||||||
|
*
|
||||||
|
* @param accessToken Spotify Access Token
|
||||||
|
* @param trackTitle 트랙 제목
|
||||||
|
* @param artistName 아티스트 이름
|
||||||
|
* @param albumTitle 앨범 제목 (선택사항)
|
||||||
|
* @return 검색 결과
|
||||||
|
*/
|
||||||
|
private VibeToSpotifySearchResponse.SearchResult performSpotifySearch(
|
||||||
|
String accessToken,
|
||||||
|
String trackTitle,
|
||||||
|
String artistName,
|
||||||
|
String albumTitle) {
|
||||||
|
|
||||||
|
VibeToSpotifySearchResponse.SearchResult.SearchResultBuilder resultBuilder =
|
||||||
|
VibeToSpotifySearchResponse.SearchResult.builder()
|
||||||
|
.matched(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Spotify Search API URL 생성
|
||||||
|
StringBuilder queryBuilder = new StringBuilder();
|
||||||
|
queryBuilder.append("track:").append(trackTitle);
|
||||||
|
|
||||||
|
if (artistName != null && !artistName.trim().isEmpty()) {
|
||||||
|
queryBuilder.append(" artist:").append(artistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumTitle != null && !albumTitle.trim().isEmpty()) {
|
||||||
|
queryBuilder.append(" album:").append(albumTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
String query = queryBuilder.toString();
|
||||||
|
log.debug("검색 쿼리: {}", query);
|
||||||
|
|
||||||
|
String searchUrl = SPOTIFY_API_BASE_URL + "/search?q=" +
|
||||||
|
java.net.URLEncoder.encode(query, StandardCharsets.UTF_8) +
|
||||||
|
"&type=track&limit=1";
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpGet getRequest = new HttpGet(searchUrl);
|
||||||
|
getRequest.setHeader("Authorization", "Bearer " + accessToken);
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
JsonNode root = objectMapper.readTree(responseBody);
|
||||||
|
JsonNode tracksNode = root.path("tracks").path("items");
|
||||||
|
|
||||||
|
if (tracksNode.isArray() && tracksNode.size() > 0) {
|
||||||
|
JsonNode trackNode = tracksNode.get(0);
|
||||||
|
|
||||||
|
// 아티스트 이름 추출 (첫 번째 아티스트)
|
||||||
|
String spotifyArtistName = null;
|
||||||
|
JsonNode artistsNode = trackNode.path("artists");
|
||||||
|
if (artistsNode.isArray() && artistsNode.size() > 0) {
|
||||||
|
spotifyArtistName = artistsNode.get(0).path("name").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앨범 이미지 추출
|
||||||
|
String albumImageUrl = null;
|
||||||
|
JsonNode imagesNode = trackNode.path("album").path("images");
|
||||||
|
if (imagesNode.isArray() && imagesNode.size() > 0) {
|
||||||
|
albumImageUrl = imagesNode.get(0).path("url").asText(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBuilder
|
||||||
|
.matched(true)
|
||||||
|
.spotifyTrackId(trackNode.path("id").asText())
|
||||||
|
.spotifyTrackUri(trackNode.path("uri").asText())
|
||||||
|
.spotifyTrackName(trackNode.path("name").asText())
|
||||||
|
.spotifyArtistName(spotifyArtistName)
|
||||||
|
.spotifyAlbumName(trackNode.path("album").path("name").asText(null))
|
||||||
|
.spotifyTrackUrl(trackNode.path("external_urls").path("spotify").asText(null))
|
||||||
|
.albumImageUrl(albumImageUrl)
|
||||||
|
.previewUrl(trackNode.path("preview_url").asText(null));
|
||||||
|
|
||||||
|
log.debug("Spotify 검색 성공: {}", query);
|
||||||
|
} else {
|
||||||
|
resultBuilder.errorMessage("Spotify에서 매칭되는 트랙을 찾을 수 없습니다.");
|
||||||
|
log.debug("Spotify 검색 결과 없음: {}", query);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultBuilder.errorMessage("Spotify API 호출 실패: " + statusCode);
|
||||||
|
log.error("Spotify Search API 호출 실패. 상태 코드: {}", statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
resultBuilder.errorMessage("검색 중 오류: " + e.getMessage());
|
||||||
|
log.error("Spotify 검색 중 오류 발생: track={}, artist={}, album={}",
|
||||||
|
trackTitle, artistName, albumTitle, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultBuilder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/main/java/com/domain/service/TranslationService.java
Normal file
147
src/main/java/com/domain/service/TranslationService.java
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package com.domain.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||||
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.core5.http.ContentType;
|
||||||
|
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||||
|
import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TranslationService {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${papago.client-id:}")
|
||||||
|
private String papagoClientId;
|
||||||
|
|
||||||
|
@Value("${papago.client-secret:}")
|
||||||
|
private String papagoClientSecret;
|
||||||
|
|
||||||
|
// Naver Cloud Platform API (신버전)
|
||||||
|
private static final String PAPAGO_API_URL = "https://papago.apigw.ntruss.com/nmt/v1/translation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 한글 텍스트를 영문으로 번역합니다 (Papago API 사용).
|
||||||
|
* 괄호 내용(Feat, Ver 등)은 제거하고 순수 제목만 번역합니다.
|
||||||
|
*
|
||||||
|
* @param koreanText 번역할 한글 텍스트
|
||||||
|
* @return 영문 번역 결과 (실패 시 null)
|
||||||
|
*/
|
||||||
|
public String translateKoreanToEnglish(String koreanText) {
|
||||||
|
if (koreanText == null || koreanText.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (papagoClientId == null || papagoClientId.trim().isEmpty() ||
|
||||||
|
papagoClientSecret == null || papagoClientSecret.trim().isEmpty()) {
|
||||||
|
log.warn("Papago API 인증 정보가 설정되지 않았습니다.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 괄호 내용 제거 (Feat, Ver, Remix 등)
|
||||||
|
String cleanedText = koreanText
|
||||||
|
.replaceAll("\\([^)]*\\)", "") // 소괄호와 내용 제거
|
||||||
|
.replaceAll("\\[[^]]*\\]", "") // 대괄호와 내용 제거
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (cleanedText.isEmpty()) {
|
||||||
|
log.warn("괄호 제거 후 빈 문자열: {}", koreanText);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("번역 전처리: {} -> {}", koreanText, cleanedText);
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpPost postRequest = new HttpPost(PAPAGO_API_URL);
|
||||||
|
postRequest.setHeader("X-NCP-APIGW-API-KEY-ID", papagoClientId);
|
||||||
|
postRequest.setHeader("X-NCP-APIGW-API-KEY", papagoClientSecret);
|
||||||
|
postRequest.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Request body 생성 (JSON 형식)
|
||||||
|
String requestBody = String.format("{\"source\":\"ko\",\"target\":\"en\",\"text\":\"%s\"}",
|
||||||
|
cleanedText.replace("\"", "\\\""));
|
||||||
|
postRequest.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
log.debug("Papago API 요청 - URL: {}, Client-ID: {}, Body: {}",
|
||||||
|
PAPAGO_API_URL, papagoClientId, requestBody);
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(postRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.debug("Papago API 응답 - 상태: {}, 응답: {}", statusCode, responseBody);
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
JsonNode root = objectMapper.readTree(responseBody);
|
||||||
|
|
||||||
|
// Naver Cloud Platform API 응답 형식: message.result.translatedText
|
||||||
|
String translatedText = root.path("message")
|
||||||
|
.path("result")
|
||||||
|
.path("translatedText")
|
||||||
|
.asText();
|
||||||
|
|
||||||
|
if (translatedText != null && !translatedText.isEmpty()) {
|
||||||
|
log.info("Papago 번역 성공: {} -> {}", cleanedText, translatedText);
|
||||||
|
return translatedText;
|
||||||
|
} else {
|
||||||
|
log.warn("Papago 번역 결과가 비어있습니다. 응답: {}", responseBody);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("Papago API 호출 실패. 상태 코드: {}, 응답: {}",
|
||||||
|
statusCode, responseBody);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Papago 번역 중 오류 발생: {}", koreanText, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트에 한글이 포함되어 있는지 확인합니다.
|
||||||
|
*
|
||||||
|
* @param text 검사할 텍스트
|
||||||
|
* @return 한글 포함 여부
|
||||||
|
*/
|
||||||
|
public boolean containsKorean(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (char c : text.toCharArray()) {
|
||||||
|
if (Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_SYLLABLES ||
|
||||||
|
Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO ||
|
||||||
|
Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_JAMO) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/main/java/com/domain/service/VibeCookieService.java
Normal file
70
src/main/java/com/domain/service/VibeCookieService.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package com.domain.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VibeCookieService {
|
||||||
|
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
private static final String VIBE_COOKIE_PREFIX = "vibe:cookie:";
|
||||||
|
private static final long COOKIE_EXPIRE_HOURS = 24; // 24시간
|
||||||
|
|
||||||
|
/**
|
||||||
|
* userId를 키로 Cookie를 Redis에 저장합니다.
|
||||||
|
*
|
||||||
|
* @param userId 사용자 아이디
|
||||||
|
* @param cookies 저장할 Cookie 맵
|
||||||
|
*/
|
||||||
|
public void saveCookieByUserId(String userId, Map<String, String> cookies) {
|
||||||
|
String key = VIBE_COOKIE_PREFIX + userId;
|
||||||
|
|
||||||
|
redisTemplate.opsForHash().putAll(key, cookies);
|
||||||
|
redisTemplate.expire(key, COOKIE_EXPIRE_HOURS, TimeUnit.HOURS);
|
||||||
|
|
||||||
|
log.info("Vibe Cookie 저장 완료 - userId: {}, 유효기간: {}시간", userId, COOKIE_EXPIRE_HOURS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* userId로 Cookie를 조회합니다.
|
||||||
|
*
|
||||||
|
* @param userId 사용자 아이디
|
||||||
|
* @return Cookie 맵 (없으면 null)
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, String> getCookieByUserId(String userId) {
|
||||||
|
String key = VIBE_COOKIE_PREFIX + userId;
|
||||||
|
|
||||||
|
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
log.info("Redis에 저장된 Vibe Cookie 없음 - userId: {}", userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTL 갱신
|
||||||
|
redisTemplate.expire(key, COOKIE_EXPIRE_HOURS, TimeUnit.HOURS);
|
||||||
|
log.debug("Vibe Cookie 조회 및 TTL 갱신 - userId: {}", userId);
|
||||||
|
|
||||||
|
return (Map<String, String>) (Map<?, ?>) entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* userId의 Cookie를 삭제합니다.
|
||||||
|
*
|
||||||
|
* @param userId 사용자 아이디
|
||||||
|
*/
|
||||||
|
public void deleteCookieByUserId(String userId) {
|
||||||
|
String key = VIBE_COOKIE_PREFIX + userId;
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
log.info("Vibe Cookie 삭제 완료 - userId: {}", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
517
src/main/java/com/domain/service/VibeService.java
Normal file
517
src/main/java/com/domain/service/VibeService.java
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
package com.domain.service;
|
||||||
|
|
||||||
|
import com.domain.request.VibeLoginRequest;
|
||||||
|
import com.domain.response.VibePlaylistResponse;
|
||||||
|
import com.domain.response.VibeTrackResponse;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.microsoft.playwright.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VibeService {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final VibeCookieService vibeCookieService;
|
||||||
|
private final com.global.util.EncryptionUtil encryptionUtil;
|
||||||
|
|
||||||
|
private static final String NAVER_LOGIN_URL = "https://nid.naver.com/nidlogin.login";
|
||||||
|
private static final String VIBE_PLAYLIST_API_URL = "https://apis.naver.com/vibeWeb/musicapiweb/vibe/v2/library/PLAYLIST/libraries?start=1&display=50";
|
||||||
|
private static final String VIBE_TRACK_API_URL = "https://apis.naver.com/vibeWeb/musicapiweb/myMusic/myAlbum/%s/tracks?start=1&display=500";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네이버 로그인을 통해 Vibe 플레이리스트를 조회합니다.
|
||||||
|
* Redis에 저장된 쿠키를 우선 사용하고, 만료된 경우에만 재로그인합니다.
|
||||||
|
*
|
||||||
|
* @param request 네이버 아이디/비밀번호 (암호화됨)
|
||||||
|
* @return Vibe 플레이리스트 응답 (sessionId 포함)
|
||||||
|
*/
|
||||||
|
public VibePlaylistResponse getVibePlaylistsWithLogin(VibeLoginRequest request) {
|
||||||
|
try {
|
||||||
|
// 0. 아이디/비밀번호 복호화
|
||||||
|
String userId = encryptionUtil.decrypt(request.getId());
|
||||||
|
String password = encryptionUtil.decrypt(request.getPassword());
|
||||||
|
|
||||||
|
log.info("Vibe 플레이리스트 조회 요청 - userId: {}", userId);
|
||||||
|
|
||||||
|
// 1. Redis에서 기존 쿠키 조회
|
||||||
|
Map<String, String> cookies = vibeCookieService.getCookieByUserId(userId);
|
||||||
|
|
||||||
|
// 2. 쿠키가 있으면 유효성 검사
|
||||||
|
if (cookies != null && !cookies.isEmpty()) {
|
||||||
|
log.info("Redis에서 쿠키 발견 - 유효성 검사 수행");
|
||||||
|
|
||||||
|
if (validateVibeCookie(cookies)) {
|
||||||
|
log.info("쿠키 유효 - Redis 쿠키 사용");
|
||||||
|
} else {
|
||||||
|
log.warn("쿠키 만료됨 - 재로그인 필요");
|
||||||
|
cookies = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 쿠키가 없거나 만료된 경우 재로그인
|
||||||
|
if (cookies == null || cookies.isEmpty()) {
|
||||||
|
log.info("재로그인 시작 - userId: {}", userId);
|
||||||
|
cookies = loginToNaver(userId, password);
|
||||||
|
|
||||||
|
if (cookies.isEmpty()) {
|
||||||
|
return VibePlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("네이버 로그인에 실패했습니다.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 로그인 성공 - Redis에 쿠키 저장
|
||||||
|
vibeCookieService.saveCookieByUserId(userId, cookies);
|
||||||
|
log.info("Redis에 쿠키 저장 완료 - userId: {}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 쿠키를 사용하여 Vibe 플레이리스트 조회
|
||||||
|
List<VibePlaylistResponse.Playlist> playlists = fetchVibePlaylists(cookies);
|
||||||
|
|
||||||
|
// 6. userId를 암호화하여 sessionId로 반환
|
||||||
|
String sessionId = encryptionUtil.encrypt(userId);
|
||||||
|
log.info("세션 ID 생성 완료 - userId: {}", userId);
|
||||||
|
|
||||||
|
return VibePlaylistResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message("플레이리스트 조회 성공")
|
||||||
|
.sessionId(sessionId)
|
||||||
|
.playlists(playlists)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 플레이리스트 조회 중 오류 발생", e);
|
||||||
|
return VibePlaylistResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("플레이리스트 조회 중 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright를 사용하여 네이버 로그인을 수행하고 쿠키를 반환합니다.
|
||||||
|
*
|
||||||
|
* @param naverId 네이버 아이디
|
||||||
|
* @param naverPassword 네이버 비밀번호
|
||||||
|
* @return 쿠키 맵 (name -> value)
|
||||||
|
*/
|
||||||
|
private Map<String, String> loginToNaver(String naverId, String naverPassword) {
|
||||||
|
Map<String, String> cookieMap = new HashMap<>();
|
||||||
|
|
||||||
|
try (Playwright playwright = Playwright.create()) {
|
||||||
|
Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
|
||||||
|
.setHeadless(true) // 브라우저를 백그라운드에서 실행
|
||||||
|
.setTimeout(60000)); // 60초 타임아웃
|
||||||
|
|
||||||
|
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
|
||||||
|
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
.setViewportSize(1920, 1080));
|
||||||
|
|
||||||
|
Page page = context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("네이버 로그인 페이지 접속");
|
||||||
|
page.navigate(NAVER_LOGIN_URL);
|
||||||
|
|
||||||
|
// 아이디 입력
|
||||||
|
page.fill("input#id", naverId);
|
||||||
|
log.debug("네이버 아이디 입력 완료");
|
||||||
|
|
||||||
|
// 비밀번호 입력
|
||||||
|
page.fill("input#pw", naverPassword);
|
||||||
|
log.debug("네이버 비밀번호 입력 완료");
|
||||||
|
|
||||||
|
// 로그인 버튼 클릭
|
||||||
|
page.click("button[type='submit']");
|
||||||
|
log.info("로그인 버튼 클릭");
|
||||||
|
|
||||||
|
// 로그인 완료 대기 (URL 변경 또는 특정 요소 확인)
|
||||||
|
try {
|
||||||
|
page.waitForURL("https://www.naver.com/**", new Page.WaitForURLOptions().setTimeout(10000));
|
||||||
|
log.info("네이버 로그인 성공");
|
||||||
|
} catch (TimeoutError e) {
|
||||||
|
// URL이 변경되지 않으면 로그인 실패 가능성
|
||||||
|
String currentUrl = page.url();
|
||||||
|
log.warn("로그인 후 URL: {}", currentUrl);
|
||||||
|
|
||||||
|
if (currentUrl.contains("nidlogin")) {
|
||||||
|
log.error("네이버 로그인 실패 - CAPTCHA 또는 인증 오류 가능성");
|
||||||
|
return cookieMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿠키 추출
|
||||||
|
List<com.microsoft.playwright.options.Cookie> cookies = context.cookies();
|
||||||
|
for (com.microsoft.playwright.options.Cookie cookie : cookies) {
|
||||||
|
cookieMap.put(cookie.name, cookie.value);
|
||||||
|
log.debug("쿠키 획득: {} = {}", cookie.name, cookie.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("총 {} 개의 쿠키 획득", cookieMap.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("네이버 로그인 중 오류 발생", e);
|
||||||
|
} finally {
|
||||||
|
page.close();
|
||||||
|
context.close();
|
||||||
|
browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Playwright 초기화 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookieMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿠키의 유효성을 검사합니다.
|
||||||
|
* Vibe API를 호출하여 401/403 응답이 나오지 않으면 유효한 것으로 판단합니다.
|
||||||
|
*
|
||||||
|
* @param cookies 검사할 쿠키 맵
|
||||||
|
* @return 유효하면 true, 만료되었으면 false
|
||||||
|
*/
|
||||||
|
private boolean validateVibeCookie(Map<String, String> cookies) {
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpGet getRequest = new HttpGet(VIBE_PLAYLIST_API_URL);
|
||||||
|
|
||||||
|
// 헤더 설정
|
||||||
|
getRequest.setHeader("Accept", "application/json");
|
||||||
|
getRequest.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// 쿠키 설정
|
||||||
|
StringBuilder cookieHeader = new StringBuilder();
|
||||||
|
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||||
|
if (cookieHeader.length() > 0) {
|
||||||
|
cookieHeader.append("; ");
|
||||||
|
}
|
||||||
|
cookieHeader.append(entry.getKey()).append("=").append(entry.getValue());
|
||||||
|
}
|
||||||
|
getRequest.setHeader("Cookie", cookieHeader.toString());
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
|
||||||
|
// 401 Unauthorized 또는 403 Forbidden이면 쿠키 만료
|
||||||
|
if (statusCode == 401 || statusCode == 403) {
|
||||||
|
log.warn("쿠키 만료 감지 - 상태 코드: {}", statusCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 OK 또는 기타 성공 응답이면 유효
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
log.debug("쿠키 유효성 검사 성공 - 상태 코드: {}", statusCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 에러는 유효하지 않은 것으로 처리
|
||||||
|
log.warn("쿠키 유효성 검사 실패 - 상태 코드: {}", statusCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("쿠키 유효성 검사 중 오류 발생", e);
|
||||||
|
return false; // 오류 발생 시 재로그인 유도
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe API를 호출하여 플레이리스트를 조회합니다.
|
||||||
|
*
|
||||||
|
* @param cookies 네이버 로그인 쿠키
|
||||||
|
* @return 플레이리스트 목록
|
||||||
|
*/
|
||||||
|
private List<VibePlaylistResponse.Playlist> fetchVibePlaylists(Map<String, String> cookies) {
|
||||||
|
List<VibePlaylistResponse.Playlist> playlists = new ArrayList<>();
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpGet getRequest = new HttpGet(VIBE_PLAYLIST_API_URL);
|
||||||
|
getRequest.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||||
|
getRequest.setHeader("Referer", "https://vibe.naver.com/");
|
||||||
|
|
||||||
|
// 쿠키 헤더 추가
|
||||||
|
StringBuilder cookieHeader = new StringBuilder();
|
||||||
|
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||||
|
if (cookieHeader.length() > 0) {
|
||||||
|
cookieHeader.append("; ");
|
||||||
|
}
|
||||||
|
cookieHeader.append(entry.getKey()).append("=").append(entry.getValue());
|
||||||
|
}
|
||||||
|
getRequest.setHeader("Cookie", cookieHeader.toString());
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.info("Vibe API 응답 코드: {}", statusCode);
|
||||||
|
log.info("Vibe API 응답 본문 길이: {} bytes", responseBody.length());
|
||||||
|
log.info("Vibe API 응답 본문 (처음 500자): {}", responseBody.length() > 500 ? responseBody.substring(0, 500) : responseBody);
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
// JSON 응답 파싱
|
||||||
|
playlists = parseVibePlaylistResponse(responseBody);
|
||||||
|
log.info("파싱된 플레이리스트 개수: {}", playlists.size());
|
||||||
|
} else {
|
||||||
|
log.error("Vibe API 호출 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 플레이리스트 조회 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe API 응답을 파싱합니다.
|
||||||
|
*
|
||||||
|
* @param responseBody JSON 응답 본문
|
||||||
|
* @return 플레이리스트 목록
|
||||||
|
*/
|
||||||
|
private List<VibePlaylistResponse.Playlist> parseVibePlaylistResponse(String responseBody) {
|
||||||
|
List<VibePlaylistResponse.Playlist> playlists = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(responseBody);
|
||||||
|
JsonNode resultNode = root.path("response").path("result");
|
||||||
|
|
||||||
|
if (resultNode.isObject()) {
|
||||||
|
JsonNode librariesNode = resultNode.path("libraries");
|
||||||
|
|
||||||
|
if (librariesNode.isArray()) {
|
||||||
|
for (JsonNode libraryNode : librariesNode) {
|
||||||
|
VibePlaylistResponse.Playlist playlist = VibePlaylistResponse.Playlist.builder()
|
||||||
|
.playlistId(libraryNode.path("id").asText())
|
||||||
|
.playlistName(libraryNode.path("title").asText())
|
||||||
|
.description(libraryNode.path("subtitle").asText(null))
|
||||||
|
.trackCount(libraryNode.path("trackCount").asInt(0))
|
||||||
|
.imageUrl(libraryNode.path("imageUrl").asText(null))
|
||||||
|
.createdAt(null) // API 응답에 없음
|
||||||
|
.updatedAt(null) // API 응답에 없음
|
||||||
|
.build();
|
||||||
|
|
||||||
|
playlists.add(playlist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 응답 파싱 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 ID(암호화된 userId)를 사용하여 플레이리스트의 트랙 목록을 조회합니다.
|
||||||
|
*
|
||||||
|
* @param sessionId 세션 ID (암호화된 userId)
|
||||||
|
* @param playlistId 플레이리스트 ID
|
||||||
|
* @return Track 목록 응답
|
||||||
|
*/
|
||||||
|
public VibeTrackResponse getVibeTracks(String sessionId, String playlistId) {
|
||||||
|
try {
|
||||||
|
// 1. sessionId(암호화된 userId)를 복호화
|
||||||
|
String userId;
|
||||||
|
try {
|
||||||
|
userId = encryptionUtil.decrypt(sessionId);
|
||||||
|
log.info("세션 ID 복호화 성공 - userId: {}", userId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("세션 ID 복호화 실패", e);
|
||||||
|
return VibeTrackResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("유효하지 않은 세션 ID입니다.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Redis에서 userId로 쿠키 조회
|
||||||
|
Map<String, String> cookies = vibeCookieService.getCookieByUserId(userId);
|
||||||
|
|
||||||
|
if (cookies == null || cookies.isEmpty()) {
|
||||||
|
log.warn("쿠키를 찾을 수 없음 - userId: {}", userId);
|
||||||
|
return VibeTrackResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Track API 호출
|
||||||
|
List<VibeTrackResponse.Track> tracks = fetchVibeTracks(cookies, playlistId);
|
||||||
|
|
||||||
|
return VibeTrackResponse.builder()
|
||||||
|
.success(true)
|
||||||
|
.message("트랙 목록 조회 성공")
|
||||||
|
.totalCount(tracks.size())
|
||||||
|
.tracks(tracks)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 트랙 목록 조회 중 오류 발생", e);
|
||||||
|
return VibeTrackResponse.builder()
|
||||||
|
.success(false)
|
||||||
|
.message("트랙 목록 조회 중 오류가 발생했습니다: " + e.getMessage())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe API를 호출하여 트랙 목록을 조회합니다.
|
||||||
|
*
|
||||||
|
* @param cookies 네이버 로그인 쿠키
|
||||||
|
* @param playlistId 플레이리스트 ID
|
||||||
|
* @return 트랙 목록
|
||||||
|
*/
|
||||||
|
private List<VibeTrackResponse.Track> fetchVibeTracks(Map<String, String> cookies, String playlistId) {
|
||||||
|
List<VibeTrackResponse.Track> tracks = new ArrayList<>();
|
||||||
|
|
||||||
|
String trackApiUrl = String.format(VIBE_TRACK_API_URL, playlistId);
|
||||||
|
|
||||||
|
RequestConfig requestConfig = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.setResponseTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(requestConfig)
|
||||||
|
.build()) {
|
||||||
|
|
||||||
|
HttpGet getRequest = new HttpGet(trackApiUrl);
|
||||||
|
getRequest.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||||
|
getRequest.setHeader("Referer", "https://vibe.naver.com/");
|
||||||
|
getRequest.setHeader("Accept", "application/json");
|
||||||
|
|
||||||
|
// 쿠키 헤더 추가
|
||||||
|
StringBuilder cookieHeader = new StringBuilder();
|
||||||
|
for (Map.Entry<String, String> entry : cookies.entrySet()) {
|
||||||
|
if (cookieHeader.length() > 0) {
|
||||||
|
cookieHeader.append("; ");
|
||||||
|
}
|
||||||
|
cookieHeader.append(entry.getKey()).append("=").append(entry.getValue());
|
||||||
|
}
|
||||||
|
getRequest.setHeader("Cookie", cookieHeader.toString());
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(getRequest)) {
|
||||||
|
int statusCode = response.getCode();
|
||||||
|
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.info("Vibe Track API 응답 코드: {}", statusCode);
|
||||||
|
log.info("Vibe Track API 응답 본문 길이: {} bytes", responseBody.length());
|
||||||
|
|
||||||
|
if (statusCode == 200) {
|
||||||
|
// JSON 응답 파싱
|
||||||
|
tracks = parseVibeTrackResponse(responseBody);
|
||||||
|
log.info("파싱된 트랙 개수: {}", tracks.size());
|
||||||
|
} else {
|
||||||
|
log.error("Vibe Track API 호출 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe 트랙 목록 조회 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe Track API 응답을 파싱합니다.
|
||||||
|
*
|
||||||
|
* @param responseBody JSON 응답 본문
|
||||||
|
* @return 트랙 목록
|
||||||
|
*/
|
||||||
|
private List<VibeTrackResponse.Track> parseVibeTrackResponse(String responseBody) {
|
||||||
|
List<VibeTrackResponse.Track> tracks = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(responseBody);
|
||||||
|
JsonNode resultNode = root.path("response").path("result");
|
||||||
|
|
||||||
|
if (resultNode.isObject()) {
|
||||||
|
JsonNode tracksNode = resultNode.path("tracks");
|
||||||
|
|
||||||
|
if (tracksNode.isArray()) {
|
||||||
|
for (JsonNode trackNode : tracksNode) {
|
||||||
|
// Artists 파싱
|
||||||
|
List<VibeTrackResponse.Artist> artists = new ArrayList<>();
|
||||||
|
JsonNode artistsNode = trackNode.path("artists");
|
||||||
|
if (artistsNode.isArray()) {
|
||||||
|
for (JsonNode artistNode : artistsNode) {
|
||||||
|
VibeTrackResponse.Artist artist = VibeTrackResponse.Artist.builder()
|
||||||
|
.artistId(artistNode.path("artistId").asLong())
|
||||||
|
.artistName(artistNode.path("artistName").asText())
|
||||||
|
.imageUrl(artistNode.path("imageUrl").asText(null))
|
||||||
|
.build();
|
||||||
|
artists.add(artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album 파싱
|
||||||
|
JsonNode albumNode = trackNode.path("album");
|
||||||
|
VibeTrackResponse.Album album = null;
|
||||||
|
if (albumNode.isObject()) {
|
||||||
|
album = VibeTrackResponse.Album.builder()
|
||||||
|
.albumId(albumNode.path("albumId").asLong())
|
||||||
|
.albumTitle(albumNode.path("albumTitle").asText())
|
||||||
|
.releaseDate(albumNode.path("releaseDate").asText(null))
|
||||||
|
.imageUrl(albumNode.path("imageUrl").asText(null))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track 생성
|
||||||
|
VibeTrackResponse.Track track = VibeTrackResponse.Track.builder()
|
||||||
|
.trackId(trackNode.path("trackId").asLong())
|
||||||
|
.trackTitle(trackNode.path("trackTitle").asText())
|
||||||
|
.playTime(trackNode.path("playTime").asText(null))
|
||||||
|
.artists(artists)
|
||||||
|
.album(album)
|
||||||
|
.hasLyric(trackNode.path("hasLyric").asBoolean(false))
|
||||||
|
.isStreaming(trackNode.path("isStreaming").asBoolean(false))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
tracks.add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Vibe Track 응답 파싱 중 오류 발생", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,12 @@ public class AuthenticationConfig {
|
|||||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||||
corsConfiguration.setAllowedOrigins(List.of(
|
corsConfiguration.setAllowedOrigins(List.of(
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
"http://10.20.20.23:8080"
|
"http://10.20.20.23:8080"
|
||||||
));
|
));
|
||||||
corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
|
corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
|
||||||
corsConfiguration.setAllowedHeaders(List.of("Authorization","Content-Type"));
|
corsConfiguration.setAllowedHeaders(List.of("Authorization","Content-Type", "X-Session-Id"));
|
||||||
corsConfiguration.setAllowCredentials(true);
|
corsConfiguration.setAllowCredentials(true);
|
||||||
corsConfiguration.setExposedHeaders(List.of(
|
corsConfiguration.setExposedHeaders(List.of(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class MongodbConfig {
|
|||||||
String encodePassword = URLEncoder.encode(password, StandardCharsets.UTF_8);
|
String encodePassword = URLEncoder.encode(password, StandardCharsets.UTF_8);
|
||||||
String auth = username.isEmpty() ? "" : String.format("%s:%s@",username, encodePassword);
|
String auth = username.isEmpty() ? "" : String.format("%s:%s@",username, encodePassword);
|
||||||
String connection;
|
String connection;
|
||||||
connection = String.format("mongodb://%s%s/?authSource=%s", auth, businessLogHost, db);
|
connection = String.format("mongodb://%s%s/%s?authSource=%s", auth, businessLogHost, db, db);
|
||||||
|
|
||||||
MongoClientSettings settings = MongoClientSettings.builder()
|
MongoClientSettings settings = MongoClientSettings.builder()
|
||||||
.applyConnectionString(new ConnectionString(connection))
|
.applyConnectionString(new ConnectionString(connection))
|
||||||
|
|||||||
168
src/main/java/com/global/util/EncryptionUtil.java
Normal file
168
src/main/java/com/global/util/EncryptionUtil.java
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package com.global.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CryptoJS 호환 AES 암호화/복호화 유틸리티
|
||||||
|
* CryptoJS의 OpenSSL 호환 포맷(Salted__)을 지원합니다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class EncryptionUtil {
|
||||||
|
|
||||||
|
private static final String ALGORITHM = "AES";
|
||||||
|
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
|
||||||
|
private static final String SALTED_PREFIX = "Salted__";
|
||||||
|
private static final int SALT_LENGTH = 8;
|
||||||
|
private static final int KEY_LENGTH = 32; // 256 bits
|
||||||
|
private static final int IV_LENGTH = 16; // 128 bits
|
||||||
|
|
||||||
|
@Value("${vibe.encryption.secret-key:MyListBridgeSecretKey1234567}")
|
||||||
|
private String secretKeyString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CryptoJS 호환 복호화
|
||||||
|
* CryptoJS의 OpenSSL 포맷(Salted__ + salt + data)을 해석합니다.
|
||||||
|
*
|
||||||
|
* @param encryptedText Base64 인코딩된 CryptoJS 암호문
|
||||||
|
* @return 복호화된 평문
|
||||||
|
*/
|
||||||
|
public String decrypt(String encryptedText) {
|
||||||
|
if (encryptedText == null || encryptedText.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Base64 디코딩
|
||||||
|
byte[] encryptedData = Base64.getDecoder().decode(encryptedText);
|
||||||
|
|
||||||
|
// 2. "Salted__" 접두사 확인
|
||||||
|
byte[] saltedPrefix = Arrays.copyOfRange(encryptedData, 0, 8);
|
||||||
|
String prefix = new String(saltedPrefix, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
if (!SALTED_PREFIX.equals(prefix)) {
|
||||||
|
log.error("Invalid CryptoJS format - missing 'Salted__' prefix");
|
||||||
|
throw new RuntimeException("Invalid CryptoJS format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Salt 추출 (8바이트)
|
||||||
|
byte[] salt = Arrays.copyOfRange(encryptedData, 8, 16);
|
||||||
|
|
||||||
|
// 4. 실제 암호화된 데이터
|
||||||
|
byte[] cipherText = Arrays.copyOfRange(encryptedData, 16, encryptedData.length);
|
||||||
|
|
||||||
|
// 5. CryptoJS의 EVP_BytesToKey 방식으로 키와 IV 생성
|
||||||
|
byte[][] keyAndIV = deriveKeyAndIV(secretKeyString.getBytes(StandardCharsets.UTF_8), salt);
|
||||||
|
byte[] key = keyAndIV[0];
|
||||||
|
byte[] iv = keyAndIV[1];
|
||||||
|
|
||||||
|
// 6. 복호화
|
||||||
|
SecretKeySpec secretKey = new SecretKeySpec(key, ALGORITHM);
|
||||||
|
IvParameterSpec ivSpec = new IvParameterSpec(iv);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
|
||||||
|
|
||||||
|
byte[] decryptedBytes = cipher.doFinal(cipherText);
|
||||||
|
String result = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
log.debug("복호화 성공");
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("CryptoJS 복호화 실패", e);
|
||||||
|
throw new RuntimeException("복호화 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CryptoJS/OpenSSL의 EVP_BytesToKey 알고리즘 구현
|
||||||
|
* 패스워드와 Salt로부터 Key와 IV를 유도합니다.
|
||||||
|
*
|
||||||
|
* @param password 패스워드 바이트
|
||||||
|
* @param salt Salt 바이트
|
||||||
|
* @return [key, iv] 배열
|
||||||
|
*/
|
||||||
|
private byte[][] deriveKeyAndIV(byte[] password, byte[] salt) throws Exception {
|
||||||
|
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||||
|
byte[] key = new byte[KEY_LENGTH];
|
||||||
|
byte[] iv = new byte[IV_LENGTH];
|
||||||
|
byte[] concatenated = new byte[0];
|
||||||
|
byte[] result = new byte[0];
|
||||||
|
|
||||||
|
int totalLength = KEY_LENGTH + IV_LENGTH;
|
||||||
|
|
||||||
|
while (result.length < totalLength) {
|
||||||
|
md5.reset();
|
||||||
|
md5.update(concatenated);
|
||||||
|
md5.update(password);
|
||||||
|
md5.update(salt);
|
||||||
|
concatenated = md5.digest();
|
||||||
|
|
||||||
|
byte[] temp = new byte[result.length + concatenated.length];
|
||||||
|
System.arraycopy(result, 0, temp, 0, result.length);
|
||||||
|
System.arraycopy(concatenated, 0, temp, result.length, concatenated.length);
|
||||||
|
result = temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.arraycopy(result, 0, key, 0, KEY_LENGTH);
|
||||||
|
System.arraycopy(result, KEY_LENGTH, iv, 0, IV_LENGTH);
|
||||||
|
|
||||||
|
return new byte[][]{key, iv};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CryptoJS 호환 암호화 (서버에서 암호화할 필요가 있는 경우)
|
||||||
|
*
|
||||||
|
* @param plainText 평문
|
||||||
|
* @return Base64 인코딩된 CryptoJS 포맷 암호문
|
||||||
|
*/
|
||||||
|
public String encrypt(String plainText) {
|
||||||
|
if (plainText == null || plainText.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 랜덤 Salt 생성
|
||||||
|
byte[] salt = new byte[SALT_LENGTH];
|
||||||
|
new java.security.SecureRandom().nextBytes(salt);
|
||||||
|
|
||||||
|
// 2. Key와 IV 유도
|
||||||
|
byte[][] keyAndIV = deriveKeyAndIV(secretKeyString.getBytes(StandardCharsets.UTF_8), salt);
|
||||||
|
byte[] key = keyAndIV[0];
|
||||||
|
byte[] iv = keyAndIV[1];
|
||||||
|
|
||||||
|
// 3. 암호화
|
||||||
|
SecretKeySpec secretKey = new SecretKeySpec(key, ALGORITHM);
|
||||||
|
IvParameterSpec ivSpec = new IvParameterSpec(iv);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
|
||||||
|
|
||||||
|
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// 4. "Salted__" + salt + encryptedData 조합
|
||||||
|
byte[] result = new byte[16 + encryptedBytes.length];
|
||||||
|
System.arraycopy(SALTED_PREFIX.getBytes(StandardCharsets.UTF_8), 0, result, 0, 8);
|
||||||
|
System.arraycopy(salt, 0, result, 8, 8);
|
||||||
|
System.arraycopy(encryptedBytes, 0, result, 16, encryptedBytes.length);
|
||||||
|
|
||||||
|
// 5. Base64 인코딩
|
||||||
|
return Base64.getEncoder().encodeToString(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("CryptoJS 암호화 실패", e);
|
||||||
|
throw new RuntimeException("암호화 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,8 +112,8 @@ excel:
|
|||||||
################################################################################################################################################################################################
|
################################################################################################################################################################################################
|
||||||
mongodb:
|
mongodb:
|
||||||
host: 140.238.22.48:27017
|
host: 140.238.22.48:27017
|
||||||
username: bcjang
|
username: myListBridge
|
||||||
password: dlawls05081)
|
password: "mdmadkr135y"
|
||||||
db: myListBridge
|
db: myListBridge
|
||||||
|
|
||||||
|
|
||||||
@@ -129,3 +129,19 @@ redis:
|
|||||||
sync-timeout: 30000
|
sync-timeout: 30000
|
||||||
ssl: false
|
ssl: false
|
||||||
abort-connect: false
|
abort-connect: false
|
||||||
|
|
||||||
|
|
||||||
|
################################################################################################################################################################################################
|
||||||
|
# Papago Translation API (Naver)
|
||||||
|
################################################################################################################################################################################################
|
||||||
|
papago:
|
||||||
|
client-id: c2epv5w4h0
|
||||||
|
client-secret: bsyQWOmFhxyHsHahWEwtN9Rfb1jXQEAvhPsZJpnu
|
||||||
|
|
||||||
|
|
||||||
|
################################################################################################################################################################################################
|
||||||
|
# Vibe 암호화 설정
|
||||||
|
################################################################################################################################################################################################
|
||||||
|
vibe:
|
||||||
|
encryption:
|
||||||
|
secret-key: MyListBridgeSecretKey1234567890
|
||||||
|
|||||||
Reference in New Issue
Block a user