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
3 changes: 2 additions & 1 deletion Sources/YouTubeKit/Extraction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
codex-pr-bot[bot] marked this conversation as resolved.
let solver = try SignatureSolver.shared(forJS: js)

var sigInputs: [String] = []
var nInputs: [String] = []
Expand Down
85 changes: 78 additions & 7 deletions Sources/YouTubeKit/SignatureSolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Comment thread
codex-pr-bot[bot] marked this conversation as resolved.
#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

Expand Down Expand Up @@ -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] = [:]
Expand Down
4 changes: 4 additions & 0 deletions Sources/YouTubeKit/YouTube.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +253 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancellation is swallowed by the outer extraction-method retry

When methods contains another entry after .local, throwing here does not propagate immediately: the enclosing Task.retry(with: methods) catches every error, including CancellationError, and proceeds to the next extraction method. A cancelled extraction can therefore start the remote WebSocket fallback and potentially return or wait for it instead of terminating promptly.

} catch {
// to force an update to the js file, we clear the cache and retry
_js = nil
Expand Down
Loading