diff --git a/Sources/YouTubeKit/Extraction.swift b/Sources/YouTubeKit/Extraction.swift index 0da85e8..4e94143 100644 --- a/Sources/YouTubeKit/Extraction.swift +++ b/Sources/YouTubeKit/Extraction.swift @@ -394,7 +394,8 @@ class Extraction { #if canImport(JavaScriptCore) /// apply the decrypted signature to the stream manifest class func applySignature(streamManifest: inout [InnerTube.StreamingData.Format], videoInfo: InnerTube.VideoInfo, js: String) throws { - let solver = try SignatureSolver(js: js) + try Task.checkCancellation() + let solver = try SignatureSolver.shared(forJS: js) var sigInputs: [String] = [] var nInputs: [String] = [] diff --git a/Sources/YouTubeKit/SignatureSolver.swift b/Sources/YouTubeKit/SignatureSolver.swift index ad90a2b..58fc892 100644 --- a/Sources/YouTubeKit/SignatureSolver.swift +++ b/Sources/YouTubeKit/SignatureSolver.swift @@ -14,6 +14,54 @@ class SignatureSolver { private static let log = OSLog(SignatureSolver.self) + // MARK: Shared instance cache + // Solver construction (JSContext + meriyah/astring bundle eval) and the first + // solve (full player parse) are very expensive on JIT-less platforms (tvOS/iOS + // JavaScriptCore runs interpreted). Cache one solver per player-JS version so + // subsequent videos reuse the context and the preprocessed player. + private static let sharedLock = NSLock() + /// Small most-recently-used cache of solvers keyed by their player JS. + /// Bounded because each solver retains a JSContext and the ~2 MB player; + /// a session rarely uses more than a couple of player variants (e.g. web + /// vs TV/embed), so alternating between them still reuses each solver. +#if swift(>=5.10) + nonisolated(unsafe) private static var sharedSolvers: [SignatureSolver] = [] +#else + private static var sharedSolvers: [SignatureSolver] = [] +#endif + private static let maxCachedSolvers = 4 + + static func shared(forJS js: String) throws -> SignatureSolver { + sharedLock.lock() + defer { sharedLock.unlock() } + // Match on the retained player JS directly — Swift string equality is + // pointer-identity-fast in the common case and avoids the hash-collision + // risk of caching by hashValue (a collision would return a wrong solver). + if let idx = sharedSolvers.firstIndex(where: { $0.playerJS == js }) { + let solver = sharedSolvers.remove(at: idx) + sharedSolvers.insert(solver, at: 0) // promote to most-recently-used + return solver + } + // A task can be cancelled while blocked on the lock; bail before the + // expensive init rather than tying up the thread pool. + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { + try Task.checkCancellation() + } + let start = Date() + let solver = try SignatureSolver(js: js) + os_log("solver init took %.2fs", log: log, type: .default, Date().timeIntervalSince(start)) + sharedSolvers.insert(solver, at: 0) + if sharedSolvers.count > maxCachedSolvers { + sharedSolvers.removeLast() + } + return solver + } + + /// Player preprocessed by the first solve; skips the full player parse afterwards + private var preprocessedPlayer: String? + /// JSContext isn't thread-safe; the shared instance serializes solves + private let solveLock = NSLock() + private let vm = JSVirtualMachine() private let ctx: JSContext @@ -148,21 +196,44 @@ class SignatureSolver { } func batchSolve(request: SolveRequest) throws -> SolveResponse { + solveLock.lock() + defer { solveLock.unlock() } + + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { + try Task.checkCancellation() + } let requests = [ Request(type: .n, challenges: request.nInputs), Request(type: .sig, challenges: request.sigInputs) ] - let input = Input( - type: .player, - player: self.playerJS, - preprocessed_player: nil, - requests: requests, - output_preprocessed: false - ) + let input: Input + if let preprocessedPlayer { + input = Input( + type: .preprocessedPlayer, + player: nil, + preprocessed_player: preprocessedPlayer, + requests: requests, + output_preprocessed: false + ) + } else { + input = Input( + type: .player, + player: self.playerJS, + preprocessed_player: nil, + requests: requests, + output_preprocessed: true + ) + } + let solveStart = Date() let response = try solve(with: input) + os_log("batch solve took %.2fs (preprocessed: %{public}@)", log: Self.log, type: .default, Date().timeIntervalSince(solveStart), preprocessedPlayer != nil ? "yes" : "no") + + if preprocessedPlayer == nil { + preprocessedPlayer = response.preprocessed_player + } var nMap: [String: String] = [:] var sigMap: [String: String] = [:] diff --git a/Sources/YouTubeKit/YouTube.swift b/Sources/YouTubeKit/YouTube.swift index c3935b6..29ea08c 100644 --- a/Sources/YouTubeKit/YouTube.swift +++ b/Sources/YouTubeKit/YouTube.swift @@ -250,6 +250,10 @@ public class YouTube { do { try await Extraction.applySignature(streamManifest: &streamManifest, videoInfo: videoInfo, js: js) + } catch is CancellationError { + // Cancellation is not a stale-JS failure — propagate it + // immediately instead of clearing the cache and retrying. + throw CancellationError() } catch { // to force an update to the js file, we clear the cache and retry _js = nil