Skip to content

Commit 2b07776

Browse files
authored
feat: mix and radio (#26)
1 parent 2929c6f commit 2b07776

22 files changed

+913
-398
lines changed

Core/Models/ArtistDetail.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ struct ArtistDetail: Sendable {
2121
let songsBrowseId: String?
2222
/// Params for loading all songs.
2323
let songsParams: String?
24+
/// Playlist ID for Mix (personalized radio), e.g., "RDEM...".
25+
let mixPlaylistId: String?
26+
/// Starting video ID for Mix.
27+
let mixVideoId: String?
2428

2529
var id: String { self.artist.id }
2630
var name: String { self.artist.name }
@@ -36,7 +40,9 @@ struct ArtistDetail: Sendable {
3640
subscriberCount: String? = nil,
3741
hasMoreSongs: Bool = false,
3842
songsBrowseId: String? = nil,
39-
songsParams: String? = nil
43+
songsParams: String? = nil,
44+
mixPlaylistId: String? = nil,
45+
mixVideoId: String? = nil
4046
) {
4147
self.artist = artist
4248
self.description = description
@@ -49,5 +55,7 @@ struct ArtistDetail: Sendable {
4955
self.hasMoreSongs = hasMoreSongs
5056
self.songsBrowseId = songsBrowseId
5157
self.songsParams = songsParams
58+
self.mixPlaylistId = mixPlaylistId
59+
self.mixVideoId = mixVideoId
5260
}
5361
}

Core/Services/API/MockUITestYTMusicClient.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,40 @@ final class MockUITestYTMusicClient: YTMusicClientProtocol {
302302
}
303303
}
304304

305+
func getMixQueue(playlistId: String, startVideoId _: String?) async throws -> RadioQueueResult {
306+
try? await Task.sleep(for: .milliseconds(100))
307+
// Return a mix queue based on the playlist ID
308+
let songs = (0 ..< 50).map { index in
309+
Song(
310+
id: "mix-\(playlistId)-\(index)",
311+
title: "Mix Song \(index + 1)",
312+
artists: [Artist(id: "mix-artist-\(index % 5)", name: "Mix Artist \(index % 5 + 1)")],
313+
album: nil,
314+
duration: TimeInterval(180 + index * 5),
315+
thumbnailURL: nil,
316+
videoId: "mix-video-\(playlistId)-\(index)"
317+
)
318+
}
319+
return RadioQueueResult(songs: songs, continuationToken: "mock-continuation-token")
320+
}
321+
322+
func getMixQueueContinuation(continuationToken _: String) async throws -> RadioQueueResult {
323+
try? await Task.sleep(for: .milliseconds(100))
324+
// Return more songs for infinite mix
325+
let songs = (50 ..< 75).map { index in
326+
Song(
327+
id: "mix-continuation-\(index)",
328+
title: "Mix Song \(index + 1)",
329+
artists: [Artist(id: "mix-artist-\(index % 5)", name: "Mix Artist \(index % 5 + 1)")],
330+
album: nil,
331+
duration: TimeInterval(180 + index * 5),
332+
thumbnailURL: nil,
333+
videoId: "mix-video-continuation-\(index)"
334+
)
335+
}
336+
return RadioQueueResult(songs: songs, continuationToken: nil)
337+
}
338+
305339
func getMoodCategory(browseId _: String, params _: String?) async throws -> HomeResponse {
306340
try? await Task.sleep(for: .milliseconds(100))
307341
// Return mock mood category content

Core/Services/API/Parsers/ArtistParser.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ enum ArtistParser {
7474
subscriberCount: headerResult.subscriberCount,
7575
hasMoreSongs: hasMoreSongs,
7676
songsBrowseId: songsBrowseId,
77-
songsParams: songsParams
77+
songsParams: songsParams,
78+
mixPlaylistId: headerResult.mixPlaylistId,
79+
mixVideoId: headerResult.mixVideoId
7880
)
7981
}
8082

@@ -88,6 +90,8 @@ enum ArtistParser {
8890
var channelId: String?
8991
var isSubscribed: Bool = false
9092
var subscriberCount: String?
93+
var mixPlaylistId: String?
94+
var mixVideoId: String?
9195
}
9296

9397
private static func parseArtistHeader(_ data: [String: Any], artistId: String) -> HeaderParseResult {
@@ -113,6 +117,9 @@ enum ArtistParser {
113117

114118
// Parse subscription button for channel ID and subscription status
115119
self.parseSubscriptionButton(from: immersiveHeader, into: &result)
120+
121+
// Parse startRadioButton for mix (personalized radio)
122+
self.parseStartRadioButton(from: immersiveHeader, into: &result)
116123
}
117124

118125
// Try musicVisualHeaderRenderer (alternative header format)
@@ -129,6 +136,9 @@ enum ArtistParser {
129136

130137
// Parse subscription button for channel ID and subscription status
131138
self.parseSubscriptionButton(from: visualHeader, into: &result)
139+
140+
// Parse startRadioButton for mix (personalized radio)
141+
self.parseStartRadioButton(from: visualHeader, into: &result)
132142
}
133143

134144
return result
@@ -183,6 +193,39 @@ enum ArtistParser {
183193
}
184194
}
185195

196+
// MARK: - Start Radio Button Parsing
197+
198+
/// Parses the startRadioButton to extract mix playlist ID and video ID.
199+
private static func parseStartRadioButton(from header: [String: Any], into result: inout HeaderParseResult) {
200+
// Look for startRadioButton in header
201+
// Path: startRadioButton.buttonRenderer.navigationEndpoint.watchPlaylistEndpoint
202+
guard let startRadioButton = header["startRadioButton"] as? [String: Any],
203+
let buttonRenderer = startRadioButton["buttonRenderer"] as? [String: Any],
204+
let navigationEndpoint = buttonRenderer["navigationEndpoint"] as? [String: Any]
205+
else {
206+
return
207+
}
208+
209+
// Try watchPlaylistEndpoint first (used by artist mix buttons)
210+
if let watchPlaylistEndpoint = navigationEndpoint["watchPlaylistEndpoint"] as? [String: Any] {
211+
if let playlistId = watchPlaylistEndpoint["playlistId"] as? String {
212+
result.mixPlaylistId = playlistId
213+
}
214+
// watchPlaylistEndpoint doesn't have videoId - API picks random start
215+
return
216+
}
217+
218+
// Fall back to watchEndpoint (used by some song radios)
219+
if let watchEndpoint = navigationEndpoint["watchEndpoint"] as? [String: Any] {
220+
if let playlistId = watchEndpoint["playlistId"] as? String {
221+
result.mixPlaylistId = playlistId
222+
}
223+
if let videoId = watchEndpoint["videoId"] as? String {
224+
result.mixVideoId = videoId
225+
}
226+
}
227+
}
228+
186229
// MARK: - Content Parsing
187230

188231
/// Parses songs from artist songs browse response.

Core/Services/API/Parsers/RadioQueueParser.swift

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import Foundation
22

3+
// MARK: - RadioQueueResult
4+
5+
/// Result from parsing a radio queue, including songs and continuation token.
6+
struct RadioQueueResult {
7+
let songs: [Song]
8+
/// Continuation token for fetching more songs (infinite mix).
9+
let continuationToken: String?
10+
}
11+
12+
// MARK: - RadioQueueParser
13+
314
/// Parses radio queue responses from YouTube Music API.
415
enum RadioQueueParser {
516
/// Parses the radio queue from the "next" endpoint response.
617
/// - Parameter data: The response from the "next" endpoint with a radio playlist ID
7-
/// - Returns: Array of songs in the radio queue
8-
static func parse(from data: [String: Any]) -> [Song] {
18+
/// - Returns: RadioQueueResult containing songs and optional continuation token
19+
static func parse(from data: [String: Any]) -> RadioQueueResult {
920
guard let contents = data["contents"] as? [String: Any],
1021
let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any],
1122
let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any],
@@ -19,9 +30,50 @@ enum RadioQueueParser {
1930
let playlistPanelRenderer = queueContent["playlistPanelRenderer"] as? [String: Any],
2031
let playlistContents = playlistPanelRenderer["contents"] as? [[String: Any]]
2132
else {
22-
return []
33+
return RadioQueueResult(songs: [], continuationToken: nil)
34+
}
35+
36+
// Extract continuation token for infinite mix
37+
var continuationToken: String?
38+
if let continuations = playlistPanelRenderer["continuations"] as? [[String: Any]],
39+
let firstContinuation = continuations.first,
40+
let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any],
41+
let token = nextRadioData["continuation"] as? String
42+
{
43+
continuationToken = token
44+
}
45+
46+
let songs = Self.parseSongs(from: playlistContents)
47+
return RadioQueueResult(songs: songs, continuationToken: continuationToken)
48+
}
49+
50+
/// Parses a continuation response for more queue items.
51+
/// - Parameter data: The continuation response
52+
/// - Returns: RadioQueueResult with additional songs and next continuation token
53+
static func parseContinuation(from data: [String: Any]) -> RadioQueueResult {
54+
guard let continuationContents = data["continuationContents"] as? [String: Any],
55+
let playlistPanelContinuation = continuationContents["playlistPanelContinuation"] as? [String: Any],
56+
let contents = playlistPanelContinuation["contents"] as? [[String: Any]]
57+
else {
58+
return RadioQueueResult(songs: [], continuationToken: nil)
2359
}
2460

61+
// Extract next continuation token
62+
var continuationToken: String?
63+
if let continuations = playlistPanelContinuation["continuations"] as? [[String: Any]],
64+
let firstContinuation = continuations.first,
65+
let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any],
66+
let token = nextRadioData["continuation"] as? String
67+
{
68+
continuationToken = token
69+
}
70+
71+
let songs = Self.parseSongs(from: contents)
72+
return RadioQueueResult(songs: songs, continuationToken: continuationToken)
73+
}
74+
75+
/// Parses songs from playlist panel contents.
76+
private static func parseSongs(from playlistContents: [[String: Any]]) -> [Song] {
2577
var songs: [Song] = []
2678
for item in playlistContents {
2779
// Handle both direct and wrapped renderer structures
@@ -67,8 +119,6 @@ enum RadioQueueParser {
67119
return songs
68120
}
69121

70-
// MARK: - Parsing Helpers
71-
72122
/// Parses the song title from the panel video renderer.
73123
private static func parseTitle(from renderer: [String: Any]) -> String {
74124
if let titleData = renderer["title"] as? [String: Any],

Core/Services/API/YTMusicClient.swift

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,21 @@ final class YTMusicClient: YTMusicClientProtocol {
209209
self.hasMoreSections(for: .podcasts)
210210
}
211211

212-
/// Makes a continuation request.
212+
/// Makes a continuation request for browse endpoints.
213213
private func requestContinuation(_ token: String, ttl: TimeInterval? = APICache.TTL.home) async throws -> [String: Any] {
214214
let body: [String: Any] = [
215215
"continuation": token,
216216
]
217217
return try await self.request("browse", body: body, ttl: ttl)
218218
}
219219

220+
/// Makes a continuation request for next/queue endpoints.
221+
private func requestContinuation(_ token: String, body additionalBody: [String: Any]) async throws -> [String: Any] {
222+
var body = additionalBody
223+
body["continuation"] = token
224+
return try await self.request("next", body: body)
225+
}
226+
220227
/// Searches for content.
221228
func search(query: String) async throws -> SearchResponse {
222229
self.logger.info("Searching for: \(query)")
@@ -693,9 +700,53 @@ final class YTMusicClient: YTMusicClientProtocol {
693700
]
694701

695702
let data = try await request("next", body: body)
696-
let songs = RadioQueueParser.parse(from: data)
697-
self.logger.info("Fetched radio queue with \(songs.count) songs")
698-
return songs
703+
let result = RadioQueueParser.parse(from: data)
704+
self.logger.info("Fetched radio queue with \(result.songs.count) songs")
705+
return result.songs
706+
}
707+
708+
/// Fetches a mix queue from a playlist ID (e.g., artist mix "RDEM...").
709+
/// Uses the "next" endpoint with the provided playlist ID.
710+
/// - Parameters:
711+
/// - playlistId: The mix playlist ID (e.g., "RDEM..." for artist mix)
712+
/// - startVideoId: Optional starting video ID
713+
/// - Returns: RadioQueueResult with songs and continuation token for infinite mix
714+
func getMixQueue(playlistId: String, startVideoId: String?) async throws -> RadioQueueResult {
715+
self.logger.info("Fetching mix queue for playlist: \(playlistId)")
716+
717+
var body: [String: Any] = [
718+
"playlistId": playlistId,
719+
"enablePersistentPlaylistPanel": true,
720+
"isAudioOnly": true,
721+
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
722+
]
723+
724+
// Add video ID if provided to start at a specific track
725+
if let videoId = startVideoId {
726+
body["videoId"] = videoId
727+
}
728+
729+
let data = try await request("next", body: body)
730+
let result = RadioQueueParser.parse(from: data)
731+
self.logger.info("Fetched mix queue with \(result.songs.count) songs, hasContinuation: \(result.continuationToken != nil)")
732+
return result
733+
}
734+
735+
/// Fetches more songs for a mix queue using a continuation token.
736+
/// - Parameter continuationToken: The continuation token from a previous getMixQueue call
737+
/// - Returns: RadioQueueResult with additional songs and next continuation token
738+
func getMixQueueContinuation(continuationToken: String) async throws -> RadioQueueResult {
739+
self.logger.info("Fetching mix queue continuation")
740+
741+
let body: [String: Any] = [
742+
"enablePersistentPlaylistPanel": true,
743+
"isAudioOnly": true,
744+
]
745+
746+
let data = try await requestContinuation(continuationToken, body: body)
747+
let result = RadioQueueParser.parseContinuation(from: data)
748+
self.logger.info("Fetched \(result.songs.count) more songs, hasContinuation: \(result.continuationToken != nil)")
749+
return result
699750
}
700751

701752
// MARK: - Song Metadata

0 commit comments

Comments
 (0)