mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
678 lines
25 KiB
Swift
678 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()
|
|
}
|
|
|
|
/// 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.invidiousCredentialsManager.sid(for: 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.pipedCredentialsManager.credential(for: 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 yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient())
|
|
let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: instance)
|
|
await yatteeServerAPI.setAuthHeader(authHeader)
|
|
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 yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient())
|
|
let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: instance)
|
|
await yatteeServerAPI.setAuthHeader(authHeader)
|
|
|
|
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)
|
|
}
|
|
}
|