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

@@ -310,21 +310,15 @@ struct ImportPlaylistsView: View {
importProgress = nil
do {
let fetchedPlaylists: [Playlist]
switch instance.type {
case .invidious:
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
fetchedPlaylists = try await api.userPlaylists(instance: instance, sid: credential)
case .piped:
let api = PipedAPI(httpClient: appEnvironment.httpClient)
fetchedPlaylists = try await api.userPlaylists(instance: instance, authToken: credential)
default:
guard instance.type == .invidious || instance.type == .piped else {
throw ImportError.notSupported
}
let fetchedPlaylists = try await appEnvironment.contentService.userPlaylists(
instance: instance,
credential: credential
)
playlists = fetchedPlaylists.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
// Recorded only now: the baseline is whichever login actually
// produced the rows on screen. A failed load leaves the previous
@@ -436,30 +430,19 @@ struct ImportPlaylistsView: View {
existingLocalPlaylist = nil
do {
// Fetch full playlist with videos
let fullPlaylist: Playlist
switch instance.type {
case .invidious:
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
fullPlaylist = try await api.userPlaylist(
id: playlist.id.playlistID,
instance: instance,
sid: credential
)
case .piped:
let api = PipedAPI(httpClient: appEnvironment.httpClient)
fullPlaylist = try await api.userPlaylist(
id: playlist.id.playlistID,
instance: instance,
authToken: credential
)
default:
guard instance.type == .invidious || instance.type == .piped else {
throw ImportError.notSupported
}
// Fetch full playlist with videos. The snapshot's per-row identities
// are of no use to an import — nothing here is written back to the
// server — so only the playlist itself is taken.
let fullPlaylist = try await appEnvironment.contentService.userPlaylistSnapshot(
id: playlist.id.playlistID,
instance: instance,
credential: credential
).playlist
let videos = fullPlaylist.videos
let total = videos.count
var skippedCount = 0

View File

@@ -224,23 +224,15 @@ struct ImportSubscriptionsView: View {
error = nil
do {
let fetchedChannels: [Channel]
switch instance.type {
case .invidious:
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
let subscriptions = try await api.subscriptions(instance: instance, sid: credential)
fetchedChannels = subscriptions.map { $0.toChannel(baseURL: instance.url) }
case .piped:
let api = PipedAPI(httpClient: appEnvironment.httpClient)
let subscriptions = try await api.subscriptions(instance: instance, authToken: credential)
fetchedChannels = subscriptions.map { $0.toChannel() }
default:
guard instance.type == .invidious || instance.type == .piped else {
throw ImportError.notSupported
}
let fetchedChannels = try await appEnvironment.contentService.subscriptions(
for: instance,
credential: credential
)
channels = fetchedChannels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
// Get already-subscribed channel IDs

View File

@@ -204,38 +204,15 @@ struct InstanceLoginView: View {
/// Performs the login based on instance type.
/// - Returns: The credential (SID for Invidious, token for Piped)
private func performLogin(appEnvironment: AppEnvironment) async throws -> String {
// If the instance sits behind an HTTP Basic Auth reverse proxy, the login
// POST must carry that proxy's Authorization header too — otherwise the
// request 401s before reaching the upstream login endpoint. Bake the
// header into a fresh per-instance HTTPClient.
let basicAuthHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance)
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }
let httpClient: HTTPClient
if let basicAuthHeader {
httpClient = appEnvironment.httpClientFactory.createClient(for: instance)
await httpClient.setDefaultHeaders(["Authorization": basicAuthHeader])
} else {
httpClient = appEnvironment.httpClient
}
switch instance.type {
case .invidious:
// InvidiousAPI.login uses its own URLSession (to handle redirect/Set-Cookie),
// so it doesn't inherit defaultHeaders from the injected HTTPClient. Pass
// the basic-auth header explicitly so the login POST passes the proxy.
let api = InvidiousAPI(httpClient: httpClient)
return try await api.login(email: username, password: password, instance: instance, extraHeaders: extraHeaders)
case .piped:
// PipedAPI.login uses httpClient.fetch which DOES inherit defaultHeaders,
// so the basic-auth header on the per-instance client is sufficient.
let api = PipedAPI(httpClient: httpClient)
return try await api.login(username: username, password: password, instance: instance)
default:
throw APIError.notSupported
}
// The service owns the per-instance configuration a login needs — the
// reverse-proxy basic auth (without which the POST 401s before reaching
// the upstream login endpoint), the certificate policy, and the
// User-Agent instances may insist on.
try await appEnvironment.contentService.login(
username: username,
password: password,
instance: instance
)
}
}