|
| 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