diff --git a/src/main/java/com/domain/api/VibeController.java b/src/main/java/com/domain/api/VibeController.java new file mode 100644 index 0000000..c9ab5ea --- /dev/null +++ b/src/main/java/com/domain/api/VibeController.java @@ -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 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 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 healthCheck() { + return ResponseEntity.ok("Vibe API is ready"); + } +} diff --git a/src/main/java/com/domain/request/VibeLoginRequest.java b/src/main/java/com/domain/request/VibeLoginRequest.java new file mode 100644 index 0000000..1022d86 --- /dev/null +++ b/src/main/java/com/domain/request/VibeLoginRequest.java @@ -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; +} diff --git a/src/main/java/com/domain/request/VibeToSpotifySearchRequest.java b/src/main/java/com/domain/request/VibeToSpotifySearchRequest.java new file mode 100644 index 0000000..ca28f59 --- /dev/null +++ b/src/main/java/com/domain/request/VibeToSpotifySearchRequest.java @@ -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 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; // 선택적, 더 정확한 매칭을 위해 + } +} diff --git a/src/main/java/com/domain/response/VibePlaylistResponse.java b/src/main/java/com/domain/response/VibePlaylistResponse.java new file mode 100644 index 0000000..816aa50 --- /dev/null +++ b/src/main/java/com/domain/response/VibePlaylistResponse.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/domain/response/VibeToSpotifySearchResponse.java b/src/main/java/com/domain/response/VibeToSpotifySearchResponse.java new file mode 100644 index 0000000..d7376f8 --- /dev/null +++ b/src/main/java/com/domain/response/VibeToSpotifySearchResponse.java @@ -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 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; + } +} diff --git a/src/main/java/com/domain/response/VibeTrackResponse.java b/src/main/java/com/domain/response/VibeTrackResponse.java new file mode 100644 index 0000000..7917b92 --- /dev/null +++ b/src/main/java/com/domain/response/VibeTrackResponse.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/domain/service/VibeCookieService.java b/src/main/java/com/domain/service/VibeCookieService.java new file mode 100644 index 0000000..e8bf3cc --- /dev/null +++ b/src/main/java/com/domain/service/VibeCookieService.java @@ -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 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 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 getCookieByUserId(String userId) { + String key = VIBE_COOKIE_PREFIX + userId; + + Map 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) (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); + } +} diff --git a/src/main/java/com/domain/service/VibeService.java b/src/main/java/com/domain/service/VibeService.java new file mode 100644 index 0000000..c44da1d --- /dev/null +++ b/src/main/java/com/domain/service/VibeService.java @@ -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 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 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 loginToNaver(String naverId, String naverPassword) { + Map 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 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 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 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 fetchVibePlaylists(Map cookies) { + List 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 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 parseVibePlaylistResponse(String responseBody) { + List 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 cookies = vibeCookieService.getCookieByUserId(userId); + + if (cookies == null || cookies.isEmpty()) { + log.warn("쿠키를 찾을 수 없음 - userId: {}", userId); + return VibeTrackResponse.builder() + .success(false) + .message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.") + .build(); + } + + // 3. Track API 호출 + List 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 fetchVibeTracks(Map cookies, String playlistId) { + List 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 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 parseVibeTrackResponse(String responseBody) { + List 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 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; + } +} diff --git a/src/main/java/com/global/configuration/AuthenticationConfig.java b/src/main/java/com/global/configuration/AuthenticationConfig.java index 87e4153..8f71d5d 100644 --- a/src/main/java/com/global/configuration/AuthenticationConfig.java +++ b/src/main/java/com/global/configuration/AuthenticationConfig.java @@ -35,10 +35,12 @@ public class AuthenticationConfig { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOrigins(List.of( "http://localhost:3000", + "http://localhost:3001", + "http://127.0.0.1:3001", "http://10.20.20.23:8080" )); 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.setExposedHeaders(List.of( "Content-Disposition", diff --git a/src/main/java/com/global/configuration/MongodbConfig.java b/src/main/java/com/global/configuration/MongodbConfig.java index d9affcc..03e6ec9 100644 --- a/src/main/java/com/global/configuration/MongodbConfig.java +++ b/src/main/java/com/global/configuration/MongodbConfig.java @@ -37,7 +37,7 @@ public class MongodbConfig { String encodePassword = URLEncoder.encode(password, StandardCharsets.UTF_8); String auth = username.isEmpty() ? "" : String.format("%s:%s@",username, encodePassword); 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() .applyConnectionString(new ConnectionString(connection)) diff --git a/src/main/resources/config/local/application.yml b/src/main/resources/config/local/application.yml index 1c15dfc..f77d506 100644 --- a/src/main/resources/config/local/application.yml +++ b/src/main/resources/config/local/application.yml @@ -112,8 +112,8 @@ excel: ################################################################################################################################################################################################ mongodb: host: 140.238.22.48:27017 - username: bcjang - password: dlawls05081) + username: myListBridge + password: "mdmadkr135y" db: myListBridge @@ -129,3 +129,19 @@ redis: sync-timeout: 30000 ssl: false abort-connect: false + + +################################################################################################################################################################################################ +# Papago Translation API (Naver) +################################################################################################################################################################################################ +papago: + client-id: c2epv5w4h0 + client-secret: bsyQWOmFhxyHsHahWEwtN9Rfb1jXQEAvhPsZJpnu + + +################################################################################################################################################################################################ +# Vibe 암호화 설정 +################################################################################################################################################################################################ +vibe: + encryption: + secret-key: MyListBridgeSecretKey1234567890