mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 02:17:46 +00:00
Inject basic auth via per-instance HTTPClient default headers
Replace the YatteeServerAPI setAuthHeader/buildHeaders pattern (which was race-prone on the shared actor across multiple instances) with a generic mechanism: HTTPClient now supports a defaultHeaders dictionary applied to every request, and ContentService builds a per-instance HTTPClient with the basic-auth Authorization header baked in whenever credentials are configured. The same code path now works uniformly for Invidious, Piped, PeerTube, and Yattee Server, so any instance sitting behind a reverse proxy that requires HTTP Basic Auth can be authenticated regardless of backend type. Cached default API actors are still reused when no basic-auth header is needed.
This commit is contained in:
@@ -68,60 +68,49 @@ actor ContentService: ContentServiceProtocol {
|
|||||||
|
|
||||||
// MARK: - Routing
|
// MARK: - Routing
|
||||||
|
|
||||||
|
/// Builds a per-instance HTTPClient with the basic-auth `Authorization` header baked in,
|
||||||
|
/// or returns nil if no basic-auth credentials are configured for the instance.
|
||||||
|
/// Used to inject reverse-proxy basic auth uniformly across all backends.
|
||||||
|
private func httpClientWithBasicAuth(for instance: Instance) async -> HTTPClient? {
|
||||||
|
guard let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let client = httpClientFactory.createClient(for: instance)
|
||||||
|
await client.setDefaultHeaders(["Authorization": authHeader])
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns an API client configured for the instance's SSL and auth requirements.
|
/// Returns an API client configured for the instance's SSL and auth requirements.
|
||||||
private func api(for instance: Instance) async -> any InstanceAPI {
|
private func api(for instance: Instance) async -> any InstanceAPI {
|
||||||
// For Yattee Server, use the dedicated method that handles auth
|
|
||||||
if instance.type == .yatteeServer {
|
|
||||||
return await yatteeServerAPI(for: instance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For instances with standard SSL, use cached default API clients
|
|
||||||
if !instance.allowInvalidCertificates {
|
|
||||||
switch instance.type {
|
|
||||||
case .invidious:
|
|
||||||
return defaultInvidiousAPI
|
|
||||||
case .piped:
|
|
||||||
return defaultPipedAPI
|
|
||||||
case .peertube:
|
|
||||||
return defaultPeerTubeAPI
|
|
||||||
case .yatteeServer:
|
|
||||||
fatalError("Should be handled above")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For instances with allowInvalidCertificates, create API with insecure HTTPClient
|
|
||||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
|
||||||
switch instance.type {
|
switch instance.type {
|
||||||
case .invidious:
|
case .invidious:
|
||||||
return InvidiousAPI(httpClient: insecureClient)
|
return await invidiousAPI(for: instance)
|
||||||
case .piped:
|
case .piped:
|
||||||
return PipedAPI(httpClient: insecureClient)
|
return await pipedAPI(for: instance)
|
||||||
case .peertube:
|
case .peertube:
|
||||||
return PeerTubeAPI(httpClient: insecureClient)
|
return await peerTubeAPI(for: instance)
|
||||||
case .yatteeServer:
|
case .yatteeServer:
|
||||||
fatalError("Should be handled above")
|
return await yatteeServerAPI(for: instance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
|
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
|
||||||
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
|
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
|
||||||
let api: YatteeServerAPI
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||||
if !instance.allowInvalidCertificates {
|
return YatteeServerAPI(httpClient: authClient)
|
||||||
api = defaultYatteeServerAPI
|
|
||||||
} else {
|
|
||||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
|
||||||
api = YatteeServerAPI(httpClient: insecureClient)
|
|
||||||
}
|
}
|
||||||
|
if !instance.allowInvalidCertificates {
|
||||||
// Fetch auth header directly from credentials manager (avoids race condition on app startup)
|
return defaultYatteeServerAPI
|
||||||
let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance)
|
}
|
||||||
await api.setAuthHeader(authHeader)
|
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||||
|
return YatteeServerAPI(httpClient: insecureClient)
|
||||||
return api
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns an InvidiousAPI configured for the instance's SSL requirements.
|
/// Returns an InvidiousAPI configured for the instance's SSL and auth requirements.
|
||||||
private func invidiousAPI(for instance: Instance) -> InvidiousAPI {
|
private func invidiousAPI(for instance: Instance) async -> InvidiousAPI {
|
||||||
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||||
|
return InvidiousAPI(httpClient: authClient)
|
||||||
|
}
|
||||||
if !instance.allowInvalidCertificates {
|
if !instance.allowInvalidCertificates {
|
||||||
return defaultInvidiousAPI
|
return defaultInvidiousAPI
|
||||||
}
|
}
|
||||||
@@ -129,8 +118,11 @@ actor ContentService: ContentServiceProtocol {
|
|||||||
return InvidiousAPI(httpClient: insecureClient)
|
return InvidiousAPI(httpClient: insecureClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a PipedAPI configured for the instance's SSL requirements.
|
/// Returns a PipedAPI configured for the instance's SSL and auth requirements.
|
||||||
private func pipedAPI(for instance: Instance) -> PipedAPI {
|
private func pipedAPI(for instance: Instance) async -> PipedAPI {
|
||||||
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||||
|
return PipedAPI(httpClient: authClient)
|
||||||
|
}
|
||||||
if !instance.allowInvalidCertificates {
|
if !instance.allowInvalidCertificates {
|
||||||
return defaultPipedAPI
|
return defaultPipedAPI
|
||||||
}
|
}
|
||||||
@@ -138,8 +130,11 @@ actor ContentService: ContentServiceProtocol {
|
|||||||
return PipedAPI(httpClient: insecureClient)
|
return PipedAPI(httpClient: insecureClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a PeerTubeAPI configured for the instance's SSL requirements.
|
/// Returns a PeerTubeAPI configured for the instance's SSL and auth requirements.
|
||||||
private func peerTubeAPI(for instance: Instance) -> PeerTubeAPI {
|
private func peerTubeAPI(for instance: Instance) async -> PeerTubeAPI {
|
||||||
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
||||||
|
return PeerTubeAPI(httpClient: authClient)
|
||||||
|
}
|
||||||
if !instance.allowInvalidCertificates {
|
if !instance.allowInvalidCertificates {
|
||||||
return defaultPeerTubeAPI
|
return defaultPeerTubeAPI
|
||||||
}
|
}
|
||||||
@@ -286,7 +281,7 @@ actor ContentService: ContentServiceProtocol {
|
|||||||
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||||
case .piped:
|
case .piped:
|
||||||
// Piped fallback - make separate calls (no storyboard support)
|
// Piped fallback - make separate calls (no storyboard support)
|
||||||
let pipedAPI = pipedAPI(for: instance)
|
let pipedAPI = await pipedAPI(for: instance)
|
||||||
async let videoTask = pipedAPI.video(id: id, instance: instance)
|
async let videoTask = pipedAPI.video(id: id, instance: instance)
|
||||||
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
|
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
|
||||||
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
|
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
|
||||||
@@ -298,7 +293,7 @@ actor ContentService: ContentServiceProtocol {
|
|||||||
|
|
||||||
case .peertube:
|
case .peertube:
|
||||||
// PeerTube fallback - make separate calls (no storyboard support)
|
// PeerTube fallback - make separate calls (no storyboard support)
|
||||||
let peerTubeAPI = peerTubeAPI(for: instance)
|
let peerTubeAPI = await peerTubeAPI(for: instance)
|
||||||
async let videoTask = peerTubeAPI.video(id: id, instance: instance)
|
async let videoTask = peerTubeAPI.video(id: id, instance: instance)
|
||||||
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
|
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
|
||||||
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)
|
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)
|
||||||
|
|||||||
@@ -14,42 +14,23 @@
|
|||||||
actor YatteeServerAPI: InstanceAPI {
|
actor YatteeServerAPI: InstanceAPI {
|
||||||
private let httpClient: HTTPClient
|
private let httpClient: HTTPClient
|
||||||
|
|
||||||
/// Optional Basic Auth header value for authenticated requests.
|
|
||||||
/// Set this when the server requires authentication.
|
|
||||||
private var authHeader: String?
|
|
||||||
|
|
||||||
init(httpClient: HTTPClient) {
|
init(httpClient: HTTPClient) {
|
||||||
self.httpClient = httpClient
|
self.httpClient = httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the Basic Auth header to use for all subsequent requests.
|
|
||||||
/// - Parameter authHeader: The Authorization header value (e.g., "Basic dXNlcjpwYXNz")
|
|
||||||
func setAuthHeader(_ authHeader: String?) {
|
|
||||||
self.authHeader = authHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds custom headers including auth if available.
|
|
||||||
private func buildHeaders(additionalHeaders: [String: String]? = nil) -> [String: String]? {
|
|
||||||
var headers = additionalHeaders ?? [:]
|
|
||||||
if let authHeader {
|
|
||||||
headers["Authorization"] = authHeader
|
|
||||||
}
|
|
||||||
return headers.isEmpty ? nil : headers
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - InstanceAPI
|
// MARK: - InstanceAPI
|
||||||
|
|
||||||
func trending(instance: Instance) async throws -> [Video] {
|
func trending(instance: Instance) async throws -> [Video] {
|
||||||
// Yattee server proxies trending from Invidious if configured
|
// Yattee server proxies trending from Invidious if configured
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/trending")
|
let endpoint = GenericEndpoint.get("/api/v1/trending")
|
||||||
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.map { $0.toVideo() }
|
return response.map { $0.toVideo() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func popular(instance: Instance) async throws -> [Video] {
|
func popular(instance: Instance) async throws -> [Video] {
|
||||||
// Yattee server proxies popular from Invidious if configured
|
// Yattee server proxies popular from Invidious if configured
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/popular")
|
let endpoint = GenericEndpoint.get("/api/v1/popular")
|
||||||
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.map { $0.toVideo() }
|
return response.map { $0.toVideo() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +52,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/search", query: queryParams)
|
let endpoint = GenericEndpoint.get("/api/v1/search", query: queryParams)
|
||||||
let response: [YatteeSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: [YatteeSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
|
|
||||||
// Helper to detect playlist IDs that may be returned as "video" type
|
// Helper to detect playlist IDs that may be returned as "video" type
|
||||||
// YouTube playlist IDs start with: PL (user playlist), RD (mix), OL (offline mix), UU (uploads)
|
// YouTube playlist IDs start with: PL (user playlist), RD (mix), OL (offline mix), UU (uploads)
|
||||||
@@ -131,18 +112,18 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
let endpoint = GenericEndpoint.get("/api/v1/search/suggestions", query: [
|
let endpoint = GenericEndpoint.get("/api/v1/search/suggestions", query: [
|
||||||
"q": query
|
"q": query
|
||||||
])
|
])
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func video(id: String, instance: Instance) async throws -> Video {
|
func video(id: String, instance: Instance) async throws -> Video {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
||||||
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.toVideo()
|
return response.toVideo()
|
||||||
}
|
}
|
||||||
|
|
||||||
func channel(id: String, instance: Instance) async throws -> Channel {
|
func channel(id: String, instance: Instance) async throws -> Channel {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)")
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)")
|
||||||
let response: YatteeChannel = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.toChannel()
|
return response.toChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +133,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
query["continuation"] = continuation
|
query["continuation"] = continuation
|
||||||
}
|
}
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/videos", query: query)
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/videos", query: query)
|
||||||
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return ChannelVideosPage(
|
return ChannelVideosPage(
|
||||||
videos: response.videos.map { $0.toVideo() },
|
videos: response.videos.map { $0.toVideo() },
|
||||||
continuation: response.continuation
|
continuation: response.continuation
|
||||||
@@ -165,7 +146,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
query["continuation"] = continuation
|
query["continuation"] = continuation
|
||||||
}
|
}
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/playlists", query: query)
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/playlists", query: query)
|
||||||
let response: YatteeChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return ChannelPlaylistsPage(
|
return ChannelPlaylistsPage(
|
||||||
playlists: response.playlists.map { $0.toPlaylist() },
|
playlists: response.playlists.map { $0.toPlaylist() },
|
||||||
continuation: response.continuation
|
continuation: response.continuation
|
||||||
@@ -178,7 +159,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
query["continuation"] = continuation
|
query["continuation"] = continuation
|
||||||
}
|
}
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/shorts", query: query)
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/shorts", query: query)
|
||||||
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return ChannelVideosPage(
|
return ChannelVideosPage(
|
||||||
videos: response.videos.map { $0.toVideo() },
|
videos: response.videos.map { $0.toVideo() },
|
||||||
continuation: response.continuation
|
continuation: response.continuation
|
||||||
@@ -191,7 +172,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
query["continuation"] = continuation
|
query["continuation"] = continuation
|
||||||
}
|
}
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/streams", query: query)
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/streams", query: query)
|
||||||
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return ChannelVideosPage(
|
return ChannelVideosPage(
|
||||||
videos: response.videos.map { $0.toVideo() },
|
videos: response.videos.map { $0.toVideo() },
|
||||||
continuation: response.continuation
|
continuation: response.continuation
|
||||||
@@ -200,7 +181,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
|
|
||||||
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)")
|
let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)")
|
||||||
let response: YatteePlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteePlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.toPlaylist()
|
return response.toPlaylist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +193,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
}
|
}
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query)
|
let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query)
|
||||||
do {
|
do {
|
||||||
let response: YatteeComments = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeComments = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return CommentsPage(
|
return CommentsPage(
|
||||||
comments: response.comments.map { $0.toComment() },
|
comments: response.comments.map { $0.toComment() },
|
||||||
continuation: response.continuation
|
continuation: response.continuation
|
||||||
@@ -227,13 +208,13 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
|
|
||||||
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
||||||
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.toStreams()
|
return response.toStreams()
|
||||||
}
|
}
|
||||||
|
|
||||||
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
|
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
||||||
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.toCaptions(baseURL: instance.url)
|
return response.toCaptions(baseURL: instance.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +224,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
"page": String(page)
|
"page": String(page)
|
||||||
])
|
])
|
||||||
// Yattee Server returns {"videos": [...]} wrapper, not a plain array
|
// Yattee Server returns {"videos": [...]} wrapper, not a plain array
|
||||||
let response: YatteeChannelSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeChannelSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
|
|
||||||
var items: [ChannelSearchItem] = []
|
var items: [ChannelSearchItem] = []
|
||||||
|
|
||||||
@@ -264,7 +245,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
|
|
||||||
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
||||||
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return (
|
return (
|
||||||
video: response.toVideo(),
|
video: response.toVideo(),
|
||||||
streams: response.toStreams(),
|
streams: response.toStreams(),
|
||||||
@@ -280,7 +261,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
/// directly to YouTube CDN, allowing the server to download at full speed and serve locally.
|
/// directly to YouTube CDN, allowing the server to download at full speed and serve locally.
|
||||||
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)", query: ["proxy": "true"])
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)", query: ["proxy": "true"])
|
||||||
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return response.toStreams()
|
return response.toStreams()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +269,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
/// Use this for downloads to get streams that route through the server.
|
/// 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]) {
|
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"])
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)", query: ["proxy": "true"])
|
||||||
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return (
|
return (
|
||||||
video: response.toVideo(),
|
video: response.toVideo(),
|
||||||
streams: response.toStreams(),
|
streams: response.toStreams(),
|
||||||
@@ -315,7 +296,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
queryItems: [URLQueryItem(name: "url", value: url.absoluteString)],
|
queryItems: [URLQueryItem(name: "url", value: url.absoluteString)],
|
||||||
timeout: 180 // 3 minutes for slow site extraction
|
timeout: 180 // 3 minutes for slow site extraction
|
||||||
)
|
)
|
||||||
let response: YatteeExternalVideo = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeExternalVideo = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return (
|
return (
|
||||||
video: response.toVideo(originalURL: url),
|
video: response.toVideo(originalURL: url),
|
||||||
streams: response.toStreams(),
|
streams: response.toStreams(),
|
||||||
@@ -343,7 +324,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
],
|
],
|
||||||
timeout: 180 // 3 minutes for slow site extraction
|
timeout: 180 // 3 minutes for slow site extraction
|
||||||
)
|
)
|
||||||
let response: YatteeExternalChannel = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
let response: YatteeExternalChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
return (
|
return (
|
||||||
channel: response.toChannel(originalURL: url),
|
channel: response.toChannel(originalURL: url),
|
||||||
videos: response.toVideos(channelURL: url),
|
videos: response.toVideos(channelURL: url),
|
||||||
@@ -357,14 +338,14 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse {
|
func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse {
|
||||||
let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset)
|
let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset)
|
||||||
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
|
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks feed status for given channels (lightweight polling).
|
/// Checks feed status for given channels (lightweight polling).
|
||||||
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
|
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
|
||||||
let body = StatelessFeedStatusRequest(channels: channels)
|
let body = StatelessFeedStatusRequest(channels: channels)
|
||||||
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
|
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Channel Metadata
|
// MARK: - Channel Metadata
|
||||||
@@ -374,7 +355,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
func channelsMetadata(channelIDs: [String], instance: Instance) async throws -> ChannelsMetadataResponse {
|
func channelsMetadata(channelIDs: [String], instance: Instance) async throws -> ChannelsMetadataResponse {
|
||||||
let body = ChannelMetadataRequest(channelIds: channelIDs)
|
let body = ChannelMetadataRequest(channelIds: channelIDs)
|
||||||
let endpoint = GenericEndpoint.post("/api/v1/channels/metadata", body: body)
|
let endpoint = GenericEndpoint.post("/api/v1/channels/metadata", body: body)
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Server Info
|
// MARK: - Server Info
|
||||||
@@ -382,7 +363,7 @@ actor YatteeServerAPI: InstanceAPI {
|
|||||||
/// Fetches server info including version, dependencies, and enabled sites.
|
/// Fetches server info including version, dependencies, and enabled sites.
|
||||||
func fetchServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo {
|
func fetchServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo {
|
||||||
let endpoint = GenericEndpoint.get("/info")
|
let endpoint = GenericEndpoint.get("/info")
|
||||||
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
|
return try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,9 +101,11 @@ final class BackgroundFeedRefresher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient())
|
let httpClient = HTTPClient()
|
||||||
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer)
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) {
|
||||||
await yatteeServerAPI.setAuthHeader(authHeader)
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
||||||
|
}
|
||||||
|
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
||||||
let response = try await yatteeServerAPI.postFeed(
|
let response = try await yatteeServerAPI.postFeed(
|
||||||
channels: channelRequests,
|
channels: channelRequests,
|
||||||
limit: 5 * notifiableSubscriptions.count,
|
limit: 5 * notifiableSubscriptions.count,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ actor HTTPClient {
|
|||||||
private var userAgent: String?
|
private var userAgent: String?
|
||||||
private var randomizeUserAgentPerRequest: Bool = false
|
private var randomizeUserAgentPerRequest: Bool = false
|
||||||
|
|
||||||
|
/// Headers automatically applied to every request from this client.
|
||||||
|
/// Per-call `customHeaders` override these on key conflict.
|
||||||
|
/// Used to bake an HTTP Basic Auth `Authorization` header into a per-instance client
|
||||||
|
/// when the instance sits behind a reverse proxy that requires basic auth.
|
||||||
|
private var defaultHeaders: [String: String] = [:]
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
|
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
|
||||||
@@ -41,6 +47,12 @@ actor HTTPClient {
|
|||||||
self.randomizeUserAgentPerRequest = enabled
|
self.randomizeUserAgentPerRequest = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets headers to be applied to every request from this client.
|
||||||
|
/// Per-call `customHeaders` override these on key conflict.
|
||||||
|
func setDefaultHeaders(_ headers: [String: String]) {
|
||||||
|
self.defaultHeaders = headers
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Fetches and decodes a response from the given endpoint.
|
/// Fetches and decodes a response from the given endpoint.
|
||||||
@@ -104,7 +116,13 @@ actor HTTPClient {
|
|||||||
mutableRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
mutableRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom headers (e.g., X-API-Key for authenticated requests)
|
// Apply default headers first (e.g., basic auth bound to a per-instance client)
|
||||||
|
for (key, value) in defaultHeaders {
|
||||||
|
mutableRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom headers (e.g., X-API-Key for authenticated requests).
|
||||||
|
// These override default headers on key conflict.
|
||||||
if let customHeaders {
|
if let customHeaders {
|
||||||
for (key, value) in customHeaders {
|
for (key, value) in customHeaders {
|
||||||
mutableRequest.setValue(value, forHTTPHeaderField: key)
|
mutableRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
|
|||||||
@@ -491,9 +491,11 @@ final class SubscriptionFeedCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient())
|
let httpClient = HTTPClient()
|
||||||
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance)
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) {
|
||||||
await yatteeServerAPI.setAuthHeader(authHeader)
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
||||||
|
}
|
||||||
|
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
||||||
LoggingService.shared.debug("refreshFromStatelessServer: Calling postFeed for \(channelRequests.count) channels", category: .general)
|
LoggingService.shared.debug("refreshFromStatelessServer: Calling postFeed for \(channelRequests.count) channels", category: .general)
|
||||||
let response = try await yatteeServerAPI.postFeed(
|
let response = try await yatteeServerAPI.postFeed(
|
||||||
channels: channelRequests,
|
channels: channelRequests,
|
||||||
@@ -555,9 +557,11 @@ final class SubscriptionFeedCache {
|
|||||||
let statusChannels = channels.map {
|
let statusChannels = channels.map {
|
||||||
StatelessChannelStatusRequest(channelId: $0.channelId, site: $0.site)
|
StatelessChannelStatusRequest(channelId: $0.channelId, site: $0.site)
|
||||||
}
|
}
|
||||||
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient())
|
let httpClient = HTTPClient()
|
||||||
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance)
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) {
|
||||||
await yatteeServerAPI.setAuthHeader(authHeader)
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
||||||
|
}
|
||||||
|
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
||||||
|
|
||||||
let maxRetries = 5
|
let maxRetries = 5
|
||||||
var retryCount = 0
|
var retryCount = 0
|
||||||
|
|||||||
@@ -438,9 +438,11 @@ struct ManageChannelsView: View {
|
|||||||
guard !channelIDs.isEmpty else { return }
|
guard !channelIDs.isEmpty else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let api = YatteeServerAPI(httpClient: HTTPClient())
|
let httpClient = HTTPClient()
|
||||||
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer)
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) {
|
||||||
await api.setAuthHeader(authHeader)
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
||||||
|
}
|
||||||
|
let api = YatteeServerAPI(httpClient: httpClient)
|
||||||
let response = try await api.channelsMetadata(channelIDs: channelIDs, instance: yatteeServer)
|
let response = try await api.channelsMetadata(channelIDs: channelIDs, instance: yatteeServer)
|
||||||
|
|
||||||
// Update subscriptions in SwiftData
|
// Update subscriptions in SwiftData
|
||||||
|
|||||||
Reference in New Issue
Block a user