Let every request reach an instance the same way

A handful of call sites built their own InvidiousAPI or PipedAPI over the
shared HTTP client, which quietly opted them out of everything an instance
may require of a request: the reverse-proxy basic auth, the certificate
policy, and the User-Agent some instances whitelist and answer 401 without.
The feed on an instance behind a proxy could load in one place and fail in
another for no reason the user could see. They now all go through the
service that already configures a client per instance.

Login was the site that had noticed. It reproduced the basic-auth handling
itself, including the header Invidious' login needs passed explicitly
because that POST runs on its own session to read the SID out of a cookie.
That logic moves into the service verbatim rather than being dropped: the
view now asks for a credential and is told nothing about how it was
obtained.

Paging is the one thing the service could not already express. Invidious
feeds have pages and Piped feeds do not, so the paged variant says so in
its return value instead of leaving each caller to know.

The browse view's subscriptions arrive as channels now rather than as raw
Invidious rows, so the avatar enrichment works over channels — a fetched
or cached avatar still wins over whatever the listing carried, which is
the precedence the conversion applied before.
This commit is contained in:
Arkadiusz Fal
2026-09-02 23:53:33 +02:00
parent f0ebe9b612
commit b3c4216013
7 changed files with 158 additions and 117 deletions

View File

@@ -172,6 +172,31 @@ actor ContentService: ContentServiceProtocol {
}
}
/// Fetches one page of the credential owner's feed.
///
/// Separate from the unpaged `feed(for:credential:)` because only Invidious
/// has pages at all: Piped answers with the whole feed however it is asked,
/// which is why it always reports there is no more to come.
func feed(for instance: Instance, credential: String, page: Int) async throws -> FeedPage {
switch instance.type {
case .invidious:
let response = try await invidiousAPI(for: instance).feed(
instance: instance,
sid: credential,
page: page
)
return FeedPage(videos: response.videos, hasMore: response.hasMore)
case .piped:
let videos = try await pipedAPI(for: instance).feed(
instance: instance,
authToken: credential
)
return FeedPage(videos: videos, hasMore: false)
default:
throw APIError.notSupported
}
}
func subscriptions(for instance: Instance, credential: String) async throws -> [Channel] {
switch instance.type {
case .invidious:
@@ -469,6 +494,46 @@ actor ContentService: ContentServiceProtocol {
}
}
// MARK: - Authentication
/// Signs in to an instance and returns the credential its backend issues —
/// a session id for Invidious, an auth token for Piped.
///
/// Routed through here so a login carries the same per-instance
/// configuration every other request does: the reverse-proxy basic auth, the
/// certificate policy, and the User-Agent some instances insist on. A login
/// that skipped any of them would fail before reaching the login endpoint.
func login(username: String, password: String, instance: Instance) async throws -> String {
switch instance.type {
case .invidious:
// Invidious' login POST runs on its own ephemeral URLSession so it
// can read the Set-Cookie the SID arrives in, and therefore inherits
// nothing from the client's default headers. An instance behind a
// basic-auth proxy needs that header handed over explicitly, or the
// proxy answers 401 before the upstream ever sees the credentials.
var extraHeaders: [String: String]?
if let basicAuthHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) {
extraHeaders = ["Authorization": basicAuthHeader]
}
return try await invidiousAPI(for: instance).login(
email: username,
password: password,
instance: instance,
extraHeaders: extraHeaders
)
case .piped:
// Piped's login goes through the HTTPClient, so the per-instance
// client already carries the proxy header in its default headers.
return try await pipedAPI(for: instance).login(
username: username,
password: password,
instance: instance
)
default:
throw APIError.notSupported
}
}
/// 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] {
@@ -593,6 +658,15 @@ actor ContentService: ContentServiceProtocol {
/// that satisfy it; the protocol exists so tests can script a server.
extension ContentService: RemotePlaylistTransport {}
// MARK: - Feed Page
/// One page of an authenticated feed, with whatever the backend says about
/// there being another.
struct FeedPage: Sendable {
let videos: [Video]
let hasMore: Bool
}
// MARK: - Search Result
enum OrderedSearchItem: Sendable {