mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The refresh captured its account generation before hopping onto the detached task it runs in. That hop is a suspension point: a context handler running in the gap - the wake-time status flap bumps the generation there - left the request carrying a stale generation. Its completion was then fenced out, and the fenced return deliberately leaves isLoading alone, so nothing current owned the flag: it stayed true, every later refresh skipped itself as already loading, and the retry paths only watch for errors - the feed sat on a spinner nothing would ever finish. The generation and context are now read at dispatch time, so the request that claims isLoading always carries the generation current at that moment and its completion is the one that releases it. The stateless-server poller's terminal writes get the same fence: a stale poller must not hand a newer request's spinner an early ready.
871 lines
35 KiB
Swift
871 lines
35 KiB
Swift
//
|
|
// SubscriptionFeedCache.swift
|
|
// Yattee
|
|
//
|
|
// Subscription feed cache with disk persistence.
|
|
// Routes feed fetching based on subscription account type.
|
|
//
|
|
|
|
import Foundation
|
|
#if os(iOS) || os(tvOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// State of feed loading.
|
|
enum FeedLoadState: Equatable {
|
|
case idle
|
|
case loading
|
|
case loadingMore
|
|
case ready
|
|
case partiallyLoaded(readyCount: Int, pendingCount: Int, errorCount: Int)
|
|
case error(FeedLoadError)
|
|
|
|
/// The feed error worth showing in this state, if any.
|
|
///
|
|
/// While the context banner is up, only the errors it already answers for
|
|
/// stand down — the banner names their cause, so it is the one that stays.
|
|
/// Everything else keeps its own message and its retry: a local refresh
|
|
/// that failed on the network is news the banner does not carry, and
|
|
/// swallowing it leaves a stale feed with nothing to say and nothing to
|
|
/// press.
|
|
func displayedError(contextBannerVisible: Bool) -> FeedLoadError? {
|
|
guard case let .error(error) = self else { return nil }
|
|
guard !contextBannerVisible || !error.isAnsweredByContextBanner else { return nil }
|
|
|
|
return error
|
|
}
|
|
|
|
/// Whether a feed left in this state should keep asking again on its own.
|
|
///
|
|
/// A network failure is its own reason to try again. `yatteeServerRequired`
|
|
/// usually is not — an account that genuinely needs a Yattee Server goes on
|
|
/// needing one, and a timer there would never end and never change
|
|
/// anything. The exception is the local fallback standing in for a server
|
|
/// that merely could not be reached: the feed is asking for a Yattee Server
|
|
/// only because reads moved to local data, and they move back the moment
|
|
/// the server answers again — silently, on a status that expires with
|
|
/// nothing on this screen watching for it.
|
|
///
|
|
/// - Parameter unavailability: why the selected context is not the one
|
|
/// answering, or `nil` when it is — local data selected by the user is
|
|
/// never unavailable, so its Yattee Server request is the real answer.
|
|
func warrantsPeriodicRetry(unavailability: DataContextUnavailability?) -> Bool {
|
|
switch self {
|
|
case .error(.networkError):
|
|
return true
|
|
case .error(.yatteeServerRequired):
|
|
return unavailability == .unreachable
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Specific error types for feed loading.
|
|
enum FeedLoadError: Equatable {
|
|
case yatteeServerRequired
|
|
case notAuthenticated
|
|
case networkError(String)
|
|
|
|
/// Whether a context banner, when one is up, already answers for this.
|
|
///
|
|
/// A selected account that cannot be used reads local data, and the
|
|
/// local feed then asks for the Yattee Server it has no reason to have
|
|
/// — "add a subscriptions account in Settings" under a banner saying
|
|
/// *this* account needs signing in again is two answers to one
|
|
/// question. These two are the account the banner is already talking
|
|
/// about; a refresh that failed on the way out is not, and has a retry
|
|
/// worth offering.
|
|
var isAnsweredByContextBanner: Bool {
|
|
switch self {
|
|
case .yatteeServerRequired, .notAuthenticated:
|
|
return true
|
|
case .networkError:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscription feed cache with disk persistence.
|
|
/// Routes feed fetching based on the selected subscription account type:
|
|
/// - Local accounts: Use Yattee Server (required)
|
|
/// - Invidious accounts: Use Invidious /api/v1/auth/feed endpoint
|
|
@MainActor
|
|
@Observable
|
|
final class SubscriptionFeedCache {
|
|
static let shared = SubscriptionFeedCache()
|
|
|
|
var videos: [Video] = []
|
|
var lastUpdated: Date?
|
|
var isLoading = false
|
|
var loadingProgress: (loaded: Int, total: Int)?
|
|
var hasLoadedOnce = false
|
|
|
|
/// State of feed loading.
|
|
var feedLoadState: FeedLoadState = .idle
|
|
|
|
/// Whether more pages are available from Invidious feed (for infinite scroll).
|
|
var hasMorePages = false
|
|
|
|
/// Whether the disk cache has been loaded.
|
|
private var diskCacheLoaded = false
|
|
|
|
/// Current page for Invidious feed pagination (1-based).
|
|
private var currentPage = 1
|
|
|
|
/// Active polling task for feed status (can be cancelled).
|
|
private var pollingTask: Task<Void, Never>?
|
|
|
|
/// Whether app is in foreground (polling only allowed in foreground).
|
|
private var isAppInForeground = true
|
|
|
|
private init() {
|
|
setupLifecycleObservers()
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// Sets up observers to detect when app goes to background.
|
|
private func setupLifecycleObservers() {
|
|
#if os(iOS) || os(tvOS)
|
|
NotificationCenter.default.addObserver(
|
|
forName: UIApplication.willResignActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [self] in
|
|
self.handleAppBackgrounded()
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
NotificationCenter.default.addObserver(
|
|
forName: NSApplication.willResignActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [self] in
|
|
self.handleAppBackgrounded()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Handles app going to background - cancels any active polling.
|
|
private func handleAppBackgrounded() {
|
|
isAppInForeground = false
|
|
cancelPolling()
|
|
}
|
|
|
|
/// Cancels any active feed status polling.
|
|
func cancelPolling() {
|
|
pollingTask?.cancel()
|
|
pollingTask = nil
|
|
}
|
|
|
|
// MARK: - Cache Validity
|
|
|
|
/// Returns true if the cache is valid based on the configured validity duration.
|
|
/// Uses the setting from SettingsManager (default 30 minutes).
|
|
func isCacheValid(using settingsManager: SettingsManager?) -> Bool {
|
|
guard let lastUpdated, !videos.isEmpty else { return false }
|
|
let validitySeconds = settingsManager?.feedCacheValiditySeconds
|
|
?? TimeInterval(SettingsManager.defaultFeedCacheValidityMinutes * 60)
|
|
return Date().timeIntervalSince(lastUpdated) < validitySeconds
|
|
}
|
|
|
|
// MARK: - Disk Cache
|
|
|
|
/// Loads cached data from disk if not already loaded.
|
|
/// Call this early (e.g., on app launch) to populate the feed quickly.
|
|
func loadFromDiskIfNeeded() async {
|
|
guard !diskCacheLoaded else {
|
|
LoggingService.shared.debug("Feed cache already loaded from disk, skipping", category: .general)
|
|
return
|
|
}
|
|
diskCacheLoaded = true
|
|
|
|
// Ordered behind any purge already asked for, or this reads the file
|
|
// that purge is about to delete.
|
|
await diskClearTask?.value
|
|
|
|
let generation = accountGeneration
|
|
|
|
if let cacheData = await FeedCache.shared.load() {
|
|
// The identity or the login can change while this is awaiting the
|
|
// disk. Applying what it brings back would restore the previous
|
|
// account's private feed on top of a cache that was deliberately
|
|
// emptied.
|
|
guard generation == accountGeneration else {
|
|
LoggingService.shared.info(
|
|
"Discarding a disk feed that belongs to the previous login",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
videos = cacheData.videos
|
|
lastUpdated = cacheData.lastUpdated
|
|
hasLoadedOnce = true
|
|
let videoCount = videos.count
|
|
let oldestVideoDate = videos.compactMap { $0.publishedAt }.min()
|
|
let age = Date().timeIntervalSince(cacheData.lastUpdated)
|
|
LoggingService.shared.debug(
|
|
"Loaded feed cache from disk: \(videoCount) videos, lastUpdated: \(cacheData.lastUpdated) (\(Int(age))s ago), oldest video: \(oldestVideoDate?.description ?? "none")",
|
|
category: .general
|
|
)
|
|
} else {
|
|
LoggingService.shared.debug("No feed cache found on disk", category: .general)
|
|
}
|
|
}
|
|
|
|
func clear() {
|
|
cancelPolling()
|
|
videos = []
|
|
lastUpdated = nil
|
|
hasMorePages = false
|
|
currentPage = 1
|
|
// The Top Shelf snapshot lives in the shared app group and is read by
|
|
// tvOS without the app running, so emptying the cache is not enough:
|
|
// the previous login's feed would stay on the system home screen until
|
|
// some later refresh happened to rewrite it. Written from the cleared
|
|
// cache, so this erases it.
|
|
TopShelfSnapshotWriter.writeFeed()
|
|
diskClearTask = Task {
|
|
await FeedCache.shared.clear()
|
|
}
|
|
}
|
|
|
|
/// The outstanding erase of the on-disk feed.
|
|
///
|
|
/// A load starting after a purge can still reach the cache actor first and
|
|
/// bring the erased feed back — the generation it captures is already the
|
|
/// new one, so nothing downstream can tell. Loads wait for this instead.
|
|
private var diskClearTask: Task<Void, Never>?
|
|
|
|
func invalidate() {
|
|
lastUpdated = nil
|
|
Task {
|
|
await FeedCache.shared.invalidate()
|
|
}
|
|
}
|
|
|
|
/// Saves the current feed state to disk.
|
|
private func saveToDisk() {
|
|
guard let lastUpdated else {
|
|
LoggingService.shared.warning("saveToDisk called but lastUpdated is nil", category: .general)
|
|
return
|
|
}
|
|
LoggingService.shared.debug("Saving feed cache to disk: \(videos.count) videos, lastUpdated: \(lastUpdated)", category: .general)
|
|
Task {
|
|
await FeedCache.shared.save(videos: videos, lastUpdated: lastUpdated)
|
|
}
|
|
}
|
|
|
|
/// Updates the cache with new videos and persists to disk.
|
|
///
|
|
/// `generation` is the account this result was requested for, captured when
|
|
/// the request started — several requests can be in flight at once, so it
|
|
/// cannot be read from shared state at the end.
|
|
func update(videos: [Video], generation: Int) {
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
|
|
let oldCount = self.videos.count
|
|
let oldDate = self.lastUpdated
|
|
self.videos = videos
|
|
self.lastUpdated = Date()
|
|
self.hasLoadedOnce = true
|
|
LoggingService.shared.info(
|
|
"Feed cache updated: \(oldCount) -> \(videos.count) videos, lastUpdated changed from \(oldDate?.description ?? "nil") to \(Date())",
|
|
category: .general
|
|
)
|
|
saveToDisk()
|
|
TopShelfSnapshotWriter.writeFeed()
|
|
}
|
|
|
|
/// Appends videos to the existing cache (for pagination).
|
|
private func appendVideos(_ newVideos: [Video], generation: Int) {
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
|
|
let existingIDs = Set(videos.map { $0.id })
|
|
let uniqueNewVideos = newVideos.filter { !existingIDs.contains($0.id) }
|
|
videos.append(contentsOf: uniqueNewVideos)
|
|
lastUpdated = Date()
|
|
LoggingService.shared.debug(
|
|
"Appended \(uniqueNewVideos.count) videos to feed cache (total: \(videos.count))",
|
|
category: .general
|
|
)
|
|
saveToDisk()
|
|
}
|
|
|
|
/// Reports what a feed request implies about the account it used.
|
|
///
|
|
/// The feed talks to the same servers as the subscription providers but
|
|
/// does not go through one, so without this a credential that expires
|
|
/// during a feed-only refresh would keep looking signed in and the local
|
|
/// fallback would never engage.
|
|
private func recordOutcome(_ error: Error?, backend: AppEnvironment.FeedBackend, using appEnvironment: AppEnvironment) {
|
|
let type: InstanceType = backend == .invidious ? .invidious : .piped
|
|
guard let account = appEnvironment.account(forSelectedContext: type) else { return }
|
|
|
|
appEnvironment.accountsManager.recordServerOutcome(error, for: account)
|
|
}
|
|
|
|
// MARK: - Account Change Handling
|
|
|
|
/// Clears cache when subscription account changes.
|
|
/// Call this when the user switches between local and Invidious accounts.
|
|
func handleAccountChange() {
|
|
LoggingService.shared.info("Subscription account changed, clearing feed cache", category: .general)
|
|
// Anything already in flight was authenticated as the previous account.
|
|
// Clearing does not cancel it, and signing in again keeps the same
|
|
// account ID and context — so without this fence the old request lands
|
|
// afterwards and writes the previous user's private feed straight back
|
|
// into the cache, and onto disk.
|
|
accountGeneration += 1
|
|
clear()
|
|
feedLoadState = .idle
|
|
// The request that was running belongs to the previous account and its
|
|
// result is now fenced out, so it must not go on blocking the
|
|
// replacement — the new account would sit on an empty feed until
|
|
// something else asked for a refresh.
|
|
isLoading = false
|
|
}
|
|
|
|
/// Fences whatever is in flight and lets a replacement start, keeping the
|
|
/// feed that is already here.
|
|
///
|
|
/// For a move between the selected account and local data that the user did
|
|
/// not make — an unreachable server, an instance switched off. The
|
|
/// selection has not changed, so what is cached is still that account's own
|
|
/// feed and it is the only thing standing between the banner and an empty
|
|
/// screen. Purging it is what left the feed with nothing to fall back to
|
|
/// and no way to reach a terminal state: the request it emptied the cache
|
|
/// for is the one that cannot answer.
|
|
func handleEffectiveContextChange() {
|
|
LoggingService.shared.info("Subscription reads moved, keeping the cached feed", category: .general)
|
|
accountGeneration += 1
|
|
cancelPolling()
|
|
feedLoadState = .idle
|
|
isLoading = false
|
|
}
|
|
|
|
/// Which login the cache currently holds data for.
|
|
private var accountGeneration = 0
|
|
|
|
/// The account a request starting now belongs to. Carried by the request
|
|
/// itself: switching accounts mid-flight starts a replacement while the
|
|
/// first one is still running.
|
|
private func beginRequest() -> Int {
|
|
accountGeneration
|
|
}
|
|
|
|
private func isCurrentAccountsResult(_ generation: Int) -> Bool {
|
|
guard generation == accountGeneration else {
|
|
LoggingService.shared.info(
|
|
"Discarding a feed result that belongs to the previous login",
|
|
category: .general
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Refresh
|
|
|
|
/// Refreshes the feed from network.
|
|
/// Routes based on subscription account type:
|
|
/// - Local: Requires Yattee Server
|
|
/// - Invidious: Uses authenticated feed endpoint
|
|
///
|
|
/// Note: This method uses a detached task internally to prevent SwiftUI from
|
|
/// cancelling the network request when view state changes during refresh.
|
|
func refresh(using appEnvironment: AppEnvironment) async {
|
|
guard !isLoading else {
|
|
LoggingService.shared.debug("Feed refresh called but already loading, skipping", category: .general)
|
|
return
|
|
}
|
|
|
|
// Use a detached task to prevent SwiftUI from cancelling the request
|
|
// when @State properties change during the refresh operation.
|
|
// This is necessary because loadSubscriptionsAsync() updates @State
|
|
// before feedCache.refresh() completes, which can cause SwiftUI to
|
|
// cancel the parent task.
|
|
//
|
|
// The generation and context are read *inside* the detached body, not
|
|
// before it: the hop onto it is a suspension point, and a context
|
|
// handler running in that gap bumps the generation and resets
|
|
// `isLoading`. A request dispatched with the pre-hop generation is
|
|
// fenced out on arrival, and the fenced return deliberately leaves
|
|
// `isLoading` alone — with nothing current owning it, the flag stayed
|
|
// true and every later refresh was skipped as "already loading",
|
|
// leaving a spinner nothing would ever finish. Reading at dispatch
|
|
// time means the request that sets `isLoading` always carries the
|
|
// generation that is current at that moment, so its completion is the
|
|
// one that resets it.
|
|
await Task.detached { @MainActor in
|
|
let generation = self.beginRequest()
|
|
|
|
// The *effective* context, so an account still waiting on its instance
|
|
// or credential falls back to local rather than reporting
|
|
// notAuthenticated while the subscriptions list already shows local data.
|
|
let context = appEnvironment.dataContextValidator.effectiveContext
|
|
let backend = appEnvironment.feedBackend(for: context)
|
|
LoggingService.shared.debug("Starting feed refresh for \(backend)", category: .general)
|
|
|
|
switch backend {
|
|
case .local:
|
|
await self.refreshLocalAccount(using: appEnvironment, generation: generation)
|
|
case .invidious:
|
|
await self.refreshInvidiousAccount(using: appEnvironment, generation: generation)
|
|
case .piped:
|
|
await self.refreshPipedAccount(using: appEnvironment, generation: generation)
|
|
}
|
|
}.value
|
|
}
|
|
|
|
/// Refreshes feed for local account using Yattee Server.
|
|
private func refreshLocalAccount(using appEnvironment: AppEnvironment, generation: Int) async {
|
|
// Require Yattee Server for local accounts
|
|
guard let serverInstance = appEnvironment.instancesManager.instances.first(where: {
|
|
$0.type == .yatteeServer && $0.isEnabled
|
|
}) else {
|
|
LoggingService.shared.warning("Local subscriptions require Yattee Server, but none is configured", category: .general)
|
|
feedLoadState = .error(.yatteeServerRequired)
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.info("Refreshing feed using Yattee Server: \(serverInstance.url.absoluteString)", category: .general)
|
|
await refreshFromStatelessServer(instance: serverInstance, using: appEnvironment, generation: generation)
|
|
}
|
|
|
|
/// Refreshes feed for Invidious account using authenticated feed endpoint.
|
|
private func refreshInvidiousAccount(using appEnvironment: AppEnvironment, generation: Int) async {
|
|
guard let (instance, sid) = getInvidiousAuth(using: appEnvironment) else {
|
|
LoggingService.shared.warning("Invidious account not authenticated", category: .general)
|
|
feedLoadState = .error(.notAuthenticated)
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.info("Refreshing feed using Invidious account: \(instance.url.absoluteString)", category: .general)
|
|
|
|
// Reset pagination for fresh refresh
|
|
currentPage = 1
|
|
|
|
await refreshFromInvidiousFeed(instance: instance, sid: sid, using: appEnvironment, generation: generation)
|
|
}
|
|
|
|
/// Gets the authenticated Invidious instance and session ID.
|
|
///
|
|
/// Resolved through the selected account and nothing else: a second login on
|
|
/// the same instance has its own credential, and the instance-keyed lookup
|
|
/// this used to fall back to cannot tell the two apart.
|
|
private func getInvidiousAuth(using appEnvironment: AppEnvironment) -> (Instance, String)? {
|
|
guard let account = appEnvironment.account(forSelectedContext: .invidious),
|
|
let instance = appEnvironment.accountsManager.instance(for: account),
|
|
let sid = appEnvironment.accountCredentialsManager(for: account)?.credential(for: account) else {
|
|
LoggingService.shared.debug("No Invidious account resolved for the selected context", category: .general)
|
|
return nil
|
|
}
|
|
|
|
return (instance, sid)
|
|
}
|
|
|
|
// MARK: - Piped Account
|
|
|
|
/// Refreshes feed for Piped account using authenticated feed endpoint.
|
|
private func refreshPipedAccount(using appEnvironment: AppEnvironment, generation: Int) async {
|
|
guard let (instance, authToken) = getPipedAuth(using: appEnvironment) else {
|
|
LoggingService.shared.warning("Piped account not authenticated", category: .general)
|
|
feedLoadState = .error(.notAuthenticated)
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.info("Refreshing feed using Piped account: \(instance.url.absoluteString)", category: .general)
|
|
|
|
isLoading = true
|
|
feedLoadState = .loading
|
|
loadingProgress = nil
|
|
hasMorePages = false // Piped doesn't support pagination
|
|
|
|
do {
|
|
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)
|
|
recordOutcome(nil, backend: .piped, using: appEnvironment)
|
|
|
|
update(videos: feedVideos, generation: generation)
|
|
appEnvironment.dataManager.updateLastVideoPublishedDates(from: feedVideos)
|
|
prefetchDeArrow(for: feedVideos, using: appEnvironment)
|
|
feedLoadState = .ready
|
|
} catch {
|
|
LoggingService.shared.error("Failed to fetch Piped feed: \(error.localizedDescription)", category: .general)
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
recordOutcome(error, backend: .piped, using: appEnvironment)
|
|
feedLoadState = .error(.networkError(error.localizedDescription))
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
/// Gets the authenticated Piped instance and auth token.
|
|
private func getPipedAuth(using appEnvironment: AppEnvironment) -> (Instance, String)? {
|
|
guard let account = appEnvironment.account(forSelectedContext: .piped),
|
|
let instance = appEnvironment.accountsManager.instance(for: account),
|
|
let token = appEnvironment.accountCredentialsManager(for: account)?.credential(for: account) else {
|
|
LoggingService.shared.debug("No Piped account resolved for the selected context", category: .general)
|
|
return nil
|
|
}
|
|
|
|
return (instance, token)
|
|
}
|
|
|
|
// MARK: - Invidious Feed
|
|
|
|
/// Refreshes feed from authenticated Invidious feed API.
|
|
private func refreshFromInvidiousFeed(
|
|
instance: Instance,
|
|
sid: String,
|
|
using appEnvironment: AppEnvironment,
|
|
generation: Int
|
|
) async {
|
|
isLoading = true
|
|
feedLoadState = .loading
|
|
loadingProgress = nil
|
|
|
|
do {
|
|
let response = try await appEnvironment.invidiousAPI.feed(
|
|
instance: instance,
|
|
sid: sid,
|
|
page: currentPage,
|
|
maxResults: 50
|
|
)
|
|
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
|
|
let feedVideos = response.videos
|
|
LoggingService.shared.info(
|
|
"Invidious feed: Received \(feedVideos.count) videos (page \(currentPage))",
|
|
category: .general
|
|
)
|
|
recordOutcome(nil, backend: .invidious, using: appEnvironment)
|
|
|
|
// For first page, replace all videos. For subsequent pages, this shouldn't be called.
|
|
update(videos: feedVideos, generation: generation)
|
|
appEnvironment.dataManager.updateLastVideoPublishedDates(from: feedVideos)
|
|
|
|
// Update pagination state
|
|
hasMorePages = response.hasMore
|
|
currentPage = 1
|
|
|
|
// Prefetch DeArrow branding for YouTube videos
|
|
prefetchDeArrow(for: feedVideos, using: appEnvironment)
|
|
|
|
feedLoadState = .ready
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to fetch Invidious feed: \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
recordOutcome(error, backend: .invidious, using: appEnvironment)
|
|
feedLoadState = .error(.networkError(error.localizedDescription))
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
/// Loads the next page of Invidious feed (for infinite scroll).
|
|
func loadMoreInvidiousFeed(using appEnvironment: AppEnvironment) async {
|
|
guard !isLoading else { return }
|
|
guard hasMorePages else { return }
|
|
guard appEnvironment.feedBackend(for: appEnvironment.dataContextValidator.effectiveContext) == .invidious
|
|
else { return }
|
|
|
|
guard let (instance, sid) = getInvidiousAuth(using: appEnvironment) else {
|
|
return
|
|
}
|
|
|
|
let generation = beginRequest()
|
|
isLoading = true
|
|
feedLoadState = .loadingMore
|
|
|
|
let nextPage = currentPage + 1
|
|
|
|
do {
|
|
let response = try await appEnvironment.invidiousAPI.feed(
|
|
instance: instance,
|
|
sid: sid,
|
|
page: nextPage,
|
|
maxResults: 50
|
|
)
|
|
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
|
|
let feedVideos = response.videos
|
|
LoggingService.shared.info(
|
|
"Invidious feed: Loaded page \(nextPage), received \(feedVideos.count) videos",
|
|
category: .general
|
|
)
|
|
|
|
// Append new videos to existing cache
|
|
appendVideos(feedVideos, generation: generation)
|
|
|
|
// Update pagination state
|
|
hasMorePages = response.hasMore
|
|
currentPage = nextPage
|
|
|
|
// Prefetch DeArrow branding for new videos
|
|
prefetchDeArrow(for: feedVideos, using: appEnvironment)
|
|
|
|
feedLoadState = .ready
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to load more Invidious feed: \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
// Don't show error state for pagination failures, just stop loading
|
|
feedLoadState = .ready
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Yattee Server Feed
|
|
|
|
/// Refreshes feed from Yattee Server's stateless POST feed API.
|
|
private func refreshFromStatelessServer(instance: Instance, using appEnvironment: AppEnvironment, generation: Int) async {
|
|
let subscriptions = appEnvironment.dataManager.subscriptions()
|
|
LoggingService.shared.debug("refreshFromStatelessServer: Found \(subscriptions.count) subscriptions", category: .general)
|
|
guard !subscriptions.isEmpty else {
|
|
videos = []
|
|
isLoading = false
|
|
hasLoadedOnce = true
|
|
feedLoadState = .ready
|
|
LoggingService.shared.debug("refreshFromStatelessServer: No subscriptions, clearing feed", category: .general)
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
feedLoadState = .loading
|
|
loadingProgress = nil
|
|
|
|
let channelRequests = subscriptions.map { subscription in
|
|
StatelessChannelRequest(
|
|
channelId: subscription.channelID,
|
|
site: subscription.site,
|
|
channelName: subscription.name,
|
|
channelUrl: subscription.channelURLString,
|
|
avatarUrl: subscription.avatarURLString
|
|
)
|
|
}
|
|
|
|
do {
|
|
let httpClient = HTTPClient()
|
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) {
|
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
|
}
|
|
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
|
LoggingService.shared.debug("refreshFromStatelessServer: Calling postFeed for \(channelRequests.count) channels", category: .general)
|
|
let response = try await yatteeServerAPI.postFeed(
|
|
channels: channelRequests,
|
|
limit: 100,
|
|
offset: 0,
|
|
instance: instance
|
|
)
|
|
|
|
guard isCurrentAccountsResult(generation) else { return }
|
|
|
|
let serverVideos = response.toVideos()
|
|
LoggingService.shared.info(
|
|
"refreshFromStatelessServer: Received \(serverVideos.count) videos from server, status: \(response.status), ready: \(response.isReady)",
|
|
category: .general
|
|
)
|
|
update(videos: serverVideos, generation: generation)
|
|
appEnvironment.dataManager.updateLastVideoPublishedDates(from: serverVideos)
|
|
|
|
// Prefetch DeArrow branding
|
|
prefetchDeArrow(for: serverVideos, using: appEnvironment)
|
|
|
|
if response.isReady {
|
|
feedLoadState = .ready
|
|
isLoading = false
|
|
} else {
|
|
// Show partial results and poll for completion
|
|
feedLoadState = .partiallyLoaded(
|
|
readyCount: response.readyCount ?? 0,
|
|
pendingCount: response.pendingCount ?? 0,
|
|
errorCount: response.errorCount ?? 0
|
|
)
|
|
// Reset foreground flag since this is a user-initiated action
|
|
isAppInForeground = true
|
|
pollingTask = Task {
|
|
await pollUntilReady(
|
|
channels: channelRequests,
|
|
instance: instance,
|
|
appEnvironment: appEnvironment,
|
|
generation: generation
|
|
)
|
|
}
|
|
await pollingTask?.value
|
|
pollingTask = nil
|
|
}
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Yattee Server feed failed: \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
feedLoadState = .error(.networkError(error.localizedDescription))
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
/// Polls server until all channels are cached, then refreshes.
|
|
/// Stops polling after max retries, consecutive errors, or when app is backgrounded.
|
|
private func pollUntilReady(
|
|
channels: [StatelessChannelRequest],
|
|
instance: Instance,
|
|
appEnvironment: AppEnvironment,
|
|
generation: Int
|
|
) async {
|
|
let statusChannels = channels.map {
|
|
StatelessChannelStatusRequest(channelId: $0.channelId, site: $0.site)
|
|
}
|
|
let httpClient = HTTPClient()
|
|
if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) {
|
|
await httpClient.setDefaultHeaders(["Authorization": authHeader])
|
|
}
|
|
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
|
|
|
let maxRetries = 5
|
|
var retryCount = 0
|
|
let maxConsecutiveErrors = 3
|
|
var consecutiveErrors = 0
|
|
|
|
while retryCount < maxRetries {
|
|
// Check for cancellation before sleep
|
|
guard !Task.isCancelled else {
|
|
LoggingService.shared.debug("Feed status polling cancelled", category: .general)
|
|
break
|
|
}
|
|
|
|
// Check if app is still in foreground
|
|
guard isAppInForeground else {
|
|
LoggingService.shared.debug("Feed status polling stopped - app backgrounded", category: .general)
|
|
break
|
|
}
|
|
|
|
try? await Task.sleep(for: .seconds(5))
|
|
|
|
// Check again after sleep
|
|
guard !Task.isCancelled, isAppInForeground else {
|
|
LoggingService.shared.debug("Feed status polling cancelled during sleep", category: .general)
|
|
break
|
|
}
|
|
|
|
retryCount += 1
|
|
|
|
do {
|
|
let status = try await yatteeServerAPI.postFeedStatus(
|
|
channels: statusChannels,
|
|
instance: instance
|
|
)
|
|
consecutiveErrors = 0 // Reset on success
|
|
|
|
if status.isReady {
|
|
// Fetch full feed now that all channels are cached
|
|
if let response = try? await yatteeServerAPI.postFeed(
|
|
channels: channels,
|
|
limit: 100,
|
|
offset: 0,
|
|
instance: instance
|
|
) {
|
|
let serverVideos = response.toVideos()
|
|
update(videos: serverVideos, generation: generation)
|
|
appEnvironment.dataManager.updateLastVideoPublishedDates(from: serverVideos)
|
|
prefetchDeArrow(for: serverVideos, using: appEnvironment)
|
|
}
|
|
guard generation == accountGeneration else { return }
|
|
feedLoadState = .ready
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
// Update progress UI
|
|
feedLoadState = .partiallyLoaded(
|
|
readyCount: status.readyCount,
|
|
pendingCount: status.pendingCount,
|
|
errorCount: status.errorCount
|
|
)
|
|
} catch {
|
|
consecutiveErrors += 1
|
|
LoggingService.shared.debug(
|
|
"Feed status poll error (\(consecutiveErrors)/\(maxConsecutiveErrors)): \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
|
|
if consecutiveErrors >= maxConsecutiveErrors {
|
|
LoggingService.shared.warning(
|
|
"Feed status polling stopped after \(maxConsecutiveErrors) consecutive errors",
|
|
category: .general
|
|
)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if retryCount >= maxRetries {
|
|
LoggingService.shared.warning(
|
|
"Feed status polling timed out after \(retryCount) attempts",
|
|
category: .general
|
|
)
|
|
}
|
|
|
|
// Show whatever we have — unless the login this poller was working for
|
|
// has been fenced out meanwhile: then the state belongs to whatever
|
|
// request the switch started, and a stale ".ready" here would end its
|
|
// spinner early or let a duplicate refresh through.
|
|
guard generation == accountGeneration else { return }
|
|
feedLoadState = .ready
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Cache Warming
|
|
|
|
/// Warms the cache if expired.
|
|
func warmIfNeeded(using appEnvironment: AppEnvironment) {
|
|
guard !isLoading else { return }
|
|
|
|
Task { @MainActor in
|
|
// Load disk cache first to get accurate lastUpdated timestamp
|
|
await loadFromDiskIfNeeded()
|
|
|
|
guard !isCacheValid(using: appEnvironment.settingsManager) else { return }
|
|
|
|
// Use the same routing logic as refresh()
|
|
await refresh(using: appEnvironment)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Prefetches DeArrow branding for YouTube videos.
|
|
private func prefetchDeArrow(for videos: [Video], using appEnvironment: AppEnvironment) {
|
|
let youtubeIDs = videos.compactMap { video -> String? in
|
|
if case .global = video.id.source { return video.id.videoID }
|
|
return nil
|
|
}
|
|
appEnvironment.deArrowBrandingProvider.prefetch(videoIDs: youtubeIDs)
|
|
}
|
|
}
|