Skip to content

Commit 6f0602d

Browse files
committed
fix: bug fixes
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 5366272 commit 6f0602d

File tree

13 files changed

+117
-39
lines changed

13 files changed

+117
-39
lines changed

Core/Services/API/APICache.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,22 @@ final class APICache {
8787
self.cache[key] = CacheEntry(data: data, timestamp: now, ttl: ttl)
8888
}
8989

90+
private static let logger = DiagnosticsLogger.api
91+
9092
/// Generates a stable, deterministic cache key from endpoint and request body.
9193
/// Uses SHA256 hash of sorted JSON to ensure consistency.
9294
static func stableCacheKey(endpoint: String, body: [String: Any]) -> String {
9395
// Use JSONSerialization with .sortedKeys for deterministic output
9496
// This is more efficient than custom recursive string building
95-
let jsonData: Data = if #available(macOS 10.13, *) {
96-
(try? JSONSerialization.data(withJSONObject: body, options: [.sortedKeys])) ?? Data()
97-
} else {
98-
// Fallback for older macOS (shouldn't happen with macOS 26 target)
99-
(try? JSONSerialization.data(withJSONObject: body)) ?? Data()
97+
let jsonData: Data
98+
do {
99+
// .sortedKeys available since macOS 10.13, we target macOS 26+
100+
jsonData = try JSONSerialization.data(withJSONObject: body, options: [.sortedKeys])
101+
} catch {
102+
// Log the error and use endpoint-only key to avoid collisions
103+
Self.logger.error("APICache: Failed to serialize body for cache key: \(error.localizedDescription)")
104+
// Return endpoint-only key with error marker to avoid collisions
105+
return "\(endpoint):serialization_error_\(body.count)"
100106
}
101107
let hash = SHA256.hash(data: jsonData)
102108
let hashString = hash.prefix(16).compactMap { String(format: "%02x", $0) }.joined()

Core/Services/API/Parsers/HomeResponseParser.swift

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,12 @@ enum HomeResponseParser {
160160

161161
guard !items.isEmpty else { return nil }
162162

163+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
164+
let firstItemId = items.first.map { Self.extractItemId($0) } ?? ""
165+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
166+
163167
return HomeSection(
164-
id: UUID().uuidString,
168+
id: stableId,
165169
title: title,
166170
items: items,
167171
isChart: ParsingHelpers.isChartSection(title)
@@ -184,8 +188,12 @@ enum HomeResponseParser {
184188

185189
guard !items.isEmpty else { return nil }
186190

191+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
192+
let firstItemId = items.first.map { Self.extractItemId($0) } ?? ""
193+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
194+
187195
return HomeSection(
188-
id: UUID().uuidString,
196+
id: stableId,
189197
title: title,
190198
items: items,
191199
isChart: ParsingHelpers.isChartSection(title)
@@ -215,8 +223,12 @@ enum HomeResponseParser {
215223

216224
guard !items.isEmpty else { return nil }
217225

226+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
227+
let firstItemId = items.first.map { Self.extractItemId($0) } ?? ""
228+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
229+
218230
return HomeSection(
219-
id: UUID().uuidString,
231+
id: stableId,
220232
title: title,
221233
items: items,
222234
isChart: ParsingHelpers.isChartSection(title)
@@ -239,8 +251,12 @@ enum HomeResponseParser {
239251

240252
guard !items.isEmpty else { return nil }
241253

254+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
255+
let firstItemId = items.first.map { Self.extractItemId($0) } ?? ""
256+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
257+
242258
return HomeSection(
243-
id: UUID().uuidString,
259+
id: stableId,
244260
title: title,
245261
items: items,
246262
isChart: ParsingHelpers.isChartSection(title)
@@ -270,8 +286,12 @@ enum HomeResponseParser {
270286

271287
guard !sectionItems.isEmpty else { return nil }
272288

289+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
290+
let firstItemId = sectionItems.first.map { Self.extractItemId($0) } ?? ""
291+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
292+
273293
// Check if this is a chart section based on title, not renderer type
274-
return HomeSection(id: UUID().uuidString, title: title, items: sectionItems, isChart: ParsingHelpers.isChartSection(title))
294+
return HomeSection(id: stableId, title: title, items: sectionItems, isChart: ParsingHelpers.isChartSection(title))
275295
}
276296

277297
// MARK: - Item Parsing
@@ -464,6 +484,16 @@ enum HomeResponseParser {
464484

465485
// MARK: - Helpers
466486

487+
/// Extracts a stable ID from a HomeSectionItem for identity purposes.
488+
private static func extractItemId(_ item: HomeSectionItem) -> String {
489+
switch item {
490+
case let .song(song): song.id
491+
case let .album(album): album.id
492+
case let .playlist(playlist): playlist.id
493+
case let .artist(artist): artist.id
494+
}
495+
}
496+
467497
private static func extractCarouselTitle(from data: [String: Any]) -> String? {
468498
if let header = data["header"] as? [String: Any],
469499
let headerRenderer = header["musicCarouselShelfBasicHeaderRenderer"] as? [String: Any]

Core/Services/API/Parsers/ParsingHelpers.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
1+
import CryptoKit
12
import Foundation
23

34
// MARK: - ParsingHelpers
45

56
/// Provides common utility methods for parsing YouTube Music API responses.
67
enum ParsingHelpers {
8+
// MARK: - Stable ID Generation
9+
10+
/// Generates a stable, deterministic ID from content components.
11+
/// This avoids SwiftUI identity churn caused by UUID() regeneration on refresh.
12+
/// - Parameters:
13+
/// - title: Primary identifying text (section/item title)
14+
/// - components: Additional identifying components (first item ID, index, etc.)
15+
/// - Returns: A stable hex string ID derived from the content hash
16+
static func stableId(title: String, components: String...) -> String {
17+
var combined = title
18+
for component in components {
19+
combined += "|" + component
20+
}
21+
let data = Data(combined.utf8)
22+
let hash = SHA256.hash(data: data)
23+
// Use first 16 bytes (32 hex chars) for a compact but collision-resistant ID
24+
return hash.prefix(16).compactMap { String(format: "%02x", $0) }.joined()
25+
}
26+
727
/// Keywords used to identify chart sections for special rendering.
828
static let chartKeywords = [
929
"chart",
@@ -106,7 +126,9 @@ enum ParsingHelpers {
106126
{
107127
artists.append(Artist(id: artistId, name: text))
108128
} else if !text.isEmpty {
109-
artists.append(Artist(id: UUID().uuidString, name: text))
129+
// Generate stable ID from artist name when no browse ID available
130+
let stableArtistId = Self.stableId(title: "artist", components: text)
131+
artists.append(Artist(id: stableArtistId, name: text))
110132
}
111133
}
112134
}

Core/Services/API/Parsers/PodcastParser.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ enum PodcastParser {
6262
}
6363
}
6464
if !items.isEmpty {
65-
sections.append(PodcastSection(id: UUID().uuidString, title: "More", items: items))
65+
// Generate stable ID from title and first item
66+
let firstItemId = items.first.map { Self.extractPodcastItemId($0) } ?? ""
67+
let stableId = ParsingHelpers.stableId(title: "More", components: firstItemId)
68+
sections.append(PodcastSection(id: stableId, title: "More", items: items))
6669
}
6770
}
6871

@@ -112,8 +115,12 @@ enum PodcastParser {
112115

113116
guard !items.isEmpty else { return nil }
114117

118+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
119+
let firstItemId = items.first.map { Self.extractPodcastItemId($0) } ?? ""
120+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
121+
115122
return PodcastSection(
116-
id: UUID().uuidString,
123+
id: stableId,
117124
title: title,
118125
items: items
119126
)
@@ -135,8 +142,12 @@ enum PodcastParser {
135142

136143
guard !items.isEmpty else { return nil }
137144

145+
// Generate stable ID from title and first item to avoid SwiftUI identity churn
146+
let firstItemId = items.first.map { Self.extractPodcastItemId($0) } ?? ""
147+
let stableId = ParsingHelpers.stableId(title: title, components: firstItemId)
148+
138149
return PodcastSection(
139-
id: UUID().uuidString,
150+
id: stableId,
140151
title: title,
141152
items: items
142153
)
@@ -535,6 +546,14 @@ enum PodcastParser {
535546

536547
// MARK: - Helper Methods
537548

549+
/// Extracts a stable ID from a PodcastSectionItem for identity purposes.
550+
private static func extractPodcastItemId(_ item: PodcastSectionItem) -> String {
551+
switch item {
552+
case let .show(show): show.id
553+
case let .episode(episode): episode.id
554+
}
555+
}
556+
538557
private static func extractCarouselTitle(from data: [String: Any]) -> String? {
539558
if let header = data["header"] as? [String: Any],
540559
let headerRenderer = header["musicCarouselShelfBasicHeaderRenderer"] as? [String: Any]

Core/Services/API/Parsers/RadioQueueParser.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ enum RadioQueueParser {
149149
{
150150
browseId
151151
} else {
152-
UUID().uuidString
152+
// Generate stable ID from artist name when no browse ID available
153+
ParsingHelpers.stableId(title: "artist", components: text)
153154
}
154155
artists.append(Artist(id: artistId, name: text))
155156
}

Core/Services/API/Parsers/SongMetadataParser.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ enum SongMetadataParser {
105105
{
106106
browseId
107107
} else {
108-
UUID().uuidString
108+
// Generate stable ID from artist name when no browse ID available
109+
ParsingHelpers.stableId(title: "artist", components: text)
109110
}
110111
artists.append(Artist(id: artistId, name: text))
111112
}

Core/Services/NetworkMonitor.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ final class NetworkMonitor {
6161
}
6262
}
6363

64-
private let monitor: NWPathMonitor
64+
// swiftformat:disable modifierOrder
65+
/// NWPathMonitor accessed from deinit, which is non-isolated in Swift 6.
66+
/// Using nonisolated(unsafe) since monitor.cancel() is thread-safe.
67+
nonisolated(unsafe) private let monitor: NWPathMonitor
68+
// swiftformat:enable modifierOrder
6569
private let queue: DispatchQueue
6670
private let logger = DiagnosticsLogger.network
6771

Core/Services/Notification/NotificationService.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ final class NotificationService {
77
private let playerService: PlayerService
88
private let settingsManager: SettingsManager
99
private let logger = DiagnosticsLogger.notification
10-
private var observationTask: Task<Void, Never>?
10+
// swiftformat:disable modifierOrder
11+
/// Task accessed from deinit, which is non-isolated in Swift 6.
12+
/// Using nonisolated(unsafe) since Task.cancel() is thread-safe.
13+
nonisolated(unsafe) private var observationTask: Task<Void, Never>?
14+
// swiftformat:enable modifierOrder
1115
private var lastNotifiedTrackId: String?
1216

1317
init(playerService: PlayerService, settingsManager: SettingsManager = .shared) {

Core/Services/Player/PlayerService.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,6 @@ final class PlayerService: NSObject, PlayerServiceProtocol {
319319

320320
/// Updates playback state from the persistent WebView observer.
321321
func updatePlaybackState(isPlaying: Bool, progress: Double, duration: Double) {
322-
// Debug log once per second (when progress changes by at least 1 second)
323-
if Int(progress) != Int(self.progress) {
324-
self.logger.debug("updatePlaybackState: isPlaying=\(isPlaying), progress=\(Int(progress)), duration=\(Int(duration))")
325-
}
326-
327322
let previousProgress = self.progress
328323
self.progress = progress
329324
self.duration = duration

Core/Utilities/ImageCache.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,6 @@ actor ImageCache {
135135
}
136136
}
137137

138-
/// Legacy fire-and-forget prefetch for backward compatibility.
139-
func prefetch(urls: [URL]) {
140-
Task.detached(priority: .utility) {
141-
await self.prefetch(urls: urls, targetSize: CGSize(width: 320, height: 320))
142-
}
143-
}
144-
145138
// MARK: - Image Creation with Downsampling
146139

147140
/// Creates an NSImage from data, optionally downsampling for memory efficiency.

0 commit comments

Comments
 (0)