mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Five findings against the accounts layer, all real. A fresh login created a random-UUID account and then flipped usesAccountLogin, which posts instancesDidChange and re-ran legacy reconciliation immediately. That guard only looked for an account whose ID equalled the instance ID, so it synthesized a second, credential-less account alongside the real one. Signing in now marks the instance migrated, and reconciliation treats any account on an instance as evidence its login is already represented. save() set hasStoredContext, so the save inside reconcileLegacyAccounts marked the still-default .local context as explicitly chosen and the legacy-context migration that ran immediately after did nothing. Upgrading users with a selected server account silently lost it. Only setContext, applySyncedContext and the migration itself record a choice now. An Apple Account change re-resolved the credential scope but never told AccountsManager to reload its identity-namespaced storage - the only reload was a one-time launch task - so the previous identity's accounts, context and outbox stayed live under the new one, as did cached per-account subscription providers. The scope now posts when its resolved value changes; the accounts store reloads and the provider cache is dropped. Fourteen call sites still read the pre-revamp instance-keyed Keychain item, which a new login no longer writes: reopening the source screen showed the account as signed out, and feed, browse, home and import operations treated it as unauthenticated. They now resolve the credential through the account layer, preferring the selected context's account and otherwise any signed-in account on that instance. Signing in while the identity scope was still unresolved created the account in a temporary namespace that reloadForCurrentIdentity later discards, while credential storage silently refused the write and the UI reported success. Login is gated on a resolved scope and verifies the credential actually stored before reporting success, rolling back a newly-created account if it did not.
683 lines
25 KiB
Swift
683 lines
25 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)
|
|
|
|
/// Specific error types for feed loading.
|
|
enum FeedLoadError: Equatable {
|
|
case yatteeServerRequired
|
|
case notAuthenticated
|
|
case networkError(String)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
if let cacheData = await FeedCache.shared.load() {
|
|
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
|
|
Task {
|
|
await FeedCache.shared.clear()
|
|
}
|
|
}
|
|
|
|
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.
|
|
func update(videos: [Video]) {
|
|
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]) {
|
|
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()
|
|
}
|
|
|
|
// 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)
|
|
clear()
|
|
feedLoadState = .idle
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
let accountType = appEnvironment.settingsManager.subscriptionAccount.type
|
|
LoggingService.shared.debug("Starting feed refresh for account type: \(accountType)", category: .general)
|
|
|
|
// 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.
|
|
await Task.detached { @MainActor in
|
|
switch accountType {
|
|
case .local:
|
|
await self.refreshLocalAccount(using: appEnvironment)
|
|
case .invidious:
|
|
await self.refreshInvidiousAccount(using: appEnvironment)
|
|
case .piped:
|
|
await self.refreshPipedAccount(using: appEnvironment)
|
|
}
|
|
}.value
|
|
}
|
|
|
|
/// Refreshes feed for local account using Yattee Server.
|
|
private func refreshLocalAccount(using appEnvironment: AppEnvironment) 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)
|
|
}
|
|
|
|
/// Refreshes feed for Invidious account using authenticated feed endpoint.
|
|
private func refreshInvidiousAccount(using appEnvironment: AppEnvironment) 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)
|
|
}
|
|
|
|
/// Gets the authenticated Invidious instance and session ID.
|
|
private func getInvidiousAuth(using appEnvironment: AppEnvironment) -> (Instance, String)? {
|
|
// Get the instance ID from subscription account settings
|
|
let account = appEnvironment.settingsManager.subscriptionAccount
|
|
|
|
let instance: Instance?
|
|
if let instanceID = account.instanceID {
|
|
// Use the specific instance from account settings
|
|
instance = appEnvironment.instancesManager.instances.first { $0.id == instanceID }
|
|
} else {
|
|
// Fall back to first enabled Invidious instance
|
|
instance = appEnvironment.instancesManager.instances.first {
|
|
$0.type == .invidious && $0.isEnabled
|
|
}
|
|
}
|
|
|
|
guard let instance else {
|
|
LoggingService.shared.debug("No Invidious instance found for subscription account", category: .general)
|
|
return nil
|
|
}
|
|
|
|
guard let sid = appEnvironment.credential(forInstance: instance) else {
|
|
LoggingService.shared.debug("No session ID found for Invidious instance: \(instance.id)", 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) 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 pipedAPI = PipedAPI(httpClient: appEnvironment.httpClient)
|
|
let feedVideos = try await pipedAPI.feed(instance: instance, authToken: authToken)
|
|
|
|
LoggingService.shared.info("Piped feed: Received \(feedVideos.count) videos", category: .general)
|
|
|
|
update(videos: feedVideos)
|
|
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)
|
|
feedLoadState = .error(.networkError(error.localizedDescription))
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
/// Gets the authenticated Piped instance and auth token.
|
|
private func getPipedAuth(using appEnvironment: AppEnvironment) -> (Instance, String)? {
|
|
let account = appEnvironment.settingsManager.subscriptionAccount
|
|
|
|
guard let instanceID = account.instanceID,
|
|
let instance = appEnvironment.instancesManager.instances.first(where: { $0.id == instanceID }),
|
|
let authToken = appEnvironment.credential(forInstance: instance) else {
|
|
LoggingService.shared.debug("No authenticated Piped instance found for subscription account", category: .general)
|
|
return nil
|
|
}
|
|
|
|
return (instance, authToken)
|
|
}
|
|
|
|
// MARK: - Invidious Feed
|
|
|
|
/// Refreshes feed from authenticated Invidious feed API.
|
|
private func refreshFromInvidiousFeed(
|
|
instance: Instance,
|
|
sid: String,
|
|
using appEnvironment: AppEnvironment
|
|
) async {
|
|
isLoading = true
|
|
feedLoadState = .loading
|
|
loadingProgress = nil
|
|
|
|
do {
|
|
let response = try await appEnvironment.invidiousAPI.feed(
|
|
instance: instance,
|
|
sid: sid,
|
|
page: currentPage,
|
|
maxResults: 50
|
|
)
|
|
|
|
let feedVideos = response.videos
|
|
LoggingService.shared.info(
|
|
"Invidious feed: Received \(feedVideos.count) videos (page \(currentPage))",
|
|
category: .general
|
|
)
|
|
|
|
// For first page, replace all videos. For subsequent pages, this shouldn't be called.
|
|
update(videos: feedVideos)
|
|
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
|
|
)
|
|
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.settingsManager.subscriptionAccount.type == .invidious else { return }
|
|
|
|
guard let (instance, sid) = getInvidiousAuth(using: appEnvironment) else {
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
feedLoadState = .loadingMore
|
|
|
|
let nextPage = currentPage + 1
|
|
|
|
do {
|
|
let response = try await appEnvironment.invidiousAPI.feed(
|
|
instance: instance,
|
|
sid: sid,
|
|
page: nextPage,
|
|
maxResults: 50
|
|
)
|
|
|
|
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)
|
|
|
|
// 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) 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
|
|
)
|
|
|
|
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)
|
|
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
|
|
)
|
|
}
|
|
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
|
|
) 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)
|
|
appEnvironment.dataManager.updateLastVideoPublishedDates(from: serverVideos)
|
|
prefetchDeArrow(for: serverVideos, using: appEnvironment)
|
|
}
|
|
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
|
|
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)
|
|
}
|
|
}
|