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:
Arkadiusz Fal
2026-02-16 19:40:55 +01:00
parent 1fa6d7a4a5
commit c8b4ae5422
9 changed files with 305 additions and 4 deletions

View File

@@ -3,6 +3,9 @@
"strings" : {
"" : {
},
"-%@" : {
},
"%@" : {
@@ -94,6 +97,12 @@
}
}
}
},
"• %@" : {
},
"↑↓ scroll • click to close" : {
},
"+%lld" : {
@@ -419,6 +428,9 @@
}
}
}
},
"Cancel" : {
},
"channel.menu.disableNotifications" : {
"localizations" : {
@@ -717,6 +729,12 @@
}
}
}
},
"click to expand" : {
},
"Comments" : {
},
"comments.disabled" : {
"comment" : "Shown when comments are disabled for a video",
@@ -1809,6 +1827,9 @@
}
}
}
},
"Description" : {
},
"discovery.empty.description" : {
"comment" : "Description when no network shares are found",
@@ -4455,6 +4476,9 @@
}
}
}
},
"Info" : {
},
"Initializing player..." : {
@@ -4612,6 +4636,9 @@
}
}
}
},
"LIVE" : {
},
"login.email" : {
"comment" : "Email field label in login form",
@@ -5581,6 +5608,15 @@
}
}
}
},
"Off" : {
},
"OK" : {
},
"On" : {
},
"onboarding.cloud.complete.description" : {
"comment" : "iCloud sync complete description on onboarding",
@@ -7275,6 +7311,9 @@
}
}
}
},
"Quality" : {
},
"queue.action.addToQueue" : {
"comment" : "Add to queue action in action sheet",
@@ -7766,6 +7805,9 @@
}
}
}
},
"Remove" : {
},
"resume.action.continueAt %@" : {
"comment" : "Continue watching action with timestamp",
@@ -7788,6 +7830,15 @@
}
}
}
},
"Search bookmarks" : {
},
"Search channels" : {
},
"Search history" : {
},
"search.clearAllRecents" : {
"comment" : "Button to clear all recent searches, channels, and playlists",
@@ -14598,6 +14649,17 @@
}
}
},
"sources.field.proxiesVideos" : {
"comment" : "Toggle label for video proxy option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Proxy videos"
}
}
}
},
"sources.field.password" : {
"comment" : "Field label for password",
"localizations" : {
@@ -14730,6 +14792,17 @@
}
}
},
"sources.footer.proxiesVideos" : {
"comment" : "Footer text explaining video proxy option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Route video streams through this instance instead of connecting directly. Enable if direct video playback is blocked."
}
}
}
},
"sources.footer.auth" : {
"comment" : "Footer text for authentication section",
"localizations" : {
@@ -14906,6 +14979,17 @@
}
}
},
"sources.header.proxy" : {
"comment" : "Section header for video proxy settings",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Video Proxy"
}
}
}
},
"sources.header.security" : {
"comment" : "Section header for security settings",
"localizations" : {

View File

@@ -73,6 +73,9 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
/// Whether to allow invalid/self-signed SSL certificates.
var allowInvalidCertificates: Bool
/// Whether to route video streams through this instance instead of connecting directly to YouTube CDN.
var proxiesVideos: Bool
// MARK: - Initialization
init(
@@ -83,7 +86,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
isEnabled: Bool = true,
dateAdded: Date = Date(),
apiKey: String? = nil,
allowInvalidCertificates: Bool = false
allowInvalidCertificates: Bool = false,
proxiesVideos: Bool = false
) {
self.id = id
self.type = type
@@ -93,6 +97,20 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
self.dateAdded = dateAdded
self.apiKey = apiKey
self.allowInvalidCertificates = allowInvalidCertificates
self.proxiesVideos = proxiesVideos
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
type = try container.decode(InstanceType.self, forKey: .type)
url = try container.decode(URL.self, forKey: .url)
name = try container.decodeIfPresent(String.self, forKey: .name)
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates)
proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false
}
// MARK: - Computed Properties
@@ -148,6 +166,11 @@ extension Instance {
var supportsPopular: Bool {
type == .invidious || type == .yatteeServer
}
/// Whether this instance supports proxying video streams through itself.
var supportsVideoProxying: Bool {
type == .invidious || type == .piped
}
}
// MARK: - Instance Validation

View File

@@ -189,6 +189,32 @@ struct StreamResolution: Codable, Hashable, Sendable, Comparable, CustomStringCo
}
}
// MARK: - URL Rewriting
extension Stream {
/// Creates a copy of this stream with a different URL.
/// Used for proxying streams through an instance.
func withURL(_ newURL: URL) -> Stream {
Stream(
url: newURL,
resolution: resolution,
format: format,
videoCodec: videoCodec,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: fileSize,
isAudioOnly: isAudioOnly,
isLive: isLive,
mimeType: mimeType,
audioLanguage: audioLanguage,
audioTrackName: audioTrackName,
isOriginalAudio: isOriginalAudio,
httpHeaders: httpHeaders,
fps: fps
)
}
}
// MARK: - Preview Data
extension Stream {

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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 }

View File

@@ -34,6 +34,7 @@ private struct EditRemoteServerContent: View {
@State private var name: String
@State private var isEnabled: Bool
@State private var allowInvalidCertificates: Bool
@State private var proxiesVideos: Bool
// Yattee Server credentials
@State private var yatteeServerUsername: String = ""
@@ -68,6 +69,7 @@ private struct EditRemoteServerContent: View {
_name = State(initialValue: instance.name ?? "")
_isEnabled = State(initialValue: instance.isEnabled)
_allowInvalidCertificates = State(initialValue: instance.allowInvalidCertificates)
_proxiesVideos = State(initialValue: instance.proxiesVideos)
}
var body: some View {
@@ -266,6 +268,23 @@ private struct EditRemoteServerContent: View {
Text(String(localized: "sources.footer.allowInvalidCertificates"))
}
if instance.supportsVideoProxying {
Section {
#if os(tvOS)
TVSettingsToggle(
title: String(localized: "sources.field.proxiesVideos"),
isOn: $proxiesVideos
)
#else
Toggle(String(localized: "sources.field.proxiesVideos"), isOn: $proxiesVideos)
#endif
} header: {
Text(String(localized: "sources.header.proxy"))
} footer: {
Text(String(localized: "sources.footer.proxiesVideos"))
}
}
Section {
Button {
testConnection()
@@ -413,6 +432,7 @@ private struct EditRemoteServerContent: View {
updated.name = name.isEmpty ? nil : name
updated.isEnabled = isEnabled
updated.allowInvalidCertificates = allowInvalidCertificates
updated.proxiesVideos = proxiesVideos
// Save Yattee Server credentials if provided
if instance.type == .yatteeServer && !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty {