mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Cache and prewarm Invidious proxy auto-detection
The proxy auto-detect path (when proxiesVideos is off) HEADed a googlevideo URL with a 5 s timeout on every video. The verdict is a property of the network, not the video, so the cost was paid for no reason on videos 2..N. On a network where the CDN is blocked the full 5 s timeout was added to playback startup every single time. Two changes: 1) ProxyDetectionCache (actor, per-instance, 10 min TTL). First miss pays the HEAD once and caches the verdict; subsequent videos hit the cache synchronously. Concurrent callers share one in-flight probe. The last-seen sample CDN URL is retained so future probes don't need a fresh URL from the current video. 2) PlayerService kicks off InvidiousAPI.prewarmProxyDetection() in parallel with the videoWith... API call. By the time streams come back, the verdict is usually already cached and proxyStreamsIfNeeded is a sync lookup. Cheap when there's nothing to prewarm. Cache invalidation: - on InstancesManager.update (URL change, proxy toggle flip) - on InstancesManager.remove - TTL covers the network-change case for now (no NWPathMonitor yet)
This commit is contained in:
@@ -145,12 +145,17 @@ final class InstancesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveInstances()
|
saveInstances()
|
||||||
|
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(_ instance: Instance) {
|
func update(_ instance: Instance) {
|
||||||
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
|
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
|
||||||
instances[index] = instance
|
instances[index] = instance
|
||||||
saveInstances()
|
saveInstances()
|
||||||
|
// Editing a source can change the proxy answer (URL change, toggle
|
||||||
|
// flip). Drop the cached auto-detect verdict so the next playback
|
||||||
|
// re-probes.
|
||||||
|
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,20 +602,33 @@ extension InvidiousAPI {
|
|||||||
// URLs). Don't second-guess that here by host-rewriting CDN URLs.
|
// URLs). Don't second-guess that here by host-rewriting CDN URLs.
|
||||||
if instance.type == .yatteeServer { return streams }
|
if instance.type == .yatteeServer { return streams }
|
||||||
|
|
||||||
// Find first YouTube CDN URL for 403 detection
|
|
||||||
let firstCDNURL = streams.first(where: { isYouTubeCDNURL($0.url) })?.url
|
let firstCDNURL = streams.first(where: { isYouTubeCDNURL($0.url) })?.url
|
||||||
|
|
||||||
let shouldProxy: Bool
|
let shouldProxy: Bool
|
||||||
if instance.proxiesVideos {
|
if instance.proxiesVideos {
|
||||||
shouldProxy = true
|
shouldProxy = true
|
||||||
LoggingService.shared.info("Proxying streams through \(instance.displayName) (user-enabled)", category: .player)
|
LoggingService.shared.info("Proxying streams through \(instance.displayName) (user-enabled)", category: .player)
|
||||||
} else if let cdnURL = firstCDNURL, await isForbidden(cdnURL) {
|
} else if let cdnURL = firstCDNURL {
|
||||||
shouldProxy = true
|
// Auto-detect via cached HEAD probe. Cache cuts videos 2..N down
|
||||||
LoggingService.shared.info("Proxying streams through \(instance.displayName) (auto-detected 403)", category: .player)
|
// to a synchronous lookup; on the first miss we pay the HEAD once.
|
||||||
|
shouldProxy = await ProxyDetectionCache.shared.decision(
|
||||||
|
for: instance,
|
||||||
|
sampleURL: cdnURL,
|
||||||
|
probe: { await isForbidden($0) }
|
||||||
|
)
|
||||||
|
if shouldProxy {
|
||||||
|
LoggingService.shared.info("Proxying streams through \(instance.displayName) (auto-detected 403)", category: .player)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shouldProxy = false
|
shouldProxy = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Even when we don't end up proxying, remember a sample CDN URL so
|
||||||
|
// future videos can prewarm the probe before their API call returns.
|
||||||
|
if !shouldProxy, let cdnURL = firstCDNURL {
|
||||||
|
await ProxyDetectionCache.shared.record(decision: false, sampleURL: cdnURL, for: instance)
|
||||||
|
}
|
||||||
|
|
||||||
guard shouldProxy else { return streams }
|
guard shouldProxy else { return streams }
|
||||||
|
|
||||||
return streams.map { stream in
|
return streams.map { stream in
|
||||||
@@ -625,6 +638,21 @@ extension InvidiousAPI {
|
|||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kick off proxy auto-detection in the background using a previously-seen
|
||||||
|
/// CDN URL for this instance. Cheap when there's nothing to do (no sample
|
||||||
|
/// URL yet, or the decision is already cached). Call this when the player
|
||||||
|
/// starts loading so the verdict is ready by the time streams come back.
|
||||||
|
static func prewarmProxyDetection(for instance: Instance) async {
|
||||||
|
guard instance.supportsVideoProxying,
|
||||||
|
instance.type != .yatteeServer,
|
||||||
|
!instance.proxiesVideos else { return }
|
||||||
|
|
||||||
|
await ProxyDetectionCache.shared.prewarm(
|
||||||
|
instance: instance,
|
||||||
|
probe: { await isForbidden($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Redirect Blocker
|
// MARK: - Redirect Blocker
|
||||||
|
|||||||
126
Yattee/Services/API/ProxyDetectionCache.swift
Normal file
126
Yattee/Services/API/ProxyDetectionCache.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Caches the answer to "should we proxy streams through this instance?" for the
|
||||||
|
/// auto-detect path (when `Instance.proxiesVideos` is *off* and we'd otherwise have
|
||||||
|
/// to HEAD a CDN URL to find out whether the network blocks direct access).
|
||||||
|
///
|
||||||
|
/// The HEAD probe is the slowest single thing on the playback startup path on a
|
||||||
|
/// network where the CDN is blocked: 5 s timeout, paid on *every* video. The
|
||||||
|
/// answer is a property of the network, not the video, so caching it for a few
|
||||||
|
/// minutes is safe and cuts videos 2..N down to a synchronous lookup.
|
||||||
|
///
|
||||||
|
/// Invalidation:
|
||||||
|
/// - on `instancesDidChange` (handles the user flipping the proxy toggle, editing
|
||||||
|
/// the URL, etc.) — wire this up at the call site
|
||||||
|
/// - implicit via `ttl` so a network change eventually re-tests
|
||||||
|
///
|
||||||
|
/// Thread-safety: actor.
|
||||||
|
actor ProxyDetectionCache {
|
||||||
|
static let shared = ProxyDetectionCache()
|
||||||
|
|
||||||
|
/// How long a verdict stays fresh. The only reason the answer can flip is a
|
||||||
|
/// network change (Wi-Fi ↔ cellular, VPN on/off). 10 min is a defensible
|
||||||
|
/// upper bound for "you'll re-probe shortly after the change settles".
|
||||||
|
static let ttl: TimeInterval = 600
|
||||||
|
|
||||||
|
/// How long ago we last saw any CDN URL for this instance. Reused as the
|
||||||
|
/// prober URL by ``prewarm(instance:probe:)`` so detection can happen
|
||||||
|
/// before the API call for the next video returns.
|
||||||
|
private struct Entry {
|
||||||
|
var decision: Bool
|
||||||
|
var expiresAt: Date
|
||||||
|
var sampleURL: URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entries: [UUID: Entry] = [:]
|
||||||
|
|
||||||
|
/// In-flight detection per instance, so concurrent callers share one HEAD.
|
||||||
|
private var inFlight: [UUID: Task<Bool, Never>] = [:]
|
||||||
|
|
||||||
|
/// Returns the cached verdict if still fresh.
|
||||||
|
func cachedDecision(for instance: Instance) -> Bool? {
|
||||||
|
guard let entry = entries[instance.id], entry.expiresAt > Date() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry.decision
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most-recently-seen CDN URL for this instance. Used by ``prewarm`` so we
|
||||||
|
/// don't have to wait for the current video's API response just to learn a
|
||||||
|
/// URL to probe.
|
||||||
|
func lastSampleURL(for instance: Instance) -> URL? {
|
||||||
|
entries[instance.id]?.sampleURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a verdict (decision + the URL we probed against, kept as a
|
||||||
|
/// future probe sample). Refreshes the TTL.
|
||||||
|
func record(decision: Bool, sampleURL: URL?, for instance: Instance) {
|
||||||
|
entries[instance.id] = Entry(
|
||||||
|
decision: decision,
|
||||||
|
expiresAt: Date().addingTimeInterval(Self.ttl),
|
||||||
|
sampleURL: sampleURL ?? entries[instance.id]?.sampleURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a verdict for `instance`. Returns the cached answer if fresh;
|
||||||
|
/// otherwise runs `probe(sampleURL)` and caches the result. Multiple
|
||||||
|
/// concurrent callers for the same instance share one probe.
|
||||||
|
func decision(
|
||||||
|
for instance: Instance,
|
||||||
|
sampleURL: URL,
|
||||||
|
probe: @Sendable @escaping (URL) async -> Bool
|
||||||
|
) async -> Bool {
|
||||||
|
if let cached = cachedDecision(for: instance) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
if let task = inFlight[instance.id] {
|
||||||
|
return await task.value
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = Task<Bool, Never> { await probe(sampleURL) }
|
||||||
|
inFlight[instance.id] = task
|
||||||
|
let verdict = await task.value
|
||||||
|
inFlight[instance.id] = nil
|
||||||
|
record(decision: verdict, sampleURL: sampleURL, for: instance)
|
||||||
|
return verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick off a detection probe in the background using the last-seen sample
|
||||||
|
/// URL for this instance, if any and if we don't already have a fresh
|
||||||
|
/// answer. Returns immediately. The point: by the time the caller's API
|
||||||
|
/// fetch returns, the verdict is likely cached, so the playback path
|
||||||
|
/// becomes a synchronous lookup.
|
||||||
|
func prewarm(
|
||||||
|
instance: Instance,
|
||||||
|
probe: @Sendable @escaping (URL) async -> Bool
|
||||||
|
) {
|
||||||
|
if cachedDecision(for: instance) != nil { return }
|
||||||
|
if inFlight[instance.id] != nil { return }
|
||||||
|
guard let sample = entries[instance.id]?.sampleURL else { return }
|
||||||
|
|
||||||
|
let task = Task<Bool, Never> { await probe(sample) }
|
||||||
|
inFlight[instance.id] = task
|
||||||
|
Task {
|
||||||
|
let verdict = await task.value
|
||||||
|
self.inFlight[instance.id] = nil
|
||||||
|
self.record(decision: verdict, sampleURL: sample, for: instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the entry for one instance — e.g. when its settings changed and the
|
||||||
|
/// previous verdict may no longer apply.
|
||||||
|
func invalidate(instance: Instance) {
|
||||||
|
entries.removeValue(forKey: instance.id)
|
||||||
|
inFlight[instance.id]?.cancel()
|
||||||
|
inFlight.removeValue(forKey: instance.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop all entries — e.g. after a network reachability change or when the
|
||||||
|
/// instances collection mutates broadly.
|
||||||
|
func invalidateAll() {
|
||||||
|
entries.removeAll()
|
||||||
|
for (_, task) in inFlight { task.cancel() }
|
||||||
|
inFlight.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1659,6 +1659,12 @@ final class PlayerService {
|
|||||||
return (result.video, result.streams, result.captions, [])
|
return (result.video, result.streams, result.captions, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Race the proxy-detection HEAD against the video API call so the
|
||||||
|
// verdict is (usually) ready by the time streams come back. Cheap
|
||||||
|
// when there's nothing to do — it returns immediately if the
|
||||||
|
// verdict is already cached, or if no prior CDN sample exists.
|
||||||
|
async let _: Void = InvidiousAPI.prewarmProxyDetection(for: instance)
|
||||||
|
|
||||||
// Fetch full video details, streams, captions, and storyboards in a single API call
|
// Fetch full video details, streams, captions, and storyboards in a single API call
|
||||||
// (for Invidious, this is a single request; for other backends, calls are made in parallel)
|
// (for Invidious, this is a single request; for other backends, calls are made in parallel)
|
||||||
let result = try await contentService.videoWithStreamsAndCaptionsAndStoryboards(id: video.id.videoID, instance: instance)
|
let result = try await contentService.videoWithStreamsAndCaptionsAndStoryboards(id: video.id.videoID, instance: instance)
|
||||||
|
|||||||
Reference in New Issue
Block a user