mirror of
https://github.com/yattee/yattee.git
synced 2026-02-21 18:29:44 +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:
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user