Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Core/Models/ArtistDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ struct ArtistDetail: Sendable {
let songsBrowseId: String?
/// Params for loading all songs.
let songsParams: String?
/// Playlist ID for Mix (personalized radio), e.g., "RDEM...".
let mixPlaylistId: String?
/// Starting video ID for Mix.
let mixVideoId: String?

var id: String { self.artist.id }
var name: String { self.artist.name }
Expand All @@ -36,7 +40,9 @@ struct ArtistDetail: Sendable {
subscriberCount: String? = nil,
hasMoreSongs: Bool = false,
songsBrowseId: String? = nil,
songsParams: String? = nil
songsParams: String? = nil,
mixPlaylistId: String? = nil,
mixVideoId: String? = nil
) {
self.artist = artist
self.description = description
Expand All @@ -49,5 +55,7 @@ struct ArtistDetail: Sendable {
self.hasMoreSongs = hasMoreSongs
self.songsBrowseId = songsBrowseId
self.songsParams = songsParams
self.mixPlaylistId = mixPlaylistId
self.mixVideoId = mixVideoId
}
}
34 changes: 34 additions & 0 deletions Core/Services/API/MockUITestYTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,40 @@ final class MockUITestYTMusicClient: YTMusicClientProtocol {
}
}

func getMixQueue(playlistId: String, startVideoId _: String?) async throws -> RadioQueueResult {
try? await Task.sleep(for: .milliseconds(100))
// Return a mix queue based on the playlist ID
let songs = (0 ..< 50).map { index in
Song(
id: "mix-\(playlistId)-\(index)",
title: "Mix Song \(index + 1)",
artists: [Artist(id: "mix-artist-\(index % 5)", name: "Mix Artist \(index % 5 + 1)")],
album: nil,
duration: TimeInterval(180 + index * 5),
thumbnailURL: nil,
videoId: "mix-video-\(playlistId)-\(index)"
)
}
return RadioQueueResult(songs: songs, continuationToken: "mock-continuation-token")
}

func getMixQueueContinuation(continuationToken _: String) async throws -> RadioQueueResult {
try? await Task.sleep(for: .milliseconds(100))
// Return more songs for infinite mix
let songs = (50 ..< 75).map { index in
Song(
id: "mix-continuation-\(index)",
title: "Mix Song \(index + 1)",
artists: [Artist(id: "mix-artist-\(index % 5)", name: "Mix Artist \(index % 5 + 1)")],
album: nil,
duration: TimeInterval(180 + index * 5),
thumbnailURL: nil,
videoId: "mix-video-continuation-\(index)"
)
}
return RadioQueueResult(songs: songs, continuationToken: nil)
}

func getMoodCategory(browseId _: String, params _: String?) async throws -> HomeResponse {
try? await Task.sleep(for: .milliseconds(100))
// Return mock mood category content
Expand Down
45 changes: 44 additions & 1 deletion Core/Services/API/Parsers/ArtistParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ enum ArtistParser {
subscriberCount: headerResult.subscriberCount,
hasMoreSongs: hasMoreSongs,
songsBrowseId: songsBrowseId,
songsParams: songsParams
songsParams: songsParams,
mixPlaylistId: headerResult.mixPlaylistId,
mixVideoId: headerResult.mixVideoId
)
}

Expand All @@ -88,6 +90,8 @@ enum ArtistParser {
var channelId: String?
var isSubscribed: Bool = false
var subscriberCount: String?
var mixPlaylistId: String?
var mixVideoId: String?
}

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

// Parse subscription button for channel ID and subscription status
self.parseSubscriptionButton(from: immersiveHeader, into: &result)

// Parse startRadioButton for mix (personalized radio)
self.parseStartRadioButton(from: immersiveHeader, into: &result)
}

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

// Parse subscription button for channel ID and subscription status
self.parseSubscriptionButton(from: visualHeader, into: &result)

// Parse startRadioButton for mix (personalized radio)
self.parseStartRadioButton(from: visualHeader, into: &result)
}

return result
Expand Down Expand Up @@ -183,6 +193,39 @@ enum ArtistParser {
}
}

// MARK: - Start Radio Button Parsing

/// Parses the startRadioButton to extract mix playlist ID and video ID.
private static func parseStartRadioButton(from header: [String: Any], into result: inout HeaderParseResult) {
// Look for startRadioButton in header
// Path: startRadioButton.buttonRenderer.navigationEndpoint.watchPlaylistEndpoint
guard let startRadioButton = header["startRadioButton"] as? [String: Any],
let buttonRenderer = startRadioButton["buttonRenderer"] as? [String: Any],
let navigationEndpoint = buttonRenderer["navigationEndpoint"] as? [String: Any]
else {
return
}

// Try watchPlaylistEndpoint first (used by artist mix buttons)
if let watchPlaylistEndpoint = navigationEndpoint["watchPlaylistEndpoint"] as? [String: Any] {
if let playlistId = watchPlaylistEndpoint["playlistId"] as? String {
result.mixPlaylistId = playlistId
}
// watchPlaylistEndpoint doesn't have videoId - API picks random start
return
}

// Fall back to watchEndpoint (used by some song radios)
if let watchEndpoint = navigationEndpoint["watchEndpoint"] as? [String: Any] {
if let playlistId = watchEndpoint["playlistId"] as? String {
result.mixPlaylistId = playlistId
}
if let videoId = watchEndpoint["videoId"] as? String {
result.mixVideoId = videoId
}
}
}

// MARK: - Content Parsing

/// Parses songs from artist songs browse response.
Expand Down
60 changes: 55 additions & 5 deletions Core/Services/API/Parsers/RadioQueueParser.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import Foundation

// MARK: - RadioQueueResult

/// Result from parsing a radio queue, including songs and continuation token.
struct RadioQueueResult {
let songs: [Song]
/// Continuation token for fetching more songs (infinite mix).
let continuationToken: String?
}

// MARK: - RadioQueueParser

/// Parses radio queue responses from YouTube Music API.
enum RadioQueueParser {
/// Parses the radio queue from the "next" endpoint response.
/// - Parameter data: The response from the "next" endpoint with a radio playlist ID
/// - Returns: Array of songs in the radio queue
static func parse(from data: [String: Any]) -> [Song] {
/// - Returns: RadioQueueResult containing songs and optional continuation token
static func parse(from data: [String: Any]) -> RadioQueueResult {
guard let contents = data["contents"] as? [String: Any],
let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any],
let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any],
Expand All @@ -19,9 +30,50 @@ enum RadioQueueParser {
let playlistPanelRenderer = queueContent["playlistPanelRenderer"] as? [String: Any],
let playlistContents = playlistPanelRenderer["contents"] as? [[String: Any]]
else {
return []
return RadioQueueResult(songs: [], continuationToken: nil)
}

// Extract continuation token for infinite mix
var continuationToken: String?
if let continuations = playlistPanelRenderer["continuations"] as? [[String: Any]],
let firstContinuation = continuations.first,
let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any],
let token = nextRadioData["continuation"] as? String
{
continuationToken = token
}

let songs = Self.parseSongs(from: playlistContents)
return RadioQueueResult(songs: songs, continuationToken: continuationToken)
}

/// Parses a continuation response for more queue items.
/// - Parameter data: The continuation response
/// - Returns: RadioQueueResult with additional songs and next continuation token
static func parseContinuation(from data: [String: Any]) -> RadioQueueResult {
guard let continuationContents = data["continuationContents"] as? [String: Any],
let playlistPanelContinuation = continuationContents["playlistPanelContinuation"] as? [String: Any],
let contents = playlistPanelContinuation["contents"] as? [[String: Any]]
else {
return RadioQueueResult(songs: [], continuationToken: nil)
}

// Extract next continuation token
var continuationToken: String?
if let continuations = playlistPanelContinuation["continuations"] as? [[String: Any]],
let firstContinuation = continuations.first,
let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any],
let token = nextRadioData["continuation"] as? String
{
continuationToken = token
}

let songs = Self.parseSongs(from: contents)
return RadioQueueResult(songs: songs, continuationToken: continuationToken)
}

/// Parses songs from playlist panel contents.
private static func parseSongs(from playlistContents: [[String: Any]]) -> [Song] {
var songs: [Song] = []
for item in playlistContents {
// Handle both direct and wrapped renderer structures
Expand Down Expand Up @@ -67,8 +119,6 @@ enum RadioQueueParser {
return songs
}

// MARK: - Parsing Helpers

/// Parses the song title from the panel video renderer.
private static func parseTitle(from renderer: [String: Any]) -> String {
if let titleData = renderer["title"] as? [String: Any],
Expand Down
59 changes: 55 additions & 4 deletions Core/Services/API/YTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,21 @@ final class YTMusicClient: YTMusicClientProtocol {
self.hasMoreSections(for: .podcasts)
}

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

/// Makes a continuation request for next/queue endpoints.
private func requestContinuation(_ token: String, body additionalBody: [String: Any]) async throws -> [String: Any] {
var body = additionalBody
body["continuation"] = token
return try await self.request("next", body: body)
}

/// Searches for content.
func search(query: String) async throws -> SearchResponse {
self.logger.info("Searching for: \(query)")
Expand Down Expand Up @@ -693,9 +700,53 @@ final class YTMusicClient: YTMusicClientProtocol {
]

let data = try await request("next", body: body)
let songs = RadioQueueParser.parse(from: data)
self.logger.info("Fetched radio queue with \(songs.count) songs")
return songs
let result = RadioQueueParser.parse(from: data)
self.logger.info("Fetched radio queue with \(result.songs.count) songs")
return result.songs
}

/// Fetches a mix queue from a playlist ID (e.g., artist mix "RDEM...").
/// Uses the "next" endpoint with the provided playlist ID.
/// - Parameters:
/// - playlistId: The mix playlist ID (e.g., "RDEM..." for artist mix)
/// - startVideoId: Optional starting video ID
/// - Returns: RadioQueueResult with songs and continuation token for infinite mix
func getMixQueue(playlistId: String, startVideoId: String?) async throws -> RadioQueueResult {
self.logger.info("Fetching mix queue for playlist: \(playlistId)")

var body: [String: Any] = [
"playlistId": playlistId,
"enablePersistentPlaylistPanel": true,
"isAudioOnly": true,
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
]

// Add video ID if provided to start at a specific track
if let videoId = startVideoId {
body["videoId"] = videoId
}

let data = try await request("next", body: body)
let result = RadioQueueParser.parse(from: data)
self.logger.info("Fetched mix queue with \(result.songs.count) songs, hasContinuation: \(result.continuationToken != nil)")
return result
}

/// Fetches more songs for a mix queue using a continuation token.
/// - Parameter continuationToken: The continuation token from a previous getMixQueue call
/// - Returns: RadioQueueResult with additional songs and next continuation token
func getMixQueueContinuation(continuationToken: String) async throws -> RadioQueueResult {
self.logger.info("Fetching mix queue continuation")

let body: [String: Any] = [
"enablePersistentPlaylistPanel": true,
"isAudioOnly": true,
]

let data = try await requestContinuation(continuationToken, body: body)
let result = RadioQueueParser.parseContinuation(from: data)
self.logger.info("Fetched \(result.songs.count) more songs, hasContinuation: \(result.continuationToken != nil)")
return result
}

// MARK: - Song Metadata
Expand Down
Loading
Loading