mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -323,8 +323,10 @@ final class BackgroundFeedRefresher {
|
||||
|
||||
do {
|
||||
// Fetch the Piped feed
|
||||
let pipedAPI = PipedAPI(httpClient: appEnvironment.httpClient)
|
||||
let feedVideos = try await pipedAPI.feed(instance: pipedInstance, authToken: authToken)
|
||||
let feedVideos = try await appEnvironment.contentService.feed(
|
||||
for: pipedInstance,
|
||||
credential: authToken
|
||||
)
|
||||
|
||||
LoggingService.shared.debug("API returned \(feedVideos.count) total videos", category: .notifications)
|
||||
|
||||
|
||||
@@ -426,8 +426,10 @@ final class SubscriptionFeedCache {
|
||||
hasMorePages = false // Piped doesn't support pagination
|
||||
|
||||
do {
|
||||
let pipedAPI = PipedAPI(httpClient: appEnvironment.httpClient)
|
||||
let feedVideos = try await pipedAPI.feed(instance: instance, authToken: authToken)
|
||||
let feedVideos = try await appEnvironment.contentService.feed(
|
||||
for: instance,
|
||||
credential: authToken
|
||||
)
|
||||
guard isCurrentAccountsResult(generation) else { return }
|
||||
|
||||
LoggingService.shared.info("Piped feed: Received \(feedVideos.count) videos", category: .general)
|
||||
|
||||
@@ -1309,39 +1309,34 @@ struct InstanceBrowseView: View {
|
||||
let videos: [Video]
|
||||
let hasMore: Bool
|
||||
|
||||
let contentService = appEnvironment.contentService
|
||||
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
|
||||
|
||||
// Load subscriptions for channel filter
|
||||
async let subscriptionsTask = api.subscriptions(instance: instance, sid: credential)
|
||||
async let subscriptionsTask = contentService.subscriptions(for: instance, credential: credential)
|
||||
|
||||
// Load feed
|
||||
async let feedTask = api.feed(instance: instance, sid: credential, page: feedPage)
|
||||
async let feedTask = contentService.feed(for: instance, credential: credential, page: feedPage)
|
||||
|
||||
let (subscriptions, feedResponse) = try await (subscriptionsTask, feedTask)
|
||||
|
||||
// Fetch channel thumbnails in parallel (subscriptions API doesn't include them)
|
||||
let enrichedSubscriptions = await fetchChannelThumbnails(
|
||||
subscriptionChannels = await fetchChannelThumbnails(
|
||||
for: subscriptions,
|
||||
instance: instance,
|
||||
api: api
|
||||
instance: instance
|
||||
)
|
||||
|
||||
subscriptionChannels = enrichedSubscriptions.map { $0.toChannel(baseURL: instance.url) }
|
||||
videos = feedResponse.videos
|
||||
hasMore = feedResponse.hasMore
|
||||
|
||||
case .piped:
|
||||
let api = PipedAPI(httpClient: appEnvironment.httpClient)
|
||||
|
||||
// Load subscriptions and feed in parallel
|
||||
async let subscriptionsTask = api.subscriptions(instance: instance, authToken: credential)
|
||||
async let feedTask = api.feed(instance: instance, authToken: credential)
|
||||
async let subscriptionsTask = contentService.subscriptions(for: instance, credential: credential)
|
||||
async let feedTask = contentService.feed(for: instance, credential: credential)
|
||||
|
||||
let (pipedSubscriptions, feedVideos) = try await (subscriptionsTask, feedTask)
|
||||
|
||||
subscriptionChannels = pipedSubscriptions.map { $0.toChannel() }
|
||||
subscriptionChannels = pipedSubscriptions
|
||||
videos = feedVideos
|
||||
// Piped doesn't support pagination for feed
|
||||
hasMore = false
|
||||
@@ -1399,36 +1394,36 @@ struct InstanceBrowseView: View {
|
||||
/// Fetches channel thumbnails for subscriptions, using cache when available.
|
||||
/// Only fetches from network for channels not already cached.
|
||||
private func fetchChannelThumbnails(
|
||||
for subscriptions: [InvidiousSubscription],
|
||||
instance: Instance,
|
||||
api: InvidiousAPI
|
||||
) async -> [InvidiousSubscription] {
|
||||
guard let credentialsManager = appEnvironment?.invidiousCredentialsManager else {
|
||||
return subscriptions
|
||||
}
|
||||
for subscriptions: [Channel],
|
||||
instance: Instance
|
||||
) async -> [Channel] {
|
||||
guard let appEnvironment else { return subscriptions }
|
||||
let credentialsManager = appEnvironment.invidiousCredentialsManager
|
||||
let contentService = appEnvironment.contentService
|
||||
|
||||
// Build initial thumbnail map from cache
|
||||
var thumbnailMap: [String: URL] = [:]
|
||||
for subscription in subscriptions {
|
||||
if let cachedURL = credentialsManager.thumbnailURL(forChannelID: subscription.authorId) {
|
||||
thumbnailMap[subscription.authorId] = cachedURL
|
||||
if let cachedURL = credentialsManager.thumbnailURL(forChannelID: subscription.id.channelID) {
|
||||
thumbnailMap[subscription.id.channelID] = cachedURL
|
||||
}
|
||||
}
|
||||
|
||||
// Find channels that need fetching
|
||||
let allChannelIDs = subscriptions.map(\.authorId)
|
||||
let allChannelIDs = subscriptions.map(\.id.channelID)
|
||||
let uncachedIDs = Set(credentialsManager.uncachedChannelIDs(from: allChannelIDs))
|
||||
|
||||
// Fetch only uncached channels in parallel
|
||||
if !uncachedIDs.isEmpty {
|
||||
let fetchedThumbnails = await withTaskGroup(of: (String, URL?).self) { group in
|
||||
for subscription in subscriptions where uncachedIDs.contains(subscription.authorId) {
|
||||
for subscription in subscriptions where uncachedIDs.contains(subscription.id.channelID) {
|
||||
let channelID = subscription.id.channelID
|
||||
group.addTask {
|
||||
do {
|
||||
let channel = try await api.channel(id: subscription.authorId, instance: instance)
|
||||
return (subscription.authorId, channel.thumbnailURL)
|
||||
let channel = try await contentService.channel(id: channelID, instance: instance)
|
||||
return (channelID, channel.thumbnailURL)
|
||||
} catch {
|
||||
return (subscription.authorId, nil)
|
||||
return (channelID, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1453,11 +1448,24 @@ struct InstanceBrowseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Create enriched subscriptions with thumbnails
|
||||
// Create enriched subscriptions with thumbnails. A fetched or cached
|
||||
// avatar wins over whatever the subscriptions listing carried, which is
|
||||
// the precedence the Invidious subscription conversion applied when this
|
||||
// ran over the raw listing.
|
||||
return subscriptions.map { subscription in
|
||||
var enriched = subscription
|
||||
enriched.thumbnailURL = thumbnailMap[subscription.authorId]
|
||||
return enriched
|
||||
guard let thumbnailURL = thumbnailMap[subscription.id.channelID] else {
|
||||
return subscription
|
||||
}
|
||||
return Channel(
|
||||
id: subscription.id,
|
||||
name: subscription.name,
|
||||
description: subscription.description,
|
||||
subscriberCount: subscription.subscriberCount,
|
||||
videoCount: subscription.videoCount,
|
||||
thumbnailURL: thumbnailURL,
|
||||
bannerURL: subscription.bannerURL,
|
||||
isVerified: subscription.isVerified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1477,8 +1485,11 @@ struct InstanceBrowseView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let api = InvidiousAPI(httpClient: appEnvironment.httpClient)
|
||||
let feedResponse = try await api.feed(instance: instance, sid: sid, page: feedPage)
|
||||
let feedResponse = try await appEnvironment.contentService.feed(
|
||||
for: instance,
|
||||
credential: sid,
|
||||
page: feedPage
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
guard generation == accountContentGeneration else { return }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user