-
Notifications
You must be signed in to change notification settings - Fork 0
feat - Spotify API 연동 및 Add탭 작업 #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import Foundation | ||
|
|
||
| struct SpotifyTokenResponse: Decodable { | ||
| let accessToken: String | ||
| let tokenType: String | ||
| let expiresIn: Int | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case accessToken = "access_token" | ||
| case tokenType = "token_type" | ||
| case expiresIn = "expires_in" | ||
| } | ||
| } | ||
|
|
||
| struct SpotifySearchResponse: Decodable { | ||
| let tracks: SpotifyTracks | ||
| } | ||
|
|
||
| struct SpotifyTracks: Decodable { | ||
| let items: [SpotifyTrackItem] | ||
| } | ||
|
|
||
| struct SpotifyTrackItem: Decodable { | ||
| let id: String | ||
| let name: String | ||
| let artists: [SpotifyArtist] | ||
| let album: SpotifyAlbum | ||
| } | ||
|
|
||
| struct SpotifyArtist: Decodable { | ||
| let name: String | ||
| } | ||
|
|
||
| struct SpotifyAlbum: Decodable { | ||
| let id: String | ||
| let images: [SpotifyAlbumImage] | ||
| } | ||
|
|
||
| struct SpotifyAlbumImage: Decodable { | ||
| let url: String | ||
| let width: Int? | ||
| let height: Int? | ||
| } | ||
|
|
||
| struct SpotifySimpleTrack: Identifiable { | ||
| let id: String | ||
| let title: String | ||
| let artist: String | ||
| let albumImageUrl: String? | ||
| let albumId: String | ||
|
|
||
| var albumImageURL: URL? { | ||
| guard let albumImageUrl else { return nil } | ||
| return URL(string: albumImageUrl) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,271 @@ | ||||||||||||||
| import Foundation | ||||||||||||||
|
|
||||||||||||||
| protocol SpotifyServicing { | ||||||||||||||
| func searchTracks(query: String, limit: Int) async throws -> [SpotifySimpleTrack] | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| enum SpotifyServiceError: LocalizedError { | ||||||||||||||
| case missingBasicAuth | ||||||||||||||
| case invalidResponse | ||||||||||||||
| case unauthorized | ||||||||||||||
| case serverError(statusCode: Int, message: String?) | ||||||||||||||
| case decodingFailed | ||||||||||||||
| case networkFailure(message: String) | ||||||||||||||
|
|
||||||||||||||
| var errorDescription: String? { | ||||||||||||||
| switch self { | ||||||||||||||
| case .missingBasicAuth: | ||||||||||||||
| return "Spotify 인증 설정이 누락되었어요." | ||||||||||||||
| case .invalidResponse: | ||||||||||||||
| return "Spotify 응답을 확인할 수 없어요." | ||||||||||||||
| case .unauthorized: | ||||||||||||||
| return "Spotify 인증에 실패했어요. 잠시 후 다시 시도해 주세요." | ||||||||||||||
| case .serverError(_, let message): | ||||||||||||||
| return message ?? "Spotify 검색 처리에 실패했어요." | ||||||||||||||
| case .decodingFailed: | ||||||||||||||
| return "Spotify 응답 파싱에 실패했어요." | ||||||||||||||
| case .networkFailure(let message): | ||||||||||||||
| return message | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| struct SpotifyService: SpotifyServicing { | ||||||||||||||
| private static let tokenCache = SpotifyTokenCache() | ||||||||||||||
|
|
||||||||||||||
| private let session: URLSession | ||||||||||||||
| private let decoder = JSONDecoder() | ||||||||||||||
|
|
||||||||||||||
| init(session: URLSession = .shared) { | ||||||||||||||
| self.session = session | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func searchTracks(query: String, limit: Int = 5) async throws -> [SpotifySimpleTrack] { | ||||||||||||||
| let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) | ||||||||||||||
| guard !trimmedQuery.isEmpty else { return [] } | ||||||||||||||
|
|
||||||||||||||
| let token = try await fetchAccessToken(forceRefresh: false) | ||||||||||||||
| do { | ||||||||||||||
| return try await searchTracksWithBearerToken( | ||||||||||||||
| trimmedQuery, | ||||||||||||||
| limit: limit, | ||||||||||||||
| bearerToken: token | ||||||||||||||
| ) | ||||||||||||||
| } catch let error as SpotifyServiceError { | ||||||||||||||
| guard case .unauthorized = error else { | ||||||||||||||
| throw error | ||||||||||||||
| } | ||||||||||||||
| } catch { | ||||||||||||||
| throw mapError(error) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| let refreshedToken = try await fetchAccessToken(forceRefresh: true) | ||||||||||||||
| return try await searchTracksWithBearerToken( | ||||||||||||||
| trimmedQuery, | ||||||||||||||
| limit: limit, | ||||||||||||||
| bearerToken: refreshedToken | ||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private func searchTracksWithBearerToken( | ||||||||||||||
| _ query: String, | ||||||||||||||
| limit: Int, | ||||||||||||||
| bearerToken: String | ||||||||||||||
| ) async throws -> [SpotifySimpleTrack] { | ||||||||||||||
| var components = URLComponents(string: "https://api.spotify.com/v1/search") | ||||||||||||||
| components?.queryItems = [ | ||||||||||||||
| URLQueryItem(name: "q", value: query), | ||||||||||||||
| URLQueryItem(name: "type", value: "track"), | ||||||||||||||
| URLQueryItem(name: "market", value: "KR"), | ||||||||||||||
| URLQueryItem(name: "limit", value: String(limit)) | ||||||||||||||
| ] | ||||||||||||||
|
|
||||||||||||||
| guard let url = components?.url else { | ||||||||||||||
| throw SpotifyServiceError.invalidResponse | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| var request = URLRequest(url: url) | ||||||||||||||
| request.httpMethod = HTTPMethod.get.rawValue | ||||||||||||||
| request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") | ||||||||||||||
| request.setValue("ko-KR", forHTTPHeaderField: "Accept-Language") | ||||||||||||||
| request.setValue("application/json", forHTTPHeaderField: "Accept") | ||||||||||||||
|
|
||||||||||||||
| let (data, response) = try await performDataTask(with: request) | ||||||||||||||
| switch response.statusCode { | ||||||||||||||
| case 200..<300: | ||||||||||||||
| let decoded: SpotifySearchResponse | ||||||||||||||
| do { | ||||||||||||||
| decoded = try decoder.decode(SpotifySearchResponse.self, from: data) | ||||||||||||||
| } catch { | ||||||||||||||
| throw SpotifyServiceError.decodingFailed | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return decoded.tracks.items.map { item in | ||||||||||||||
| let artistNames = item.artists.map(\.name).joined(separator: ", ") | ||||||||||||||
|
|
||||||||||||||
| return SpotifySimpleTrack( | ||||||||||||||
| id: item.id, | ||||||||||||||
| title: item.name, | ||||||||||||||
| artist: artistNames.isEmpty ? "Unknown Artist" : artistNames, | ||||||||||||||
| albumImageUrl: item.album.images.sorted { (lhs, rhs) in | ||||||||||||||
| (lhs.width ?? 0) > (rhs.width ?? 0) | ||||||||||||||
| }.first?.url, | ||||||||||||||
|
Comment on lines
+110
to
+112
|
||||||||||||||
| albumImageUrl: item.album.images.sorted { (lhs, rhs) in | |
| (lhs.width ?? 0) > (rhs.width ?? 0) | |
| }.first?.url, | |
| albumImageUrl: item.album.images.max { (lhs, rhs) in | |
| (lhs.width ?? 0) < (rhs.width ?? 0) | |
| }?.url, |
Copilot
AI
Feb 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 구현은 Spotify Client Credentials 플로우를 위해 SPOTIFY_BASIC_AUTH(클라이언트 ID/Secret 기반 Basic 인증값)를 앱 번들(Info.plist)에서 읽어 토큰을 발급받습니다. 클라이언트 앱에 client secret(또는 그로부터 생성된 Basic auth)을 포함하면 추출이 가능해 보안상 취약하며, Spotify 정책/일반적인 OAuth 보안 관점에서도 권장되지 않습니다. 토큰 발급은 백엔드에서 프록시로 처리하거나, 사용자 인증이 필요하다면 Authorization Code + PKCE 등 secret이 필요 없는 플로우로 전환하는 방향으로 수정해 주세요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SPOTIFY_BASIC_AUTH값을 Info.plist에 포함시키면(빌드 설정 치환이더라도) 최종 앱 번들에 노출되어 역공학으로 추출될 수 있습니다. Spotify client secret/Basic auth를 클라이언트에 포함하지 않도록 구성(백엔드 프록시/PKCE 전환 등)으로 변경하는 게 안전합니다.