diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index bf1ca19b..63585317 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Models/Instance.swift b/Yattee/Models/Instance.swift index fa8fe9a0..4cb75cd0 100644 --- a/Yattee/Models/Instance.swift +++ b/Yattee/Models/Instance.swift @@ -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 diff --git a/Yattee/Models/Stream.swift b/Yattee/Models/Stream.swift index c0094f5b..5689d490 100644 --- a/Yattee/Models/Stream.swift +++ b/Yattee/Models/Stream.swift @@ -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 { diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index 4b0c7083..206245e7 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -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. diff --git a/Yattee/Services/Migration/LegacyDataMigrationService.swift b/Yattee/Services/Migration/LegacyDataMigrationService.swift index 38043963..76111b8f 100644 --- a/Yattee/Services/Migration/LegacyDataMigrationService.swift +++ b/Yattee/Services/Migration/LegacyDataMigrationService.swift @@ -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 diff --git a/Yattee/Services/Migration/LegacyDataModels.swift b/Yattee/Services/Migration/LegacyDataModels.swift index 1fda0998..8c2134d4 100644 --- a/Yattee/Services/Migration/LegacyDataModels.swift +++ b/Yattee/Services/Migration/LegacyDataModels.swift @@ -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 diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index d526f32c..dfdcb8a9 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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? diff --git a/Yattee/Services/Player/PlayerState.swift b/Yattee/Services/Player/PlayerState.swift index ffe5811e..25b12e88 100644 --- a/Yattee/Services/Player/PlayerState.swift +++ b/Yattee/Services/Player/PlayerState.swift @@ -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 } diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 9c8515cf..672caa4b 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -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 {