mirror of
https://github.com/yattee/yattee.git
synced 2026-02-21 10:19:46 +00:00
Add video proxy support with live toggle for Invidious/Piped instances
Adds a "Proxy videos" toggle in instance settings that routes video streams through the instance instead of connecting directly to YouTube CDN. Includes auto-detection of 403 blocks and live re-application of proxy settings without requiring app restart or video reload.
This commit is contained in:
@@ -541,6 +541,78 @@ actor InvidiousAPI: InstanceAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Proxy
|
||||
|
||||
extension InvidiousAPI {
|
||||
/// Rewrites a stream URL to route through the given instance.
|
||||
/// Replaces the scheme, host, and port with the instance's, keeping the original path and query.
|
||||
static func proxiedURL(instance: Instance, originalURL: URL) -> URL {
|
||||
var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: true) ?? URLComponents()
|
||||
let instanceComponents = URLComponents(url: instance.url, resolvingAgainstBaseURL: true)
|
||||
components.scheme = instanceComponents?.scheme ?? "https"
|
||||
components.host = instanceComponents?.host
|
||||
components.port = instanceComponents?.port
|
||||
return components.url ?? originalURL
|
||||
}
|
||||
|
||||
/// Checks if a URL points to a YouTube CDN (googlevideo.com or youtube.com).
|
||||
/// Only these URLs should be proxied — URLs already on the instance should not be.
|
||||
static func isYouTubeCDNURL(_ url: URL) -> Bool {
|
||||
guard let host = url.host?.lowercased() else { return false }
|
||||
return host.hasSuffix("googlevideo.com") || host.hasSuffix("youtube.com")
|
||||
}
|
||||
|
||||
/// Performs a HEAD request to detect if a URL returns HTTP 403 (Forbidden).
|
||||
/// Used for auto-detecting when ISPs block direct YouTube CDN access.
|
||||
static func isForbidden(_ url: URL) async -> Bool {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD"
|
||||
request.timeoutInterval = 5
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
return httpResponse.statusCode == 403
|
||||
}
|
||||
} catch {
|
||||
// Network errors are not 403 — don't proxy on timeout or other failures
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Applies proxy URL rewriting to an array of streams if needed.
|
||||
/// - Parameters:
|
||||
/// - streams: The original streams from the API
|
||||
/// - instance: The instance to proxy through
|
||||
/// - Returns: Streams with YouTube CDN URLs rewritten to go through the instance
|
||||
static func proxyStreamsIfNeeded(_ streams: [Stream], instance: Instance) async -> [Stream] {
|
||||
guard instance.supportsVideoProxying else { 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 {
|
||||
shouldProxy = false
|
||||
}
|
||||
|
||||
guard shouldProxy else { return streams }
|
||||
|
||||
return streams.map { stream in
|
||||
if isYouTubeCDNURL(stream.url) {
|
||||
return stream.withURL(proxiedURL(instance: instance, originalURL: stream.url))
|
||||
}
|
||||
return stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Redirect Blocker
|
||||
|
||||
/// URLSession delegate that prevents automatic redirect following.
|
||||
|
||||
@@ -84,7 +84,8 @@ final class LegacyDataMigrationService {
|
||||
legacyInstanceID: instance.id,
|
||||
instanceType: instanceType,
|
||||
url: url,
|
||||
name: instance.name.isEmpty ? nil : instance.name
|
||||
name: instance.name.isEmpty ? nil : instance.name,
|
||||
proxiesVideos: instance.proxiesVideos
|
||||
)
|
||||
items.append(item)
|
||||
}
|
||||
@@ -184,7 +185,8 @@ final class LegacyDataMigrationService {
|
||||
type: item.instanceType,
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
isEnabled: true
|
||||
isEnabled: true,
|
||||
proxiesVideos: item.proxiesVideos
|
||||
)
|
||||
|
||||
// Add to instances manager
|
||||
|
||||
@@ -99,6 +99,9 @@ struct LegacyImportItem: Identifiable, Sendable {
|
||||
/// User-defined name for the instance
|
||||
let name: String?
|
||||
|
||||
/// Whether this instance proxies videos
|
||||
var proxiesVideos: Bool = false
|
||||
|
||||
/// Whether this item is selected for import
|
||||
var isSelected: Bool = true
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ final class PlayerService {
|
||||
/// Available streams for the current video.
|
||||
private(set) var availableStreams: [Stream] = []
|
||||
|
||||
/// Original (unproxied) streams from the API, used to re-apply proxy when settings change.
|
||||
private var originalStreams: [Stream] = []
|
||||
|
||||
/// Available captions for the current video.
|
||||
private(set) var availableCaptions: [Caption] = []
|
||||
|
||||
@@ -186,6 +189,7 @@ final class PlayerService {
|
||||
|
||||
// Clear streams
|
||||
availableStreams = []
|
||||
originalStreams = []
|
||||
|
||||
// Set video in state but keep idle (not loading)
|
||||
state.setCurrentVideo(video, stream: nil)
|
||||
@@ -564,6 +568,7 @@ final class PlayerService {
|
||||
state.reset()
|
||||
state.clearHistory()
|
||||
availableStreams = []
|
||||
originalStreams = []
|
||||
availableCaptions = []
|
||||
folderFilesCache.removeAll()
|
||||
downloadedSubtitlesCache.removeAll()
|
||||
@@ -1657,7 +1662,48 @@ final class PlayerService {
|
||||
// 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)
|
||||
return (result.video, result.streams, result.captions, result.storyboards)
|
||||
|
||||
// Store original (unproxied) streams so we can re-apply proxy when settings change
|
||||
self.originalStreams = result.streams
|
||||
|
||||
// Apply proxy URL rewriting for instances that support it
|
||||
let streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
|
||||
|
||||
return (result.video, streams, result.captions, result.storyboards)
|
||||
}
|
||||
|
||||
/// Re-applies proxy URL rewriting to cached streams when instance settings change.
|
||||
/// Uses the stored `originalStreams` to derive the correct URLs without re-fetching from the API.
|
||||
private func reapplyProxyToStreams() async {
|
||||
guard !originalStreams.isEmpty,
|
||||
let video = state.currentVideo,
|
||||
let instance = try? await findInstance(for: video) else { return }
|
||||
|
||||
let newStreams = await InvidiousAPI.proxyStreamsIfNeeded(originalStreams, instance: instance)
|
||||
|
||||
// Update available streams (preserve any downloaded/local file streams)
|
||||
let downloadedStreams = availableStreams.filter { $0.url.isFileURL }
|
||||
availableStreams = downloadedStreams + newStreams
|
||||
|
||||
// Update current stream URL if it changed
|
||||
if let currentStream = state.currentStream,
|
||||
let matchingNew = newStreams.first(where: { $0.resolution == currentStream.resolution && $0.format == currentStream.format }) {
|
||||
if matchingNew.url != currentStream.url {
|
||||
state.updateCurrentStream(matchingNew)
|
||||
LoggingService.shared.logPlayer("Updated current stream URL after proxy setting change")
|
||||
}
|
||||
}
|
||||
|
||||
// Update current audio stream URL if it changed
|
||||
if let currentAudio = state.currentAudioStream,
|
||||
let matchingNew = newStreams.first(where: { $0.resolution == currentAudio.resolution && $0.format == currentAudio.format && $0.isAudioOnly == true }) {
|
||||
if matchingNew.url != currentAudio.url {
|
||||
state.updateCurrentAudioStream(matchingNew)
|
||||
LoggingService.shared.logPlayer("Updated current audio stream URL after proxy setting change")
|
||||
}
|
||||
}
|
||||
|
||||
LoggingService.shared.logPlayer("Re-applied proxy settings: \(newStreams.count) streams updated")
|
||||
}
|
||||
|
||||
/// Creates a stream for media source videos (WebDAV, SMB, or local folder).
|
||||
@@ -2029,6 +2075,18 @@ final class PlayerService {
|
||||
/// Sets the instances manager for finding instances.
|
||||
func setInstancesManager(_ manager: InstancesManager) {
|
||||
self.instancesManager = manager
|
||||
|
||||
// Observe instance setting changes (e.g. proxy toggle) to re-apply proxy to cached streams
|
||||
instanceChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .instancesDidChange,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.reapplyProxyToStreams()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the media sources manager for WebDAV/SMB/local folder playback.
|
||||
@@ -2070,6 +2128,9 @@ final class PlayerService {
|
||||
nowPlayingService.configureRemoteCommands(mode: mode, duration: duration)
|
||||
}
|
||||
|
||||
/// Observer for instance setting changes to re-apply proxy to cached streams.
|
||||
private var instanceChangeObserver: NSObjectProtocol?
|
||||
|
||||
/// Observer for preset changes to reconfigure system controls.
|
||||
private var presetChangeObserver: NSObjectProtocol?
|
||||
|
||||
|
||||
@@ -451,6 +451,16 @@ final class PlayerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the current stream without changing the video.
|
||||
func updateCurrentStream(_ stream: Stream) {
|
||||
currentStream = stream
|
||||
}
|
||||
|
||||
/// Updates the current audio stream without changing the video.
|
||||
func updateCurrentAudioStream(_ stream: Stream) {
|
||||
currentAudioStream = stream
|
||||
}
|
||||
|
||||
/// Whether the playback has failed.
|
||||
var isFailed: Bool {
|
||||
if case .failed = playbackState { return true }
|
||||
|
||||
Reference in New Issue
Block a user