Skip to content

Commit 3b7b189

Browse files
committed
feat: improve parsers, UI polish, and Universal Binary CI builds
1 parent f497799 commit 3b7b189

24 files changed

+981
-153
lines changed

.github/workflows/dev-build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ jobs:
4444
CODE_SIGN_IDENTITY="" \
4545
CODE_SIGNING_REQUIRED=NO \
4646
CODE_SIGNING_ALLOWED=NO \
47+
ARCHS="arm64 x86_64" \
48+
ONLY_ACTIVE_ARCH=NO \
4749
MARKETING_VERSION="0.0.0-dev.${{ steps.slug.outputs.sha_short }}" \
4850
CURRENT_PROJECT_VERSION="${{ github.run_number }}"
4951

.github/workflows/release.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ jobs:
7272
CODE_SIGN_IDENTITY="" \
7373
CODE_SIGNING_REQUIRED=NO \
7474
CODE_SIGNING_ALLOWED=NO \
75+
ARCHS="arm64 x86_64" \
76+
ONLY_ACTIVE_ARCH=NO \
7577
MARKETING_VERSION="${{ steps.version.outputs.version }}" \
7678
CURRENT_PROJECT_VERSION="${{ github.run_number }}" \
7779
build
@@ -104,7 +106,8 @@ jobs:
104106
ARCHS=$(lipo -archs "$APP_PATH/Contents/MacOS/Kaset" 2>/dev/null || echo "unknown")
105107
echo "Architectures: $ARCHS"
106108
if [[ "$ARCHS" != *"x86_64"* ]] || [[ "$ARCHS" != *"arm64"* ]]; then
107-
echo "Warning: App is not a Universal Binary (found: $ARCHS)"
109+
echo "Error: App is not a Universal Binary (found: $ARCHS)"
110+
exit 1
108111
fi
109112
110113
# Create styled DMG with Applications symlink

Core/Models/LoadingState.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ struct LoadingError: Sendable {
5858
}
5959
}
6060

61-
// MARK: - LoadingState Equatable
61+
// MARK: - LoadingState + Equatable
6262

6363
extension LoadingState: Equatable {
6464
static func == (lhs: LoadingState, rhs: LoadingState) -> Bool {

Core/Models/YTMusicError.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ enum YTMusicError: LocalizedError, Sendable {
7777
case .networkError:
7878
// Network issues are often transient
7979
return true
80-
case .apiError(_, let code):
80+
case let .apiError(_, code):
8181
// Server errors (5xx) are retryable, client errors (4xx) usually aren't
8282
if let code {
8383
return code >= 500
@@ -122,7 +122,7 @@ enum YTMusicError: LocalizedError, Sendable {
122122
"Please sign in to access this content."
123123
case .networkError:
124124
"Unable to connect. Please check your internet connection."
125-
case .apiError(_, let code):
125+
case let .apiError(_, code):
126126
if let code {
127127
"Something went wrong (Error \(code))."
128128
} else {
@@ -132,7 +132,7 @@ enum YTMusicError: LocalizedError, Sendable {
132132
"Unable to load content. Please try again."
133133
case .playbackError:
134134
"Unable to play this track. Try a different one."
135-
case .unknown(let message):
135+
case let .unknown(message):
136136
message
137137
}
138138
}

Core/Services/AI/FoundationModelsService.swift

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,32 @@ import os
77

88
/// Defines the type of AI session to create, each optimized for specific use cases.
99
///
10-
/// Different session types use different temperature settings:
11-
/// - **Command sessions**: Lower temperature (0.7) for more predictable intent parsing
12-
/// - **Analysis sessions**: Higher temperature (1.2) for creative lyric analysis
13-
/// - **Conversational sessions**: Balanced temperature (1.0) for natural dialogue
10+
/// Session types help organize the code and provide semantic meaning,
11+
/// but note that Apple's Foundation Models framework does not expose
12+
/// temperature control in macOS 26. All sessions use the system default.
1413
@available(macOS 26.0, *)
15-
enum AISessionType {
14+
enum AISessionType: String, CaseIterable, Sendable {
1615
/// Quick command parsing (play, skip, queue, etc.)
17-
/// Uses lower temperature for predictable structured output.
16+
/// Best for predictable structured output.
1817
case command
1918

2019
/// Creative analysis (lyrics explanation, recommendations)
21-
/// Uses higher temperature for more insightful, varied responses.
20+
/// Best for more insightful, varied responses.
2221
case analysis
2322

2423
/// Multi-turn conversation (future use)
25-
/// Uses balanced temperature for natural dialogue.
24+
/// Best for natural dialogue.
2625
case conversational
2726

28-
/// Default generation options for this session type.
29-
var generationOptions: GenerationOptions {
27+
/// Human-readable description for logging and debugging.
28+
var description: String {
3029
switch self {
3130
case .command:
32-
// Lower temperature for more deterministic intent parsing
33-
GenerationOptions(temperature: 0.7)
31+
"Command parsing (structured output)"
3432
case .analysis:
35-
// Higher temperature for creative, insightful analysis
36-
GenerationOptions(temperature: 1.2)
33+
"Creative analysis (insightful responses)"
3734
case .conversational:
38-
// Balanced for natural conversation
39-
GenerationOptions(temperature: 1.0)
35+
"Conversational (multi-turn dialogue)"
4036
}
4137
}
4238
}
@@ -232,19 +228,6 @@ final class FoundationModelsService {
232228
self.createCommandSession(instructions: instructions, tools: tools)
233229
}
234230

235-
// MARK: - Generation Options
236-
237-
/// Returns the recommended generation options for a session type.
238-
///
239-
/// Use these options when calling `session.respond(to:generating:options:)`
240-
/// to optimize generation for the specific use case.
241-
///
242-
/// - Parameter type: The type of session/task.
243-
/// - Returns: Configured GenerationOptions with appropriate temperature.
244-
func generationOptions(for type: AISessionType) -> GenerationOptions {
245-
type.generationOptions
246-
}
247-
248231
/// Clears any cached session state.
249232
/// This can help if the model gets into a bad state.
250233
func clearContext() {

Core/Services/API/Parsers/ArtistParser.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,13 @@ enum ArtistParser {
248248
let thumbnails = ParsingHelpers.extractThumbnails(from: responsiveRenderer)
249249
let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } ?? fallbackThumbnailURL
250250
let duration = ParsingHelpers.extractDurationFromFlexColumns(responsiveRenderer)
251+
let album = ParsingHelpers.extractAlbumFromFlexColumns(responsiveRenderer)
251252

252253
let track = Song(
253254
id: videoId,
254255
title: title,
255256
artists: artists,
256-
album: nil,
257+
album: album,
257258
duration: duration,
258259
thumbnailURL: thumbnailURL,
259260
videoId: videoId

Core/Services/API/Parsers/HomeResponseParser.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,12 +410,13 @@ enum HomeResponseParser {
410410
let thumbnails = ParsingHelpers.extractThumbnails(from: data)
411411
let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
412412
let duration = ParsingHelpers.extractDurationFromFlexColumns(data)
413+
let album = ParsingHelpers.extractAlbumFromFlexColumns(data)
413414

414415
let song = Song(
415416
id: videoId,
416417
title: title,
417418
artists: artists,
418-
album: nil,
419+
album: album,
419420
duration: duration,
420421
thumbnailURL: thumbnailURL,
421422
videoId: videoId

Core/Services/API/Parsers/ParsingHelpers.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ enum ParsingHelpers {
7171
}
7272
}
7373

74+
// Try foregroundThumbnail (used by some album/artist headers)
75+
if let foregroundThumbnail = data["foregroundThumbnail"] as? [String: Any] {
76+
if let musicThumbnailRenderer = foregroundThumbnail["musicThumbnailRenderer"] as? [String: Any],
77+
let thumbData = musicThumbnailRenderer["thumbnail"] as? [String: Any],
78+
let thumbnails = thumbData["thumbnails"] as? [[String: Any]]
79+
{
80+
return thumbnails.compactMap { $0["url"] as? String }.map(self.normalizeURL)
81+
}
82+
}
83+
84+
// Try direct thumbnails array at top level
85+
if let thumbnails = data["thumbnails"] as? [[String: Any]] {
86+
return thumbnails.compactMap { $0["url"] as? String }.map(self.normalizeURL)
87+
}
88+
7489
return []
7590
}
7691

@@ -319,4 +334,49 @@ enum ParsingHelpers {
319334

320335
return artists
321336
}
337+
338+
/// Extracts album from flex columns.
339+
/// Album info is typically in the second or third flex column with a browseId starting with MPRE or OLAK.
340+
static func extractAlbumFromFlexColumns(_ data: [String: Any]) -> Album? {
341+
guard let flexColumns = data["flexColumns"] as? [[String: Any]] else {
342+
return nil
343+
}
344+
345+
// Album is typically in the second or third column
346+
// Look through columns 1, 2, and 3 (indices 1, 2, 3) for album data
347+
for columnIndex in 1 ..< min(4, flexColumns.count) {
348+
guard let column = flexColumns[safe: columnIndex],
349+
let renderer = column["musicResponsiveListItemFlexColumnRenderer"] as? [String: Any],
350+
let text = renderer["text"] as? [String: Any],
351+
let runs = text["runs"] as? [[String: Any]]
352+
else {
353+
continue
354+
}
355+
356+
// Look for a run with a navigation endpoint pointing to an album
357+
for run in runs {
358+
guard let albumName = run["text"] as? String,
359+
!albumName.isEmpty,
360+
albumName != "", albumName != " & ", albumName != ", ",
361+
let endpoint = run["navigationEndpoint"] as? [String: Any],
362+
let browseEndpoint = endpoint["browseEndpoint"] as? [String: Any],
363+
let browseId = browseEndpoint["browseId"] as? String,
364+
browseId.hasPrefix("MPRE") || browseId.hasPrefix("OLAK")
365+
else {
366+
continue
367+
}
368+
369+
return Album(
370+
id: browseId,
371+
title: albumName,
372+
artists: nil,
373+
thumbnailURL: nil,
374+
year: nil,
375+
trackCount: nil
376+
)
377+
}
378+
}
379+
380+
return nil
381+
}
322382
}

Core/Services/API/Parsers/PlaylistParser.swift

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,13 @@ enum PlaylistParser {
9191

9292
private static func parsePlaylistHeader(_ data: [String: Any]) -> HeaderData {
9393
var header = HeaderData()
94-
// Try musicDetailHeaderRenderer
95-
if let headerDict = data["header"] as? [String: Any],
96-
let headerRenderer = headerDict["musicDetailHeaderRenderer"] as? [String: Any]
97-
{
94+
95+
guard let headerDict = data["header"] as? [String: Any] else {
96+
return header
97+
}
98+
99+
// Try musicDetailHeaderRenderer (most common for playlists)
100+
if let headerRenderer = headerDict["musicDetailHeaderRenderer"] as? [String: Any] {
98101
if let text = ParsingHelpers.extractTitle(from: headerRenderer) {
99102
header.title = text
100103
}
@@ -123,40 +126,69 @@ enum PlaylistParser {
123126
}
124127
}
125128

126-
// Try musicImmersiveHeaderRenderer
127-
if header.title == "Unknown Playlist",
128-
let headerDict = data["header"] as? [String: Any],
129-
let immersiveHeader = headerDict["musicImmersiveHeaderRenderer"] as? [String: Any]
130-
{
131-
if let text = ParsingHelpers.extractTitle(from: immersiveHeader) {
129+
// Try musicImmersiveHeaderRenderer (used by some albums)
130+
if let immersiveHeader = headerDict["musicImmersiveHeaderRenderer"] as? [String: Any] {
131+
if header.title == "Unknown Playlist",
132+
let text = ParsingHelpers.extractTitle(from: immersiveHeader)
133+
{
132134
header.title = text
133135
}
134136

135-
let thumbnails = ParsingHelpers.extractThumbnails(from: immersiveHeader)
136-
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
137+
// Always try to get thumbnail from immersive header if we don't have one
138+
if header.thumbnailURL == nil {
139+
let thumbnails = ParsingHelpers.extractThumbnails(from: immersiveHeader)
140+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
141+
}
137142

138-
if let descData = immersiveHeader["description"] as? [String: Any],
143+
if header.description == nil,
144+
let descData = immersiveHeader["description"] as? [String: Any],
139145
let runs = descData["runs"] as? [[String: Any]]
140146
{
141147
header.description = runs.compactMap { $0["text"] as? String }.joined()
142148
}
149+
150+
// Try subtitle for author if not set
151+
if header.author == nil,
152+
let subtitleData = immersiveHeader["subtitle"] as? [String: Any],
153+
let runs = subtitleData["runs"] as? [[String: Any]]
154+
{
155+
let texts = runs.compactMap { $0["text"] as? String }
156+
header.author = texts.first
157+
}
158+
}
159+
160+
// Try musicVisualHeaderRenderer (alternative format for some albums/artists)
161+
if let visualHeader = headerDict["musicVisualHeaderRenderer"] as? [String: Any] {
162+
if header.title == "Unknown Playlist",
163+
let text = ParsingHelpers.extractTitle(from: visualHeader)
164+
{
165+
header.title = text
166+
}
167+
168+
if header.thumbnailURL == nil {
169+
let thumbnails = ParsingHelpers.extractThumbnails(from: visualHeader)
170+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
171+
}
143172
}
144173

145-
// Try musicEditablePlaylistDetailHeaderRenderer
146-
if header.title == "Unknown Playlist",
147-
let headerDict = data["header"] as? [String: Any],
148-
let editableHeader = headerDict["musicEditablePlaylistDetailHeaderRenderer"] as? [String: Any],
174+
// Try musicEditablePlaylistDetailHeaderRenderer (for user-editable playlists)
175+
if let editableHeader = headerDict["musicEditablePlaylistDetailHeaderRenderer"] as? [String: Any],
149176
let nestedHeaderData = editableHeader["header"] as? [String: Any],
150177
let detailHeader = nestedHeaderData["musicDetailHeaderRenderer"] as? [String: Any]
151178
{
152-
if let text = ParsingHelpers.extractTitle(from: detailHeader) {
179+
if header.title == "Unknown Playlist",
180+
let text = ParsingHelpers.extractTitle(from: detailHeader)
181+
{
153182
header.title = text
154183
}
155184

156-
let thumbnails = ParsingHelpers.extractThumbnails(from: detailHeader)
157-
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
185+
if header.thumbnailURL == nil {
186+
let thumbnails = ParsingHelpers.extractThumbnails(from: detailHeader)
187+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
188+
}
158189

159-
if let subtitleData = detailHeader["subtitle"] as? [String: Any],
190+
if header.author == nil,
191+
let subtitleData = detailHeader["subtitle"] as? [String: Any],
160192
let runs = subtitleData["runs"] as? [[String: Any]]
161193
{
162194
let texts = runs.compactMap { $0["text"] as? String }
@@ -268,12 +300,13 @@ enum PlaylistParser {
268300
let thumbnails = ParsingHelpers.extractThumbnails(from: responsiveRenderer)
269301
let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } ?? fallbackThumbnailURL
270302
let duration = ParsingHelpers.extractDurationFromFlexColumns(responsiveRenderer)
303+
let album = ParsingHelpers.extractAlbumFromFlexColumns(responsiveRenderer)
271304

272305
return Song(
273306
id: videoId,
274307
title: title,
275308
artists: artists,
276-
album: nil,
309+
album: album,
277310
duration: duration,
278311
thumbnailURL: thumbnailURL,
279312
videoId: videoId

Core/Services/API/Parsers/SearchResponseParser.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,13 @@ enum SearchResponseParser {
231231
let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
232232
let title = ParsingHelpers.extractTitleFromFlexColumns(data) ?? "Unknown"
233233
let artists = ParsingHelpers.extractArtistsFromFlexColumns(data)
234+
let album = ParsingHelpers.extractAlbumFromFlexColumns(data)
234235

235236
let song = Song(
236237
id: videoId,
237238
title: title,
238239
artists: artists,
239-
album: nil,
240+
album: album,
240241
duration: nil,
241242
thumbnailURL: thumbnailURL,
242243
videoId: videoId

0 commit comments

Comments
 (0)