diff --git a/Yattee/Core/InstancesManager.swift b/Yattee/Core/InstancesManager.swift index 507664b2..e712d3d6 100644 --- a/Yattee/Core/InstancesManager.swift +++ b/Yattee/Core/InstancesManager.swift @@ -145,12 +145,17 @@ final class InstancesManager { } saveInstances() + Task { await ProxyDetectionCache.shared.invalidate(instance: instance) } } func update(_ instance: Instance) { if let index = instances.firstIndex(where: { $0.id == instance.id }) { instances[index] = instance 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) } } } diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index f0303ef9..29d7121d 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -602,20 +602,33 @@ extension InvidiousAPI { // URLs). Don't second-guess that here by host-rewriting CDN URLs. if instance.type == .yatteeServer { return streams } - // Find first YouTube CDN URL for 403 detection let firstCDNURL = streams.first(where: { isYouTubeCDNURL($0.url) })?.url let shouldProxy: Bool if instance.proxiesVideos { shouldProxy = true LoggingService.shared.info("Proxying streams through \(instance.displayName) (user-enabled)", category: .player) - } else if let cdnURL = firstCDNURL, await isForbidden(cdnURL) { - shouldProxy = true - LoggingService.shared.info("Proxying streams through \(instance.displayName) (auto-detected 403)", category: .player) + } else if let cdnURL = firstCDNURL { + // Auto-detect via cached HEAD probe. Cache cuts videos 2..N down + // 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 { 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 } return streams.map { stream in @@ -625,6 +638,21 @@ extension InvidiousAPI { 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 diff --git a/Yattee/Services/API/ProxyDetectionCache.swift b/Yattee/Services/API/ProxyDetectionCache.swift new file mode 100644 index 00000000..97ec0ca2 --- /dev/null +++ b/Yattee/Services/API/ProxyDetectionCache.swift @@ -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] = [:] + + /// 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 { 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 { 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() + } +} diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index dfdcb8a9..67ee8c16 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -1659,6 +1659,12 @@ final class PlayerService { 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 // (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)