Skip to content

Commit

Permalink
Merge pull request #68 from alexeichhorn/fix/signature-decoding
Browse files Browse the repository at this point in the history
Fix for newest YouTube update
  • Loading branch information
alexeichhorn authored Dec 13, 2024
2 parents c488296 + c59eb8b commit ee5a86f
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 30 deletions.
156 changes: 129 additions & 27 deletions Sources/YouTubeKit/Cipher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,26 +107,44 @@ class Cipher {

/// Extract the name of the function responsible for computing the signature.
class func getInitialFunctionName(js: String) throws -> String {

struct ExtractionRegex {
let regex: NSRegularExpression
let group: Int

init(pattern: String, group: Int) {
self.regex = NSRegularExpression(pattern)
self.group = group
}

func firstMatch(in js: String) -> NSRegularExpression.Match? {
return regex.firstMatch(in: js, group: group)
}
}

// note: make sure patterns don't contain named groups. Instead the function name should be always in group 1
let functionPatterns = [
#"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*([a-zA-Z0-9$]+)\("#,
#"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*([a-zA-Z0-9$]+)\("#,
#"(?:\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2})\s*=\s*function\(\s*a\s*\)\s*\{\s*a\s*=\s*a\.split\(\s*\"\"\s*\)"#, // slight modifications from original
#"([a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*\{\s*a\s*=\s*a\.split\(\s*""\s*\)"#, // escaped {
#"["\']signature["\']\s*,\s*([a-zA-Z0-9$]+)\("#, // slightly modified (weaker condition) to correctly have function name in group 1
#"\.sig\|\|([a-zA-Z0-9$]+)\("#,
#"yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*([a-zA-Z0-9$]+)\("#, // noqa: E501
#"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*([a-zA-Z0-9$]+)\("#, // noqa: E501
#"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*([a-zA-Z0-9$]+)\("#, // noqa: E501
#"\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*([a-zA-Z0-9$]+)\("#, // noqa: E501
#"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*([a-zA-Z0-9$]+)\("#, // noqa: E501
#"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*([a-zA-Z0-9$]+)\("#, // noqa: E501
].map { NSRegularExpression($0) }
ExtractionRegex(pattern: #"\b([a-zA-Z0-9_$]+)&&\(\1=([a-zA-Z0-9_$]{2,})\(decodeURIComponent\(\1\)\)"#, group: 2),
ExtractionRegex(pattern: #"([a-zA-Z0-9_$]+)\s*=\s*function\(\s*([a-zA-Z0-9_$]+)\s*\)\s*\{\s*\2\s*=\s*\2\.split\(\s*\"\"\s*\)\s*;\s*[^}]+;\s*return\s+\2\.join\(\s*\"\"\s*\)"#, group: 1),
ExtractionRegex(pattern: #"(?:\b|[^a-zA-Z0-9_$])([a-zA-Z0-9_$]{2,})\s*=\s*function\(\s*a\s*\)\s*\{\s*a\s*=\s*a\.split\(\s*\"\"\s*\)(?:;[a-zA-Z0-9_$]{2}\.[a-zA-Z0-9_$]{2}\(a,\d+\))?"#, group: 1),
// older
ExtractionRegex(pattern: #"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"(?:\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2})\s*=\s*function\(\s*a\s*\)\s*\{\s*a\s*=\s*a\.split\(\s*""\s*\)"#, group: 1), // slight modifications from original
ExtractionRegex(pattern: #"([a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*\{\s*a\s*=\s*a\.split\(\s*""\s*\)"#, group: 1), // escaped {
ExtractionRegex(pattern: #"["\']signature["\']\s*,\s*([a-zA-Z0-9$]+)\("#, group: 1), // slightly modified (weaker condition) to correctly have function name in group 1
ExtractionRegex(pattern: #"\.sig\|\|([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*([a-zA-Z0-9$]+)\("#, group: 1),
ExtractionRegex(pattern: #"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*([a-zA-Z0-9$]+)\("#, group: 1),
]
os_log("finding initial function name", log: log, type: .debug)

for pattern in functionPatterns {
if let functionMatch = pattern.firstMatch(in: js, group: 1) {
os_log("finished regex search, matched %{public}@", log: log, type: .debug, pattern.pattern)
if let functionMatch = pattern.firstMatch(in: js) {
os_log("finished regex search, matched %{public}@", log: log, type: .debug, pattern.regex.pattern)
return functionMatch.content
}
}
Expand Down Expand Up @@ -204,25 +222,106 @@ class Cipher {

/// Extract the name of the function that computes the throttling parameter.
class func getThrottlingFunctionName(js: String) throws -> String {
let functionPatterns = [
NSRegularExpression(#"a\.[a-zA-Z]\s*&&\s*\([a-z]\s*=\s*a\.get\("n"\)\)\s*&&\s*"#),
NSRegularExpression(#"\([a-z]\s*=\s*([a-zA-Z0-9$]+)(\[\d+\])?\([a-z]\)"#)
]
for pattern in functionPatterns {
guard let (_, functionMatchGroups) = pattern.allMatches(in: js, includingGroups: [1, 2]).first else { continue }
guard let firstGroup = functionMatchGroups[1] else { continue }
guard let secondGroup = functionMatchGroups[2] else {
return firstGroup.content

let functionName: String
let index: Int?

if #available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) {
/*
Full regex pattern:
"""
(?x)
(?:
\.get\("n"\)\)&&\(b=|
(?:
b=String\.fromCharCode\(110\)|
(?P<str_idx>[a-zA-Z0-9_$.]+)&&\(b="nn"\[\+(?P=str_idx)\]
)
(?:
,[a-zA-Z0-9_$]+\(a\))?,c=a\.
(?:
get\(b\)|
[a-zA-Z0-9_$]+\[b\]\|\|null
)\)&&\(c=|
\b(?P<var>[a-zA-Z0-9_$]+)=
)(?P<nfunc>[a-zA-Z0-9_$]+)(?:\[(?P<idx>\d+)\])?\([a-zA-Z]\)
(?(var),[a-zA-Z0-9_$]+\.set\((?:"n+"|[a-zA-Z0-9_$]+)\,(?P=var)\))
"""

-> We split it up in two, as Swift can't handle the conditional (on the last line). So we handle it in code
*/

let patternWithVar = #/
(?x)
(?:
\b(?P<var>[a-zA-Z0-9_$]+)=
)(?P<nfunc>[a-zA-Z0-9_$]+)(?:\[(?P<idx>\d+)\])?\([a-zA-Z]\)
(,[a-zA-Z0-9_$]+\.set\((?:"n+"|[a-zA-Z0-9_$]+)\,(?P=var)\))
/#

let patternWithoutVar = #/
(?x)
(?:
\.get\("n"\)\)&&\(b=|
(?:
b=String\.fromCharCode\(110\)|
(?P<str_idx>[a-zA-Z0-9_$.]+)&&\(b="nn"\[\+(?P=str_idx)\]
)
(?:
,[a-zA-Z0-9_$]+\(a\))?,c=a\.
(?:
get\(b\)|
[a-zA-Z0-9_$]+\[b\]\|\|null
)\)&&\(c=
)(?P<nfunc>[a-zA-Z0-9_$]+)(?:\[(?P<idx>\d+)\])?\([a-zA-Z]\)
/#

if let match = try patternWithVar.firstMatch(in: js) {
functionName = String(match.nfunc)
index = match.idx.flatMap { Int($0) }
} else if let match = try patternWithoutVar.firstMatch(in: js) {
functionName = String(match.nfunc)
index = match.idx.flatMap { Int($0) }
} else {
throw YouTubeKitError.regexMatchError
}

guard let index = Int(secondGroup.content.strip(from: "[]")) else { continue }
let arrayPattern = NSRegularExpression(#"var "# + NSRegularExpression.escapedPattern(for: firstGroup.content) + #"\s*=\s*(\[.+?\]);"#)
guard let index else {
os_log("extracted throttle function name %{public}@ but no index", log: log, type: .error, functionName)
throw YouTubeKitError.regexMatchError
}

let arrayPattern = NSRegularExpression(#"var "# + NSRegularExpression.escapedPattern(for:functionName) + #"\s*=\s*(\[.+?\])\s*[,;]"#)
if let arrayMatch = arrayPattern.firstMatch(in: js, group: 1) {
let array = arrayMatch.content.strip(from: "[]").split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
if array.indices.contains(index) {
return array[index]
}
}

} else {

let functionPatterns = [
NSRegularExpression(#"a\.[a-zA-Z]\s*&&\s*\([a-z]\s*=\s*a\.get\("n"\)\)\s*&&\s*"#),
NSRegularExpression(#"\([a-z]\s*=\s*([a-zA-Z0-9$]+)(\[\d+\])?\([a-z]\)"#)
]

for pattern in functionPatterns {
guard let (_, functionMatchGroups) = pattern.allMatches(in: js, includingGroups: [1, 2]).first else { continue }
guard let firstGroup = functionMatchGroups[1] else { continue }
guard let secondGroup = functionMatchGroups[2] else {
return firstGroup.content
}

guard let index = Int(secondGroup.content.strip(from: "[]")) else { continue }
let arrayPattern = NSRegularExpression(#"var "# + NSRegularExpression.escapedPattern(for: firstGroup.content) + #"\s*=\s*(\[.+?\])\s*[,;]"#)
if let arrayMatch = arrayPattern.firstMatch(in: js, group: 1) {
let array = arrayMatch.content.strip(from: "[]").split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
if array.indices.contains(index) {
return array[index]
}
}
}
}

throw YouTubeKitError.regexMatchError
Expand All @@ -241,7 +340,10 @@ class Cipher {
throw YouTubeKitError.regexMatchError
}

let code = try Parser.findJavascriptFunctionFromStartpoint(html: js, startPoint: match.end)
var code = try Parser.findJavascriptFunctionFromStartpoint(html: js, startPoint: match.end)

// workaround for "typeof" issue
code.replace(NSRegularExpression(#";\s*if\s*\(\s*typeof\s+[a-zA-Z0-9_$]+\s*===?\s*(["\'])undefined\1\s*\)\s*return\s+\#(variableName);"#), with: ";")

return "function \(functionName)(\(variableName)) \(code)"
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/YouTubeKit/Extensions/RegularExpression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,12 @@ extension NSRegularExpression {
}

}

extension String {

mutating func replace(_ pattern: NSRegularExpression, with replacement: String) {
let range = NSRange(location: 0, length: utf16.count)
self = pattern.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replacement)
}

}
3 changes: 2 additions & 1 deletion Sources/YouTubeKit/InnerTube.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class InnerTube {
ClientType.webSafari: Client(name: "WEB", version: "2.20240726.00.00", screen: nil, apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15,gzip(gfe)"),
ClientType.android: Client(name: "ANDROID", version: "19.09.37", screen: nil, apiKey: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", userAgent: "com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip", playerParams: "CgIQBg==", androidSdkVersion: 30),
ClientType.androidMusic: Client(name: "ANDROID_MUSIC", version: "5.16.51", screen: nil, apiKey: "AIzaSyAOghZGza2MQSZkY_zfZ370N-PUdXEo8AI", userAgent: "com.google.android.apps.youtube.music/5.16.51 (Linux; U; Android 11) gzip", playerParams: "CgIQBg==", androidSdkVersion: 30),
ClientType.androidVR: Client(name: "ANDROID_VR", version: "1.60.19", screen: nil, apiKey: "", userAgent: "com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip"),
ClientType.webEmbed: Client(name: "WEB_EMBEDDED_PLAYER", version: "1.20220731.00.00", screen: "EMBED", apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", userAgent: "Mozilla/5.0"),
ClientType.webCreator: Client(name: "WEB_CREATOR", version: "1.20240723.03.00", screen: nil, apiKey: "", userAgent: nil),
ClientType.androidEmbed: Client(name: "ANDROID_EMBEDDED_PLAYER", version: "18.11.34", screen: "EMBED", apiKey: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", userAgent: "com.google.android.youtube/18.11.34 (Linux; U; Android 11) gzip"),
Expand All @@ -59,7 +60,7 @@ class InnerTube {
]

enum ClientType: String {
case web, webSafari, android, androidMusic, webEmbed, webCreator, androidEmbed, tvEmbed, ios, iosMusic, mediaConnectFrontend, mWeb
case web, webSafari, android, androidMusic, androidVR, webEmbed, webCreator, androidEmbed, tvEmbed, ios, iosMusic, mediaConnectFrontend, mWeb
}

private var accessToken: String?
Expand Down
4 changes: 2 additions & 2 deletions Tests/YouTubeKitTests/YouTubeKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ final class YouTubeKitTests: XCTestCase {
}

func testLivestreamHlsManifestUrl() async {
let youtube = YouTube(videoID: "O9mYwRlucZY")
let youtube = YouTube(videoID: "wG4YaEcNlb0")
do {
let livestreams = try await youtube.livestreams
XCTAssert(livestreams.count > 0)
Expand All @@ -182,7 +182,7 @@ final class YouTubeKitTests: XCTestCase {
}

func testLivestreamHlsManifestUrlRemote() async {
let youtube = YouTube(videoID: "O9mYwRlucZY", methods: [.remote])
let youtube = YouTube(videoID: "wG4YaEcNlb0", methods: [.remote])
do {
let livestreams = try await youtube.livestreams
XCTAssert(livestreams.count > 0)
Expand Down

0 comments on commit ee5a86f

Please sign in to comment.