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; } }