Skip to content

Commit 8aa60bb

Browse files
committed
refactor client and queue fixes
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 4a83d96 commit 8aa60bb

File tree

8 files changed

+566
-352
lines changed

8 files changed

+566
-352
lines changed

Core/Services/API/APIExplorer.swift

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -217,38 +217,40 @@ final class APIExplorer {
217217
name: "Library Landing",
218218
description: "Library overview with recent activity",
219219
requiresAuth: true,
220-
isImplemented: false
220+
isImplemented: false,
221+
notes: "Returns HTTP 200 but only login prompt without auth"
221222
),
222223
EndpointConfig(
223224
id: "FEmusic_library_albums",
224225
name: "Library Albums",
225226
description: "Albums saved to user's library",
226227
requiresAuth: true,
227228
isImplemented: false,
228-
notes: "May need special params - currently returns 400"
229+
notes: "Returns HTTP 400 without auth+params. Needs sort params from web client."
229230
),
230231
EndpointConfig(
231232
id: "FEmusic_library_artists",
232233
name: "Library Artists",
233234
description: "Artists the user follows/subscribes to",
234235
requiresAuth: true,
235236
isImplemented: false,
236-
notes: "May need special params - currently returns 400"
237+
notes: "Returns HTTP 400 without auth+params. Needs sort params from web client."
237238
),
238239
EndpointConfig(
239240
id: "FEmusic_library_songs",
240241
name: "Library Songs",
241242
description: "All songs in user's library",
242243
requiresAuth: true,
243244
isImplemented: false,
244-
notes: "May need special params - currently returns 400"
245+
notes: "Returns HTTP 400 without auth+params. Needs sort params from web client."
245246
),
246247
EndpointConfig(
247248
id: "FEmusic_recently_played",
248249
name: "Recently Played",
249250
description: "Quick access to recently played content",
250251
requiresAuth: true,
251-
isImplemented: false
252+
isImplemented: false,
253+
notes: "Returns HTTP 400 without auth. May overlap with FEmusic_history."
252254
),
253255
EndpointConfig(
254256
id: "FEmusic_offline",
@@ -365,36 +367,39 @@ final class APIExplorer {
365367
description: "Get video details, streaming formats, captions",
366368
requiresAuth: false,
367369
isImplemented: false,
368-
notes: "Returns full video metadata without auth"
370+
notes: "Verified: Returns 17+ keys including videoDetails, streamingData (26 formats), playabilityStatus"
369371
),
370372
EndpointConfig(
371373
id: "music/get_queue",
372374
name: "Get Queue",
373375
description: "Get queue data for video IDs",
374376
requiresAuth: false,
375377
isImplemented: false,
376-
notes: "Works without auth for public videos"
378+
notes: "Verified: Returns queueDatas with playlistPanelVideoRenderer per video"
377379
),
378380
EndpointConfig(
379381
id: "playlist/get_add_to_playlist",
380382
name: "Get Add to Playlist",
381383
description: "Get user's playlists for 'Add to Playlist' menu",
382384
requiresAuth: true,
383-
isImplemented: false
385+
isImplemented: false,
386+
notes: "Verified: Returns HTTP 401 without auth"
384387
),
385388
EndpointConfig(
386389
id: "browse/edit_playlist",
387390
name: "Edit Playlist",
388391
description: "Add/remove tracks from a playlist",
389392
requiresAuth: true,
390-
isImplemented: false
393+
isImplemented: false,
394+
notes: "Verified: Returns HTTP 401 without auth"
391395
),
392396
EndpointConfig(
393397
id: "playlist/create",
394398
name: "Create Playlist",
395399
description: "Create a new playlist",
396400
requiresAuth: true,
397-
isImplemented: false
401+
isImplemented: false,
402+
notes: "Verified: Returns HTTP 401 without auth"
398403
),
399404
EndpointConfig(
400405
id: "playlist/delete",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import Foundation
2+
3+
/// Parses lyrics responses from YouTube Music API.
4+
enum LyricsParser {
5+
/// Extracts the lyrics browse ID from the "next" endpoint response.
6+
/// - Parameter data: The response from the "next" endpoint
7+
/// - Returns: The browse ID for fetching lyrics, or nil if unavailable
8+
static func extractLyricsBrowseId(from data: [String: Any]) -> String? {
9+
guard let contents = data["contents"] as? [String: Any],
10+
let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any],
11+
let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any],
12+
let watchNextTabbedResults = tabbedRenderer["watchNextTabbedResultsRenderer"] as? [String: Any],
13+
let tabs = watchNextTabbedResults["tabs"] as? [[String: Any]]
14+
else {
15+
return nil
16+
}
17+
18+
// Find the lyrics tab (usually index 1, but search by content type to be safe)
19+
for tab in tabs {
20+
guard let tabRenderer = tab["tabRenderer"] as? [String: Any],
21+
let endpoint = tabRenderer["endpoint"] as? [String: Any],
22+
let browseEndpoint = endpoint["browseEndpoint"] as? [String: Any],
23+
let browseId = browseEndpoint["browseId"] as? String,
24+
browseId.hasPrefix("MPLYt")
25+
else {
26+
continue
27+
}
28+
return browseId
29+
}
30+
31+
return nil
32+
}
33+
34+
/// Parses lyrics from the browse endpoint response.
35+
/// - Parameter data: The response from the browse endpoint
36+
/// - Returns: Parsed lyrics, or `.unavailable` if not found
37+
static func parse(from data: [String: Any]) -> Lyrics {
38+
guard let contents = data["contents"] as? [String: Any],
39+
let sectionListRenderer = contents["sectionListRenderer"] as? [String: Any],
40+
let sectionContents = sectionListRenderer["contents"] as? [[String: Any]]
41+
else {
42+
return .unavailable
43+
}
44+
45+
for section in sectionContents {
46+
// Try musicDescriptionShelfRenderer (plain lyrics)
47+
if let shelfRenderer = section["musicDescriptionShelfRenderer"] as? [String: Any] {
48+
return self.parseLyricsFromShelf(shelfRenderer)
49+
}
50+
}
51+
52+
return .unavailable
53+
}
54+
55+
/// Parses lyrics from a musicDescriptionShelfRenderer.
56+
private static func parseLyricsFromShelf(_ shelf: [String: Any]) -> Lyrics {
57+
// Extract the description (lyrics text)
58+
var lyricsText = ""
59+
if let description = shelf["description"] as? [String: Any],
60+
let runs = description["runs"] as? [[String: Any]]
61+
{
62+
lyricsText = runs.compactMap { $0["text"] as? String }.joined()
63+
}
64+
65+
// Extract the footer (source attribution)
66+
var source: String?
67+
if let footer = shelf["footer"] as? [String: Any],
68+
let runs = footer["runs"] as? [[String: Any]]
69+
{
70+
source = runs.compactMap { $0["text"] as? String }.joined()
71+
}
72+
73+
if lyricsText.isEmpty {
74+
return .unavailable
75+
}
76+
77+
return Lyrics(text: lyricsText, source: source)
78+
}
79+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Foundation
2+
3+
/// Parses radio queue responses from YouTube Music API.
4+
enum RadioQueueParser {
5+
/// Parses the radio queue from the "next" endpoint response.
6+
/// - 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] {
9+
guard let contents = data["contents"] as? [String: Any],
10+
let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any],
11+
let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any],
12+
let watchNextTabbedResults = tabbedRenderer["watchNextTabbedResultsRenderer"] as? [String: Any],
13+
let tabs = watchNextTabbedResults["tabs"] as? [[String: Any]],
14+
let firstTab = tabs.first,
15+
let tabRenderer = firstTab["tabRenderer"] as? [String: Any],
16+
let tabContent = tabRenderer["content"] as? [String: Any],
17+
let musicQueueRenderer = tabContent["musicQueueRenderer"] as? [String: Any],
18+
let queueContent = musicQueueRenderer["content"] as? [String: Any],
19+
let playlistPanelRenderer = queueContent["playlistPanelRenderer"] as? [String: Any],
20+
let playlistContents = playlistPanelRenderer["contents"] as? [[String: Any]]
21+
else {
22+
return []
23+
}
24+
25+
var songs: [Song] = []
26+
for item in playlistContents {
27+
guard let panelVideoRenderer = item["playlistPanelVideoRenderer"] as? [String: Any] else {
28+
continue
29+
}
30+
31+
// Extract videoId - required field
32+
guard let videoId = panelVideoRenderer["videoId"] as? String else {
33+
continue
34+
}
35+
36+
let title = self.parseTitle(from: panelVideoRenderer)
37+
let artists = self.parseArtists(from: panelVideoRenderer)
38+
let thumbnailURL = self.parseThumbnail(from: panelVideoRenderer)
39+
let duration = self.parseDuration(from: panelVideoRenderer)
40+
41+
let song = Song(
42+
id: videoId,
43+
title: title,
44+
artists: artists,
45+
album: nil,
46+
duration: duration,
47+
thumbnailURL: thumbnailURL,
48+
videoId: videoId
49+
)
50+
songs.append(song)
51+
}
52+
53+
return songs
54+
}
55+
56+
// MARK: - Parsing Helpers
57+
58+
/// Parses the song title from the panel video renderer.
59+
private static func parseTitle(from renderer: [String: Any]) -> String {
60+
if let titleData = renderer["title"] as? [String: Any],
61+
let runs = titleData["runs"] as? [[String: Any]],
62+
let firstRun = runs.first,
63+
let text = firstRun["text"] as? String
64+
{
65+
return text
66+
}
67+
return "Unknown"
68+
}
69+
70+
/// Parses artists from the panel video renderer's longBylineText.
71+
private static func parseArtists(from renderer: [String: Any]) -> [Artist] {
72+
var artists: [Artist] = []
73+
guard let bylineData = renderer["longBylineText"] as? [String: Any],
74+
let runs = bylineData["runs"] as? [[String: Any]]
75+
else { return artists }
76+
77+
for run in runs {
78+
guard let text = run["text"] as? String,
79+
text != "", text != " & ", text != ", ", text != " · "
80+
else { continue }
81+
82+
let artistId: String = if let navEndpoint = run["navigationEndpoint"] as? [String: Any],
83+
let browseEndpoint = navEndpoint["browseEndpoint"] as? [String: Any],
84+
let browseId = browseEndpoint["browseId"] as? String
85+
{
86+
browseId
87+
} else {
88+
UUID().uuidString
89+
}
90+
artists.append(Artist(id: artistId, name: text))
91+
}
92+
return artists
93+
}
94+
95+
/// Parses the thumbnail URL from the panel video renderer.
96+
private static func parseThumbnail(from renderer: [String: Any]) -> URL? {
97+
guard let thumbnail = renderer["thumbnail"] as? [String: Any],
98+
let thumbnails = thumbnail["thumbnails"] as? [[String: Any]],
99+
let lastThumb = thumbnails.last,
100+
let urlString = lastThumb["url"] as? String
101+
else { return nil }
102+
103+
let normalizedURL = urlString.hasPrefix("//") ? "https:" + urlString : urlString
104+
return URL(string: normalizedURL)
105+
}
106+
107+
/// Parses the duration from the panel video renderer.
108+
private static func parseDuration(from renderer: [String: Any]) -> TimeInterval? {
109+
guard let lengthText = renderer["lengthText"] as? [String: Any],
110+
let runs = lengthText["runs"] as? [[String: Any]],
111+
let firstRun = runs.first,
112+
let text = firstRun["text"] as? String
113+
else { return nil }
114+
115+
return ParsingHelpers.parseDuration(text)
116+
}
117+
}

0 commit comments

Comments
 (0)