diff --git a/Yattee/Services/API/ContentService.swift b/Yattee/Services/API/ContentService.swift index 708bf639..68333892 100644 --- a/Yattee/Services/API/ContentService.swift +++ b/Yattee/Services/API/ContentService.swift @@ -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 { diff --git a/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift b/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift index a473de9a..4661f2f7 100644 --- a/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift +++ b/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift @@ -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) diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index d819313e..bb24f28a 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -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) diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index 78c7c713..919ddfa7 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -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 } diff --git a/Yattee/Views/Settings/ImportPlaylistsView.swift b/Yattee/Views/Settings/ImportPlaylistsView.swift index efe38bd8..4536d7c7 100644 --- a/Yattee/Views/Settings/ImportPlaylistsView.swift +++ b/Yattee/Views/Settings/ImportPlaylistsView.swift @@ -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 diff --git a/Yattee/Views/Settings/ImportSubscriptionsView.swift b/Yattee/Views/Settings/ImportSubscriptionsView.swift index d5a529fd..cf114bba 100644 --- a/Yattee/Views/Settings/ImportSubscriptionsView.swift +++ b/Yattee/Views/Settings/ImportSubscriptionsView.swift @@ -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 diff --git a/Yattee/Views/Settings/InstanceLoginView.swift b/Yattee/Views/Settings/InstanceLoginView.swift index 4c82ab5a..c50c26c7 100644 --- a/Yattee/Views/Settings/InstanceLoginView.swift +++ b/Yattee/Views/Settings/InstanceLoginView.swift @@ -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 + ) } }