@@ -40,24 +40,90 @@ struct ITunesService: ITunesServicing {
4040 let safeLimit = max ( limit, 1 )
4141 let safeOffset = max ( offset, 0 )
4242 let requestLimit = min ( safeOffset + safeLimit, maxResultCount)
43+ let searchVariants = makeSearchVariants ( from: trimmedQuery)
44+ var mergedTracks : [ SpotifySimpleTrack ] = [ ]
45+ var dedupeKeys = Set < String > ( )
46+ var firstFailure : ITunesServiceError ?
47+ var hasSuccessfulRequest = false
4348
44- var components = URLComponents ( string: " https://itunes.apple.com/search " )
45- components? . queryItems = [
46- URLQueryItem ( name: " term " , value: trimmedQuery) ,
49+ for variant in searchVariants {
50+ do {
51+ let rawItems = try await requestTracks (
52+ term: variant. term,
53+ limit: requestLimit,
54+ country: variant. country,
55+ language: variant. language,
56+ attribute: variant. attribute
57+ )
58+ hasSuccessfulRequest = true
59+
60+ for item in rawItems {
61+ guard let track = mapToSimpleTrack ( item) else { continue }
62+ let dedupeKey = normalizedTrackIdentity ( for: track)
63+ guard dedupeKeys. insert ( dedupeKey) . inserted else { continue }
64+ mergedTracks. append ( track)
65+ }
66+
67+ if mergedTracks. count >= requestLimit {
68+ break
69+ }
70+ } catch let serviceError as ITunesServiceError {
71+ if firstFailure == nil {
72+ firstFailure = serviceError
73+ }
74+ } catch {
75+ if firstFailure == nil {
76+ firstFailure = . networkFailure( message: " iTunes 요청 중 네트워크 오류가 발생했어요. " )
77+ }
78+ }
79+ }
80+
81+ if !hasSuccessfulRequest, let firstFailure {
82+ throw firstFailure
83+ }
84+
85+ guard safeOffset < mergedTracks. count else {
86+ return [ ]
87+ }
88+
89+ return Array ( mergedTracks. dropFirst ( safeOffset) . prefix ( safeLimit) )
90+ }
91+
92+ private func requestTracks(
93+ term: String ,
94+ limit: Int ,
95+ country: String ? ,
96+ language: String ? ,
97+ attribute: String ?
98+ ) async throws -> [ ITunesTrackItem ] {
99+ var queryItems : [ URLQueryItem ] = [
100+ URLQueryItem ( name: " term " , value: term) ,
47101 URLQueryItem ( name: " media " , value: " music " ) ,
48102 URLQueryItem ( name: " entity " , value: " song " ) ,
49- URLQueryItem ( name: " country " , value: " KR " ) ,
50- URLQueryItem ( name: " lang " , value: " ko_kr " ) ,
51- URLQueryItem ( name: " limit " , value: String ( requestLimit) )
103+ URLQueryItem ( name: " limit " , value: String ( limit) )
52104 ]
53105
106+ if let country, !country. isEmpty {
107+ queryItems. append ( URLQueryItem ( name: " country " , value: country) )
108+ }
109+ if let language, !language. isEmpty {
110+ queryItems. append ( URLQueryItem ( name: " lang " , value: language) )
111+ }
112+ if let attribute, !attribute. isEmpty {
113+ queryItems. append ( URLQueryItem ( name: " attribute " , value: attribute) )
114+ }
115+
116+ var components = URLComponents ( string: " https://itunes.apple.com/search " )
117+ components? . queryItems = queryItems
118+
54119 guard let url = components? . url else {
55120 throw ITunesServiceError . invalidResponse
56121 }
57122
58123 var request = URLRequest ( url: url)
59124 request. httpMethod = HTTPMethod . get. rawValue
60125 request. setValue ( " application/json " , forHTTPHeaderField: " Accept " )
126+ request. setValue ( " ko-KR,ko;q=0.9,en-US;q=0.8 " , forHTTPHeaderField: " Accept-Language " )
61127
62128 do {
63129 let ( data, response) = try await session. data ( for: request)
@@ -79,19 +145,81 @@ struct ITunesService: ITunesServicing {
79145 throw ITunesServiceError . decodingFailed
80146 }
81147
82- let mappedTracks = decoded. results. compactMap ( mapToSimpleTrack)
83- guard safeOffset < mappedTracks. count else {
84- return [ ]
85- }
86-
87- return Array ( mappedTracks. dropFirst ( safeOffset) . prefix ( safeLimit) )
148+ return decoded. results
88149 } catch let error as ITunesServiceError {
89150 throw error
90151 } catch {
91152 throw ITunesServiceError . networkFailure ( message: " iTunes 요청 중 네트워크 오류가 발생했어요. " )
92153 }
93154 }
94155
156+ private func makeSearchVariants( from query: String ) -> [ SearchVariant ] {
157+ let normalized = query
158+ . components ( separatedBy: . whitespacesAndNewlines)
159+ . filter { !$0. isEmpty }
160+ . joined ( separator: " " )
161+ let compact = normalized. replacingOccurrences ( of: " " , with: " " )
162+
163+ let candidateTerms : [ String ] = compact == normalized
164+ ? [ normalized]
165+ : [ normalized, compact]
166+
167+ var variants : [ SearchVariant ] = [ ]
168+ var seenKeys = Set < String > ( )
169+
170+ func appendVariant(
171+ term: String ,
172+ country: String ? ,
173+ language: String ? ,
174+ attribute: String ?
175+ ) {
176+ let key = [
177+ term. lowercased ( ) ,
178+ country? . lowercased ( ) ?? " " ,
179+ language? . lowercased ( ) ?? " " ,
180+ attribute? . lowercased ( ) ?? " "
181+ ] . joined ( separator: " | " )
182+ guard seenKeys. insert ( key) . inserted else { return }
183+ variants. append (
184+ SearchVariant (
185+ term: term,
186+ country: country,
187+ language: language,
188+ attribute: attribute
189+ )
190+ )
191+ }
192+
193+ for term in candidateTerms {
194+ appendVariant ( term: term, country: " KR " , language: " ko_kr " , attribute: " songTerm " )
195+ appendVariant ( term: term, country: " KR " , language: " ko_kr " , attribute: nil )
196+ }
197+
198+ if let primaryTerm = candidateTerms. first {
199+ appendVariant ( term: primaryTerm, country: nil , language: nil , attribute: nil )
200+ }
201+
202+ return variants
203+ }
204+
205+ private func normalizedTrackIdentity( for track: SpotifySimpleTrack ) -> String {
206+ " \( normalizedIdentityComponent ( track. title) ) | \( normalizedIdentityComponent ( track. artist) ) "
207+ }
208+
209+ private func normalizedIdentityComponent( _ value: String ) -> String {
210+ value
211+ . trimmingCharacters ( in: . whitespacesAndNewlines)
212+ . folding ( options: [ . diacriticInsensitive, . caseInsensitive] , locale: . current)
213+ . replacingOccurrences ( of: " \\ s+ " , with: " " , options: . regularExpression)
214+ }
215+
216+ private struct SearchVariant {
217+ let term : String
218+ let country : String ?
219+ let language : String ?
220+ let attribute : String ?
221+ }
222+
95223 private func mapToSimpleTrack( _ item: ITunesTrackItem ) -> SpotifySimpleTrack ? {
96224 let title = ( item. trackName ?? " " ) . trimmingCharacters ( in: . whitespacesAndNewlines)
97225 let artist = ( item. artistName ?? " " ) . trimmingCharacters ( in: . whitespacesAndNewlines)
0 commit comments