-
-
Notifications
You must be signed in to change notification settings - Fork 112
Expand file tree
/
Copy pathLyricsParser.swift
More file actions
212 lines (179 loc) · 7.35 KB
/
Copy pathLyricsParser.swift
File metadata and controls
212 lines (179 loc) · 7.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import Foundation
/// Parses lyrics responses from YouTube Music API.
enum LyricsParser {
private static let logger = DiagnosticsLogger.api
/// Extracts the lyrics browse ID from the "next" endpoint response.
/// - Parameter data: The response from the "next" endpoint
/// - Returns: The browse ID for fetching lyrics, or nil if unavailable
static func extractLyricsBrowseId(from data: [String: Any]) -> String? {
guard let contents = data["contents"] as? [String: Any],
let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any],
let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any],
let watchNextTabbedResults = tabbedRenderer["watchNextTabbedResultsRenderer"] as? [String: Any],
let tabs = watchNextTabbedResults["tabs"] as? [[String: Any]]
else {
self.logger.debug("LyricsParser: Failed to extract lyrics browse ID structure")
return nil
}
// Find the lyrics tab (usually index 1, but search by content type to be safe)
for tab in tabs {
guard let tabRenderer = tab["tabRenderer"] as? [String: Any],
let endpoint = tabRenderer["endpoint"] as? [String: Any],
let browseEndpoint = endpoint["browseEndpoint"] as? [String: Any],
let browseId = browseEndpoint["browseId"] as? String,
browseId.hasPrefix("MPLYt")
else {
continue
}
return browseId
}
return nil
}
/// Extracts timed lyrics from the browse endpoint or next endpoint response.
/// - Parameter data: The data containing timed lyrics.
/// - Returns: Parsed SyncedLyrics, or nil if unavailable.
static func extractTimedLyrics(from data: [String: Any]) -> SyncedLyrics? {
guard let lines = self.findTimedLyricsLines(in: data), !lines.isEmpty else {
return nil
}
return SyncedLyrics(lines: lines, source: "YTMusic")
}
/// Recursively searches nested dictionaries/arrays for timed lyrics payloads.
private static func findTimedLyricsLines(in node: Any) -> [SyncedLyricLine]? {
if let dictionary = node as? [String: Any] {
if let lines = self.parseTimedLyricsLines(from: dictionary) {
return lines
}
for value in dictionary.values {
if let lines = self.findTimedLyricsLines(in: value) {
return lines
}
}
} else if let array = node as? [Any] {
if let lines = self.parseTimedLyricsLines(from: array) {
return lines
}
for value in array {
if let lines = self.findTimedLyricsLines(in: value) {
return lines
}
}
}
return nil
}
/// Parses a collection that looks like timed lyric entries.
private static func parseTimedLyricsLines(from node: Any) -> [SyncedLyricLine]? {
let entries: [[String: Any]]
if let dictionary = node as? [String: Any] {
if let nested = dictionary["timedLyricsModel"] as? [String: Any] {
return self.parseTimedLyricsLines(from: nested)
}
if let nested = dictionary["timedLyricsData"] {
return self.parseTimedLyricsLines(from: nested)
}
if let nested = dictionary["lyricsData"] {
return self.parseTimedLyricsLines(from: nested)
}
return nil
} else if let array = node as? [Any] {
entries = array.compactMap { $0 as? [String: Any] }
} else {
return nil
}
var lines: [SyncedLyricLine] = []
for entry in entries {
guard let line = self.parseTimedLyricLine(from: entry) else {
continue
}
lines.append(line)
}
return lines.isEmpty ? nil : lines
}
private static func parseTimedLyricLine(from entry: [String: Any]) -> SyncedLyricLine? {
guard let lyricLine = self.timedLyricsText(from: entry),
let startTimeMs = self.startTimeMilliseconds(from: entry)
else {
return nil
}
let durationMs = self.durationMilliseconds(from: entry) ?? 0
return SyncedLyricLine(
timeInMs: startTimeMs,
duration: durationMs,
text: lyricLine,
words: nil
)
}
private static func timedLyricsText(from entry: [String: Any]) -> String? {
for key in ["lyricLine", "text", "line", "lyrics"] {
if let value = entry[key] as? String, !value.isEmpty {
return value
}
}
return nil
}
private static func startTimeMilliseconds(from entry: [String: Any]) -> Int? {
if let direct = self.intValue(for: entry["startTimeMs"]) {
return direct
}
if let cueRange = entry["cueRange"] as? [String: Any] {
return self.intValue(for: cueRange["startTimeMilliseconds"])
}
return nil
}
private static func durationMilliseconds(from entry: [String: Any]) -> Int? {
if let direct = self.intValue(for: entry["durationMs"]) {
return direct
}
if let cueRange = entry["cueRange"] as? [String: Any],
let start = self.intValue(for: cueRange["startTimeMilliseconds"]),
let end = self.intValue(for: cueRange["endTimeMilliseconds"])
{
return max(0, end - start)
}
return nil
}
private static func intValue(for value: Any?) -> Int? {
switch value {
case let value as Int:
value
case let value as String:
Int(value)
case let value as NSNumber:
value.intValue
default:
nil
}
}
/// Parses lyrics from the browse endpoint response.
/// - Parameter data: The response from the browse endpoint
/// - Returns: Parsed lyrics, or `.unavailable` if not found
static func parse(from data: [String: Any]) -> Lyrics {
guard let contents = data["contents"] as? [String: Any],
let sectionListRenderer = contents["sectionListRenderer"] as? [String: Any],
let sectionContents = sectionListRenderer["contents"] as? [[String: Any]]
else {
return .unavailable
}
for section in sectionContents {
// Try musicDescriptionShelfRenderer (plain lyrics)
if let shelfRenderer = section["musicDescriptionShelfRenderer"] as? [String: Any] {
return self.parseLyricsFromShelf(shelfRenderer)
}
}
return .unavailable
}
/// Parses lyrics from a musicDescriptionShelfRenderer.
private static func parseLyricsFromShelf(_ shelf: [String: Any]) -> Lyrics {
// Extract the description (lyrics text)
var lyricsText = ""
if let description = shelf["description"] as? [String: Any],
let runs = description["runs"] as? [[String: Any]]
{
lyricsText = runs.compactMap { $0["text"] as? String }.joined()
}
if lyricsText.isEmpty {
return .unavailable
}
return Lyrics(text: lyricsText, source: "YTMusic")
}
}