diff --git a/Sources/YouTubeKit/Cipher.swift b/Sources/YouTubeKit/Cipher.swift index 46917d7..05c0b44 100644 --- a/Sources/YouTubeKit/Cipher.swift +++ b/Sources/YouTubeKit/Cipher.swift @@ -12,6 +12,16 @@ import JavaScriptCore import os.log @available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) +/// Handles YouTube signature deciphering and throttling parameter calculation. +/// +/// This class is responsible for: +/// 1. Extracting and decoding the signature transformation functions from YouTube's JavaScript +/// 2. Applying these transformations to decipher video signatures +/// 3. Calculating the 'n' parameter to prevent throttling +/// +/// The implementation has been updated to dynamically detect the global variable used for +/// signature algorithms, based on the fix from YouTube.js PR #953, which improves compatibility +/// with YouTube's frequently changing obfuscation techniques. class Cipher { let js: String @@ -19,9 +29,11 @@ class Cipher { private let transformPlan: [(func: JSFunction, param: Int)] private let transformMap: [String: JSFunction] - private let jsFuncPatterns = [ + private let jsFuncPatterns = [ NSRegularExpression(#"\w+\.(\w+)\(\w,(\d+)\)"#), - NSRegularExpression(#"\w+\[(\"\w+\")\]\(\w,(\d+)\)"#) + NSRegularExpression(#"\w+\[(\"\w+\")\]\(\w,(\d+)\)"#), + NSRegularExpression(#"function\(\w\)\{[\w=.]*;(.*);"#), + NSRegularExpression(#"\b([a-zA-Z0-9_$]+)\s*=\s*function\(\s*([a-zA-Z0-9_$]+)\s*\)"#) ] private let nParameterFunction: String @@ -30,25 +42,28 @@ class Cipher { private static let log = OSLog(Cipher.self) + /// Initializes the Cipher with YouTube player JavaScript. + /// + /// This implementation dynamically detects the global variable used for signature algorithms + /// based on the fix from YouTube.js PR #953. Instead of using a hardcoded variable name ("DE"), + /// it now attempts to extract the variable name from the JavaScript code, falling back to "DE" + /// only if extraction fails. + /// + /// - Parameter js: The JavaScript content from YouTube player + /// - Throws: YouTubeKitError if parsing fails init(js: String) throws { self.js = js + // Extract the global variable used for signature algorithm + let globalVariable = Extraction.extractGlobalVariable(js: js) ?? "DE" + os_log("Using global variable: %{public}@", log: Cipher.log, type: .debug, globalVariable) - /*let rawTransformPlan = try Cipher.getRawTransformPlan(js: js) - - let varRegex = NSRegularExpression(#"^\$*\w+\W"#) - guard let varMatch = varRegex.firstMatch(in: rawTransformPlan[0], group: 0) else { - throw YouTubeKitError.regexMatchError - } - var variable = varMatch.content - _ = variable.popLast() - - self.transformMap = try Cipher.getTransformMap(js: js, variable: variable) - self.transformPlan = try Cipher.getDecodedTransformPlan(rawPlan: rawTransformPlan, variable: variable, transformMap: transformMap)*/ - // -> temporarily disabled (as mostly unused) - self.transformMap = [:] - self.transformPlan = [] - - self.nParameterFunction = try Cipher.getThrottlingFunctionCode(js: js) //try Cipher.getNParameterFunction(js: js) + // Implement transformPlan and transformMap logic based on TypeScript fixes + let transformObject = try Cipher.getTransformObject(js: js, variable: globalVariable) + self.transformMap = try Cipher.getTransformMap(js: js, variable: globalVariable) + let rawPlan = try Cipher.getRawTransformPlan(js: js) + self.transformPlan = try Cipher.getDecodedTransformPlan(rawPlan: rawPlan, variable: globalVariable, transformMap: transformMap) + // Extract the n parameter function code (throttling) + self.nParameterFunction = try Cipher.getThrottlingFunctionCode(js: js) } /// Converts n to the correct value to prevent throttling. @@ -56,26 +71,19 @@ class Cipher { if let newN = calculatedN[initialN] { return newN } - #if canImport(JavaScriptCore) guard let context = JSContext() else { os_log("failed to create JSContext", log: Cipher.log, type: .error) return "" } - context.evaluateScript(nParameterFunction) - let function = context.objectForKeyedSubscript("processNSignature") let result = function?.call(withArguments: [initialN]) - guard let result, result.isString, let newN = result.toString() else { os_log("failed to calculate n", log: Cipher.log, type: .error) return "" } - - // cache the result calculatedN[initialN] = newN - return newN #else return "" @@ -85,12 +93,9 @@ class Cipher { /// Decipher the signature func getSignature(cipheredSignature: String) -> String? { var signature = Array(cipheredSignature) - guard !transformPlan.isEmpty else { return nil } - - // apply transform functions for (function, param) in transformPlan { switch function { case .reverse: @@ -101,7 +106,6 @@ class Cipher { (signature[0], signature[param % signature.count]) = (signature[param % signature.count], signature[0]) } } - return String(signature) } diff --git a/Sources/YouTubeKit/Extraction.swift b/Sources/YouTubeKit/Extraction.swift index 302fd7f..7794ee4 100644 --- a/Sources/YouTubeKit/Extraction.swift +++ b/Sources/YouTubeKit/Extraction.swift @@ -58,8 +58,9 @@ class Extraction { class func getYTPlayerConfig(html: String) throws -> PlayerConfig { os_log("finding initial function name", log: log, type: .debug) let configPatterns = [ - NSRegularExpression(#"ytplayer\.config\s*=\s*"#), - NSRegularExpression(#"ytInitialPlayerResponse\s*=\s*"#) + // Outdated Regex + // NSRegularExpression(#"ytplayer\.config\s*=\s*(\{)"#), + NSRegularExpression(#"ytInitialPlayerResponse\s*=\s*(\{)"#) ] for pattern in configPatterns { @@ -112,17 +113,61 @@ class Extraction { return (nil, [nil]) } - /// Extracts the signature timestamp (sts) from javascript. + /// Extracts the signature timestamp (sts) from javascript. /// Used to pass into InnerTube to tell API what sig/player is in use. /// - parameter js: The javascript contents of the watch page /// - returns: The signature timestamp (sts) or nil if not found class func extractSignatureTimestamp(fromJS js: String) -> Int? { - let pattern = NSRegularExpression(#"(?:signatureTimestamp|sts)\s*:\s*([0-9]{5})"#) + // Improved regex to match signatureTimestamp extraction as in TS + let pattern = NSRegularExpression(#"signatureTimestamp\s*:\s*(\d+)"#) if let match = pattern.firstMatch(in: js, group: 1) { return Int(match.content) } return nil } + + /// Extracts the global variable used for deciphering signatures from JS. + /// This implementation is based on the fix from YouTube.js PR #953 which uses a global variable + /// to find the signature algorithm instead of relying on hardcoded variable names. + /// YouTube.js PR #953 - https://github.com/LuanRT/YouTube.js/pull/953 + /// + /// - Parameter js: The JavaScript content from YouTube player + /// - Returns: The name of the global variable used for signature deciphering, or nil if not found + class func extractGlobalVariable(js: String) -> String? { + // Try to match common variable patterns as in YouTube.js + let patterns = [ + // Match variable declarations that contain signature-related code + NSRegularExpression(#"var (\w+)=\{[^}]+\}"#), + // Match function declarations that handle signature splitting + NSRegularExpression(#"function\((\w+)\)\{\w+=\w+\.split\(""\)"#), + // Match variables that might contain signature transformation functions + NSRegularExpression(#"var (\w+)=\{[\s\S]*?\};"#) + ] + + for pattern in patterns { + if let match = pattern.firstMatch(in: js, group: 1) { + os_log("Found global variable for signature algorithm: %{public}@", log: log, type: .debug, match.content) + return match.content + } + } + + // If no variable is found, the default "DE" will be used as fallback in Cipher.swift + os_log("Could not extract global variable for signature algorithm, using default", log: log, type: .debug) + return nil + } + + /// Extracts the signature deciphering source code from JS. + class func extractSigSourceCode(js: String, globalVariable: String?) -> String? { + guard let globalVariable else { return nil } + // Try to extract the function body for signature deciphering + guard let pattern = try? NSRegularExpression(pattern: "function\\((\\w+)\\)\\{(\\w+=\\w+\\.split\\(\\\"\\\"\\)(.+?)\\.join\\(\\\"\\\"\\))\\}", options: []) else { + return nil + } + if let match = pattern.firstMatch(in: js, group: 2) { + return "function descramble_sig(sig) { var \(globalVariable) = {...}; \(match.content) } descramble_sig(sig);" + } + return nil + } struct YtCfg: Decodable { let VISITOR_DATA: String? diff --git a/Sources/YouTubeKit/YouTube.swift b/Sources/YouTubeKit/YouTube.swift index 5a0ba26..e31f896 100644 --- a/Sources/YouTubeKit/YouTube.swift +++ b/Sources/YouTubeKit/YouTube.swift @@ -47,6 +47,11 @@ public class YouTube { public let videoID: String + public static func extractStreams(forVideoID videoID: String, method: ExtractionMethod) async throws -> [Stream] { + let youtube = YouTube(videoID: videoID, methods: [method]) + return try await youtube.streams + } + var watchURL: URL { URL(string: "https://youtube.com/watch?v=\(videoID)")! } @@ -176,6 +181,15 @@ public class YouTube { } } + /// Retrieves the YouTube player JavaScript code. + /// + /// This JavaScript contains the signature deciphering algorithms needed to generate valid video URLs. + /// The implementation now supports dynamic detection of the global variable used for signature algorithms + /// based on the fix from YouTube.js PR #953, which improves compatibility with YouTube's + /// frequently changing obfuscation techniques. + /// + /// - Returns: The JavaScript content from YouTube player + /// - Throws: Error if JavaScript cannot be retrieved var js: String { get async throws { if let cached = _js {