Skip to content

Commit 556886c

Browse files
authored
[Release] 1.1.4 업데이트 (#75)
* feat(1.1.3): 버전 업데이트 * feat: 데이터 refetch 추가 * Merge pull request #74 from ApptiveDev/feat/72 feat - QA 반영
2 parents f177b52 + d4a0450 commit 556886c

18 files changed

Lines changed: 767 additions & 208 deletions

File tree

KillingPart.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@
434434
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
435435
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
436436
CODE_SIGN_STYLE = Automatic;
437-
CURRENT_PROJECT_VERSION = 28;
437+
CURRENT_PROJECT_VERSION = 30;
438438
DEAD_CODE_STRIPPING = YES;
439439
DEVELOPMENT_TEAM = GQ89YG5G9R;
440440
ENABLE_APP_SANDBOX = YES;
@@ -459,7 +459,7 @@
459459
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
460460
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
461461
MACOSX_DEPLOYMENT_TARGET = 14.0;
462-
MARKETING_VERSION = 1.1.2;
462+
MARKETING_VERSION = 1.1.4;
463463
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
464464
PRODUCT_NAME = "$(TARGET_NAME)";
465465
REGISTER_APP_GROUPS = YES;
@@ -479,7 +479,7 @@
479479
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
480480
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
481481
CODE_SIGN_STYLE = Automatic;
482-
CURRENT_PROJECT_VERSION = 28;
482+
CURRENT_PROJECT_VERSION = 30;
483483
DEAD_CODE_STRIPPING = YES;
484484
DEVELOPMENT_TEAM = GQ89YG5G9R;
485485
ENABLE_APP_SANDBOX = YES;
@@ -504,7 +504,7 @@
504504
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
505505
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
506506
MACOSX_DEPLOYMENT_TARGET = 14.0;
507-
MARKETING_VERSION = 1.1.2;
507+
MARKETING_VERSION = 1.1.4;
508508
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
509509
PRODUCT_NAME = "$(TARGET_NAME)";
510510
REGISTER_APP_GROUPS = YES;

KillingPart/Services/ITunesService.swift

Lines changed: 140 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

KillingPart/ViewModels/Add/AddTabViewModel.swift

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ final class AddTabViewModel: ObservableObject {
88
@Published private(set) var isLoadingMore = false
99
@Published var errorMessage: String?
1010

11+
private let spotifyService: SpotifyServicing
1112
private let itunesService: ITunesServicing
1213
private var searchTask: Task<Void, Never>?
1314
private var loadMoreTask: Task<Void, Never>?
@@ -16,7 +17,11 @@ final class AddTabViewModel: ObservableObject {
1617
private var nextOffset = 0
1718
private var hasMoreResults = true
1819

19-
init(itunesService: ITunesServicing = ITunesService()) {
20+
init(
21+
spotifyService: SpotifyServicing = SpotifyService(),
22+
itunesService: ITunesServicing = ITunesService()
23+
) {
24+
self.spotifyService = spotifyService
2025
self.itunesService = itunesService
2126
}
2227

@@ -143,7 +148,7 @@ final class AddTabViewModel: ObservableObject {
143148
}
144149

145150
do {
146-
let fetchedTracks = try await itunesService.searchTracks(
151+
let fetchedTracks = try await fetchTracks(
147152
query: query,
148153
limit: pageSize,
149154
offset: offset
@@ -153,8 +158,8 @@ final class AddTabViewModel: ObservableObject {
153158
case .initial:
154159
tracks = fetchedTracks
155160
case .pagination:
156-
let existingTrackIDs = Set(tracks.map(\.id))
157-
let newTracks = fetchedTracks.filter { !existingTrackIDs.contains($0.id) }
161+
let existingTrackKeys = Set(tracks.map { normalizedTrackIdentity(for: $0) })
162+
let newTracks = fetchedTracks.filter { !existingTrackKeys.contains(normalizedTrackIdentity(for: $0)) }
158163
tracks.append(contentsOf: newTracks)
159164
if fetchedTracks.isEmpty || newTracks.isEmpty {
160165
hasMoreResults = false
@@ -182,12 +187,128 @@ final class AddTabViewModel: ObservableObject {
182187
hasMoreResults = true
183188
}
184189

190+
private func fetchTracks(
191+
query: String,
192+
limit: Int,
193+
offset: Int
194+
) async throws -> [SpotifySimpleTrack] {
195+
let safeLimit = max(limit, 1)
196+
let safeOffset = max(offset, 0)
197+
198+
async let spotifyResult = fetchSpotifyTracks(
199+
query: query,
200+
limit: safeLimit,
201+
offset: safeOffset
202+
)
203+
async let iTunesResult = fetchITunesTracks(
204+
query: query,
205+
limit: max(safeLimit * 2, safeLimit),
206+
offset: safeOffset
207+
)
208+
209+
let spotifyOutcome = await spotifyResult
210+
let iTunesOutcome = await iTunesResult
211+
212+
let mergedTracks = mergeTracks(
213+
primary: spotifyOutcome.tracks,
214+
secondary: iTunesOutcome.tracks,
215+
limit: safeLimit
216+
)
217+
218+
if !mergedTracks.isEmpty {
219+
return mergedTracks
220+
}
221+
222+
if spotifyOutcome.didSucceed || iTunesOutcome.didSucceed {
223+
return []
224+
}
225+
226+
if let spotifyError = spotifyOutcome.error {
227+
throw spotifyError
228+
}
229+
230+
if let iTunesError = iTunesOutcome.error {
231+
throw iTunesError
232+
}
233+
234+
return []
235+
}
236+
237+
private func fetchSpotifyTracks(
238+
query: String,
239+
limit: Int,
240+
offset: Int
241+
) async -> (tracks: [SpotifySimpleTrack], error: Error?, didSucceed: Bool) {
242+
do {
243+
let tracks = try await spotifyService.searchTracks(
244+
query: query,
245+
limit: limit,
246+
offset: offset
247+
)
248+
return (tracks, nil, true)
249+
} catch {
250+
return ([], error, false)
251+
}
252+
}
253+
254+
private func fetchITunesTracks(
255+
query: String,
256+
limit: Int,
257+
offset: Int
258+
) async -> (tracks: [SpotifySimpleTrack], error: Error?, didSucceed: Bool) {
259+
do {
260+
let tracks = try await itunesService.searchTracks(
261+
query: query,
262+
limit: limit,
263+
offset: offset
264+
)
265+
return (tracks, nil, true)
266+
} catch {
267+
return ([], error, false)
268+
}
269+
}
270+
271+
private func mergeTracks(
272+
primary: [SpotifySimpleTrack],
273+
secondary: [SpotifySimpleTrack],
274+
limit: Int
275+
) -> [SpotifySimpleTrack] {
276+
var merged: [SpotifySimpleTrack] = []
277+
var dedupeKeys = Set<String>()
278+
279+
for track in primary + secondary {
280+
let key = normalizedTrackIdentity(for: track)
281+
guard dedupeKeys.insert(key).inserted else { continue }
282+
merged.append(track)
283+
if merged.count == limit {
284+
break
285+
}
286+
}
287+
288+
return merged
289+
}
290+
291+
private func normalizedTrackIdentity(for track: SpotifySimpleTrack) -> String {
292+
"\(normalizedIdentityComponent(track.title))|\(normalizedIdentityComponent(track.artist))"
293+
}
294+
295+
private func normalizedIdentityComponent(_ value: String) -> String {
296+
value
297+
.trimmingCharacters(in: .whitespacesAndNewlines)
298+
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
299+
.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression)
300+
}
301+
185302
private enum SearchMode {
186303
case initial
187304
case pagination
188305
}
189306

190307
private func resolveErrorMessage(from error: Error) -> String {
308+
if let spotifyError = error as? SpotifyServiceError {
309+
return spotifyError.errorDescription ?? "음악 검색에 실패했어요."
310+
}
311+
191312
if let itunesError = error as? ITunesServiceError {
192313
return itunesError.errorDescription ?? "음악 검색에 실패했어요."
193314
}

0 commit comments

Comments
 (0)