Wire Yattee Server playback through /proxy/relay when proxiesVideos is on

The Sources -> Edit Source -> Proxy toggle now renders for Yattee
Server entries (supportsVideoProxying gains .yatteeServer). When the
toggle is on, playback fetches go through
videoWithProxyStreamsAndCaptionsAndStoryboards with mode .relay, so
the server returns signed /proxy/relay URLs (byte-relay, supports
HTTP Range, no on-disk caching). Downloads keep going through mode
.download so the server-side /proxy/fast/ flow continues to cache
files for repeat use.

InvidiousAPI.proxyStreamsIfNeeded early-returns for .yatteeServer
since proxying is now done at fetch time via ?proxy=true rather than
client-side host rewriting.
This commit is contained in:
Arkadiusz Fal
2026-05-04 08:01:51 +02:00
parent 73e3d8164b
commit 93240b4314
4 changed files with 46 additions and 16 deletions

View File

@@ -180,7 +180,7 @@ extension Instance {
/// Whether this instance supports proxying video streams through itself.
var supportsVideoProxying: Bool {
type == .invidious || type == .piped
type == .invidious || type == .piped || type == .yatteeServer
}
}

View File

@@ -243,21 +243,23 @@ actor ContentService: ContentServiceProtocol {
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
}
/// Fetches streams with proxy URLs for faster LAN downloads (Yattee Server only).
/// For other backends, returns regular streams.
/// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/
/// so the server caches the file on disk; other backends apply Invidious URL rewriting).
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
if instance.type == .yatteeServer {
return try await yatteeServerAPI(for: instance).proxyStreams(videoID: videoID, instance: instance)
return try await yatteeServerAPI(for: instance)
.proxyStreams(videoID: videoID, instance: instance, mode: .download)
}
let fetchedStreams = try await streams(videoID: videoID, instance: instance)
return await InvidiousAPI.proxyStreamsIfNeeded(fetchedStreams, instance: instance)
}
/// Fetches video details, proxy streams, captions, and storyboards (Yattee Server only).
/// For other backends, applies Invidious proxy rewriting if enabled.
/// Fetches video details + streams for downloads. For Yattee Server this routes
/// through `/proxy/fast/`, which caches the file on disk on the server.
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
if instance.type == .yatteeServer {
return try await yatteeServerAPI(for: instance).videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
return try await yatteeServerAPI(for: instance)
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .download)
}
var result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
result.streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
@@ -278,6 +280,15 @@ actor ContentService: ContentServiceProtocol {
case .invidious:
return try await invidiousAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
case .yatteeServer:
// When the user has opted into proxying for this Yattee Server source,
// fetch playback streams through the server's /proxy/relay byte-relay
// (Range-friendly, fast TTFB). When off, leave routing to the server's
// per-site proxy_streaming flag the server can still proxy by default,
// but the client doesn't force it on top.
if instance.proxiesVideos {
return try await yatteeServerAPI(for: instance)
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .relay)
}
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
case .piped:
// Piped fallback - make separate calls (no storyboard support)

View File

@@ -597,6 +597,10 @@ extension InvidiousAPI {
/// - 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 }
// Yattee Server does proxying server-side via the ?proxy=true query
// on the videos endpoint (the converter mints signed /proxy/relay
// 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

View File

@@ -254,21 +254,36 @@ actor YatteeServerAPI: InstanceAPI {
)
}
// MARK: - Proxy Streams for Downloads
// MARK: - Proxy Streams
/// Fetches streams with URLs that proxy through the Yattee Server for faster LAN downloads.
/// The proxy URLs point to the server's /proxy/fast/{video_id}?itag=X endpoint instead of
/// directly to YouTube CDN, allowing the server to download at full speed and serve locally.
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)", query: ["proxy": "true"])
/// Selects which yattee-server proxy endpoint stream URLs point at.
///
/// - `relay`: signed `/proxy/relay` byte-relay fast TTFB, supports HTTP
/// Range, no on-disk caching. Right shape for playback.
/// - `download`: legacy `/proxy/fast/{video_id}` runs yt-dlp on the
/// server, writes a file to `/downloads`, then tails it. Right shape
/// for downloads since the cached file accelerates repeated requests.
enum ProxyMode: String {
case relay
case download
}
/// Fetches streams with URLs that proxy through the Yattee Server.
func proxyStreams(videoID: String, instance: Instance, mode: ProxyMode) async throws -> [Stream] {
let endpoint = GenericEndpoint.get(
"/api/v1/videos/\(videoID)",
query: ["proxy": "true", "proxy_mode": mode.rawValue]
)
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toStreams()
}
/// Fetches video details, proxy streams, captions, and storyboards in a single API call.
/// Use this for downloads to get streams that route through the server.
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)", query: ["proxy": "true"])
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance, mode: ProxyMode) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
let endpoint = GenericEndpoint.get(
"/api/v1/videos/\(id)",
query: ["proxy": "true", "proxy_mode": mode.rawValue]
)
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return (
video: response.toVideo(),