Skip to content
Open
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
1 change: 1 addition & 0 deletions Sources/Kaset/KasetApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ struct KasetApp: App {
_syncedLyricsService = State(initialValue: SyncedLyricsService(providers: [
YTMusicSyncedProvider(client: client),
LRCLibProvider(),
MusixMatchProvider(),
]))
_notificationService = State(initialValue: NotificationService(playerService: player))
_accountService = State(initialValue: account)
Expand Down
154 changes: 114 additions & 40 deletions Sources/Kaset/Services/API/Parsers/LyricsParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,58 +38,140 @@ enum LyricsParser {
/// - Parameter data: The data containing timed lyrics.
/// - Returns: Parsed SyncedLyrics, or nil if unavailable.
static func extractTimedLyrics(from data: [String: Any]) -> SyncedLyrics? {
guard let timedLyricsModel = self.findTimedLyricsModel(in: data),
let lyricsData = timedLyricsModel["lyricsData"] as? [[String: Any]]
else {
return nil
}

var lines: [SyncedLyricLine] = []
for lineData in lyricsData {
if let lyricLine = lineData["lyricLine"] as? String,
let startTimeStr = lineData["startTimeMs"] as? String,
let startTimeMs = Int(startTimeStr)
{
let durationMs = (lineData["durationMs"] as? String).flatMap(Int.init) ?? 0
lines.append(SyncedLyricLine(
timeInMs: startTimeMs,
duration: durationMs,
text: lyricLine,
words: nil
))
}
}

if lines.isEmpty {
guard let lines = self.findTimedLyricsLines(in: data), !lines.isEmpty else {
return nil
}

return SyncedLyrics(lines: lines, source: "YTMusic")
}

/// Recursively searches nested dictionaries/arrays for a timedLyricsModel payload.
private static func findTimedLyricsModel(in node: Any) -> [String: Any]? {
/// 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 timedLyricsModel = dictionary["timedLyricsModel"] as? [String: Any] {
return timedLyricsModel
if let lines = self.parseTimedLyricsLines(from: dictionary) {
return lines
}

for value in dictionary.values {
if let timedLyricsModel = self.findTimedLyricsModel(in: value) {
return timedLyricsModel
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 timedLyricsModel = self.findTimedLyricsModel(in: value) {
return timedLyricsModel
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
Expand Down Expand Up @@ -121,18 +203,10 @@ enum LyricsParser {
lyricsText = runs.compactMap { $0["text"] as? String }.joined()
}

// Extract the footer (source attribution)
var source: String?
if let footer = shelf["footer"] as? [String: Any],
let runs = footer["runs"] as? [[String: Any]]
{
source = runs.compactMap { $0["text"] as? String }.joined()
}

if lyricsText.isEmpty {
return .unavailable
}

return Lyrics(text: lyricsText, source: source)
return Lyrics(text: lyricsText, source: "YTMusic")
}
}
69 changes: 61 additions & 8 deletions Sources/Kaset/Services/API/YTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ final class YTMusicClient: YTMusicClientProtocol {
private let authService: AuthService
private let webKitManager: WebKitManager
private let session: URLSession
private let proxySession: URLSession
private let logger = DiagnosticsLogger.api

/// Provider for the current brand account ID.
Expand All @@ -55,6 +56,11 @@ final class YTMusicClient: YTMusicClientProtocol {
/// Client version for WEB_REMIX.
private static let clientVersion = "1.20231204.01.00"

/// Pear-style lyrics proxy configuration.
private static let lyricsProxyBaseURL = "https://ytmbrowseproxy.zvz.be"
private static let lyricsProxyClientName = "26"
private static let lyricsProxyClientVersion = "7.01.05"

/// Centralized storage for continuation tokens keyed by content type.
private var continuationTokens: [PaginatedContentType: String] = [:]

Expand All @@ -79,6 +85,18 @@ final class YTMusicClient: YTMusicClientProtocol {
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
self.session = URLSession(configuration: configuration)

let proxyConfiguration = URLSessionConfiguration.ephemeral
proxyConfiguration.httpAdditionalHeaders = [
"Accept": "application/json",
"Content-Type": "application/json",
]
proxyConfiguration.httpShouldSetCookies = false
proxyConfiguration.httpCookieStorage = nil
proxyConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
proxyConfiguration.timeoutIntervalForRequest = 15
proxyConfiguration.timeoutIntervalForResource = 30
self.proxySession = URLSession(configuration: proxyConfiguration)
}

// MARK: - Generic Pagination Methods
Expand Down Expand Up @@ -1093,7 +1111,7 @@ final class YTMusicClient: YTMusicClientProtocol {
}

/// Fetches timed (synced) lyrics for a song from YouTube Music.
/// Checks the "next" endpoint for timedLyricsModel data, then falls back to browse endpoint for plain lyrics.
/// Uses the lyrics tab from `next`, then fetches the lyrics payload through the pear-style proxy browse endpoint.
func getTimedLyrics(videoId: String) async throws -> LyricResult {
self.logger.info("Fetching timed lyrics for: \(videoId)")

Expand All @@ -1106,21 +1124,20 @@ final class YTMusicClient: YTMusicClientProtocol {

let nextData = try await request("next", body: nextBody)

// Try to extract timed lyrics first
if let synced = LyricsParser.extractTimedLyrics(from: nextData) {
self.logger.info("Found timed lyrics for \(videoId): \(synced.lines.count) lines")
return .synced(synced)
}

// Fall back to plain lyrics via browse endpoint
if let lyricsBrowseId = LyricsParser.extractLyricsBrowseId(from: nextData) {
let browseBody: [String: Any] = [
"browseId": lyricsBrowseId,
]
let browseData = try await request("browse", body: browseBody, ttl: APICache.TTL.lyrics)
let browseData = try await self.requestLyricsProxy(browseId: lyricsBrowseId)
if let synced = LyricsParser.extractTimedLyrics(from: browseData) {
self.logger.info("Found timed lyrics in proxy browse payload for \(videoId): \(synced.lines.count) lines")
return .synced(synced)
}
let lyrics = LyricsParser.parse(from: browseData)
if lyrics.isAvailable {
self.logger.info("Fell back to plain lyrics for \(videoId)")
self.logger.info("Fell back to plain lyrics for \(videoId) via proxy browse payload")
return .plain(lyrics)
}
}
Expand All @@ -1129,6 +1146,42 @@ final class YTMusicClient: YTMusicClientProtocol {
return .unavailable
}

/// Fetches a lyrics browse payload from the pear-style proxy.
private func requestLyricsProxy(browseId: String) async throws -> [String: Any] {
let urlString = "\(Self.lyricsProxyBaseURL)/browse?prettyPrint=false"
guard let url = URL(string: urlString) else {
throw YTMusicError.unknown(message: "Invalid lyrics proxy URL: \(urlString)")
}

let body: [String: Any] = [
"browseId": browseId,
"context": [
"client": [
"clientName": Self.lyricsProxyClientName,
"clientVersion": Self.lyricsProxyClientVersion,
],
],
]

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

let (data, response) = try await self.proxySession.data(for: request)
if let httpResponse = response as? HTTPURLResponse, !(200 ..< 300).contains(httpResponse.statusCode) {
throw YTMusicError.apiError(message: "Lyrics proxy HTTP \(httpResponse.statusCode)", code: httpResponse.statusCode)
}

let json = try JSONSerialization.jsonObject(with: data)
guard let dictionary = json as? [String: Any] else {
throw YTMusicError.parseError(message: "Invalid lyrics proxy response")
}

return dictionary
}

// MARK: - Radio Queue

/// Fetches a radio queue (similar songs) based on a video ID.
Expand Down
Loading
Loading