mirror of
https://github.com/yattee/yattee.git
synced 2026-10-26 06:32:53 +00:00
A home-feed disk load that was already suspended when the context or Apple account moved no longer installs what it brings back: the in-memory cache is empty by then, so the purge finds nothing to drop and the previous login's private feeds would quietly reappear. Instance-browser pagination carries the login it was requested for, so a page still in flight cannot be appended to the next account's feed, and the pagination state is reset with the content it belongs to. The stateless-server refresh stops before touching anything when its result is stale, rather than only rejecting the videos and going on to set loading state and start polling for subscriptions nobody is showing. Signing in re-resolves its target first. The round trip leaves room for another device to delete the account, and the credential would have been filed under one that no longer exists — reported as success. A provisional account is rolled back when its backend cannot be resolved at all, which is what a vanished instance looks like.
289 lines
11 KiB
Swift
289 lines
11 KiB
Swift
//
|
|
// HomeInstanceCache.swift
|
|
// Yattee
|
|
//
|
|
// Home instance content cache with disk persistence.
|
|
// Caches Popular and Trending content from instances for fast Home view loading.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Home instance content cache with disk persistence.
|
|
/// Loads cached content from disk on startup for instant display, then refreshes from network.
|
|
@MainActor
|
|
@Observable
|
|
final class HomeInstanceCache {
|
|
static let shared = HomeInstanceCache()
|
|
|
|
/// Cached videos by cache key (format: "instanceID_contentType").
|
|
private(set) var cache: [String: [Video]] = [:]
|
|
|
|
/// Last updated timestamps by cache key.
|
|
private(set) var lastUpdated: [String: Date] = [:]
|
|
|
|
/// Whether the disk cache has been loaded.
|
|
private var diskCacheLoaded = false
|
|
|
|
/// Set when feeds had to be dropped before the disk cache was read, so the
|
|
/// entries that load carry the same purge.
|
|
private var pendingFeedPurge = false
|
|
|
|
/// Which login the cached feeds belong to. Bumped whenever a feed is
|
|
/// dropped because the account behind it changed, so a request that was
|
|
/// already awaiting the server cannot write the previous user's videos back
|
|
/// in — clearing a cache does not cancel what is in flight.
|
|
private var accountGeneration = 0
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Cache Key Management
|
|
|
|
/// Generates cache key in format "instanceID_contentType".
|
|
private func cacheKey(instanceID: UUID, contentType: InstanceContentType) -> String {
|
|
"\(instanceID.uuidString)_\(contentType.rawValue)"
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Returns cached videos for a specific instance and content type.
|
|
func videos(for instanceID: UUID, contentType: InstanceContentType) -> [Video]? {
|
|
let key = cacheKey(instanceID: instanceID, contentType: contentType)
|
|
return cache[key]
|
|
}
|
|
|
|
/// Returns true if the cache is valid based on 30-minute validity duration.
|
|
func isCacheValid(for instanceID: UUID, contentType: InstanceContentType) -> Bool {
|
|
let key = cacheKey(instanceID: instanceID, contentType: contentType)
|
|
guard let timestamp = lastUpdated[key], !cache[key, default: []].isEmpty else {
|
|
return false
|
|
}
|
|
let validitySeconds = TimeInterval(30 * 60) // 30 minutes
|
|
return Date().timeIntervalSince(timestamp) < validitySeconds
|
|
}
|
|
|
|
/// Loads cached data from disk if not already loaded.
|
|
/// Call this early (e.g., on app launch) to populate the cache quickly.
|
|
func loadFromDiskIfNeeded() async {
|
|
guard !diskCacheLoaded else {
|
|
LoggingService.shared.debug("Home instance cache already loaded from disk, skipping", category: .general)
|
|
return
|
|
}
|
|
diskCacheLoaded = true
|
|
let generation = accountGeneration
|
|
|
|
if let cacheData = await HomeInstanceDiskCache.shared.load() {
|
|
// The context or the Apple account can move while this is
|
|
// suspended. The in-memory cache is empty by then, so the purge
|
|
// finds nothing to drop and this would quietly reinstate the
|
|
// previous login's private feeds.
|
|
guard generation == accountGeneration else {
|
|
LoggingService.shared.info(
|
|
"Discarding a disk cache that belongs to the previous login",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
cache = cacheData.videos
|
|
lastUpdated = cacheData.lastUpdated
|
|
let totalVideos = cache.values.map { $0.count }.reduce(0, +)
|
|
LoggingService.shared.debug(
|
|
"Loaded home instance cache from disk: \(cache.count) keys, \(totalVideos) total videos",
|
|
category: .general
|
|
)
|
|
} else {
|
|
LoggingService.shared.debug("No home instance cache found on disk", category: .general)
|
|
}
|
|
|
|
if pendingFeedPurge {
|
|
pendingFeedPurge = false
|
|
clearAllFeeds()
|
|
}
|
|
}
|
|
|
|
/// Refreshes content from network for a specific instance and content type.
|
|
/// On success, updates cache and saves to disk. On failure, preserves existing cache.
|
|
func refresh(
|
|
instanceID: UUID,
|
|
contentType: InstanceContentType,
|
|
using appEnvironment: AppEnvironment
|
|
) async {
|
|
// Find the instance
|
|
guard let instance = appEnvironment.instancesManager.instances.first(where: { $0.id == instanceID }),
|
|
instance.isEnabled else {
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.refresh: Instance \(instanceID) not found or disabled",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
let key = cacheKey(instanceID: instanceID, contentType: contentType)
|
|
let generation = accountGeneration
|
|
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.refresh: Fetching \(contentType.rawValue) from \(instance.displayName)",
|
|
category: .general
|
|
)
|
|
|
|
do {
|
|
let videos: [Video]
|
|
|
|
switch contentType {
|
|
case .popular:
|
|
videos = try await appEnvironment.contentService.popular(for: instance)
|
|
|
|
case .trending:
|
|
videos = try await appEnvironment.contentService.trending(for: instance)
|
|
|
|
case .feed:
|
|
// Only Invidious and Piped support feed
|
|
guard instance.supportsFeed else {
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.refresh: Feed not supported for \(instance.type)",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
// Check if user is logged in
|
|
guard let credential = appEnvironment.credential(forInstance: instance) else {
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.refresh: No credential for \(instance.displayName), skipping feed",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
videos = try await appEnvironment.contentService.feed(for: instance, credential: credential)
|
|
}
|
|
|
|
guard generation == accountGeneration else {
|
|
LoggingService.shared.info(
|
|
"Discarding a \(contentType.rawValue) result that belongs to the previous login",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
// Update cache
|
|
cache[key] = videos
|
|
lastUpdated[key] = Date()
|
|
|
|
LoggingService.shared.info(
|
|
"HomeInstanceCache.refresh: Cached \(videos.count) \(contentType.rawValue) videos from \(instance.displayName)",
|
|
category: .general
|
|
)
|
|
|
|
// Save to disk
|
|
await saveToDisk()
|
|
|
|
// Prefetch DeArrow branding for YouTube videos
|
|
let youtubeIDs = videos.compactMap { video -> String? in
|
|
if case .global = video.id.source { return video.id.videoID }
|
|
return nil
|
|
}
|
|
if !youtubeIDs.isEmpty {
|
|
appEnvironment.deArrowBrandingProvider.prefetch(videoIDs: youtubeIDs)
|
|
}
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"HomeInstanceCache.refresh: Failed to fetch \(contentType.rawValue) from \(instance.displayName)",
|
|
category: .general,
|
|
details: error.localizedDescription
|
|
)
|
|
// Preserve existing cache on error (show stale data)
|
|
}
|
|
}
|
|
|
|
/// Clears cached content for a specific instance and content type.
|
|
func clear(instanceID: UUID, contentType: InstanceContentType) {
|
|
accountGeneration += 1
|
|
|
|
let key = cacheKey(instanceID: instanceID, contentType: contentType)
|
|
cache.removeValue(forKey: key)
|
|
lastUpdated.removeValue(forKey: key)
|
|
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.clear: Cleared cache for \(key)",
|
|
category: .general
|
|
)
|
|
|
|
Task {
|
|
await saveToDisk()
|
|
}
|
|
}
|
|
|
|
/// Clears all cached content for a specific instance.
|
|
/// Drops every cached feed, on every instance.
|
|
///
|
|
/// Feeds are the one content type here that is private to a login, and the
|
|
/// cache key names only the instance — two accounts on the same instance,
|
|
/// or the same instance ID retained across a change of Apple account, would
|
|
/// otherwise be served each other's. Popular and trending are public and
|
|
/// stay.
|
|
func clearAllFeeds() {
|
|
accountGeneration += 1
|
|
|
|
// A purge can be asked for before the disk cache has been read — the
|
|
// identity check runs at startup, the load is async. Dropping only what
|
|
// is in memory would then be undone by the load, so the requirement is
|
|
// remembered and applied to whatever it brings in.
|
|
guard diskCacheLoaded else {
|
|
pendingFeedPurge = true
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.clearAllFeeds: Deferred until the disk cache is loaded",
|
|
category: .general
|
|
)
|
|
return
|
|
}
|
|
|
|
let keysToRemove = cache.keys.filter { $0.hasSuffix("_\(InstanceContentType.feed.rawValue)") }
|
|
guard !keysToRemove.isEmpty else { return }
|
|
|
|
for key in keysToRemove {
|
|
cache.removeValue(forKey: key)
|
|
lastUpdated.removeValue(forKey: key)
|
|
}
|
|
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.clearAllFeeds: Cleared \(keysToRemove.count) cached feeds",
|
|
category: .general
|
|
)
|
|
|
|
Task {
|
|
await saveToDisk()
|
|
}
|
|
}
|
|
|
|
func clearAllForInstance(_ instanceID: UUID) {
|
|
let keysToRemove = cache.keys.filter { $0.hasPrefix(instanceID.uuidString) }
|
|
for key in keysToRemove {
|
|
cache.removeValue(forKey: key)
|
|
lastUpdated.removeValue(forKey: key)
|
|
}
|
|
|
|
if !keysToRemove.isEmpty {
|
|
LoggingService.shared.debug(
|
|
"HomeInstanceCache.clearAllForInstance: Cleared \(keysToRemove.count) cache entries for instance \(instanceID)",
|
|
category: .general
|
|
)
|
|
|
|
Task {
|
|
await saveToDisk()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
/// Saves the current cache state to disk.
|
|
private func saveToDisk() async {
|
|
let cacheData = HomeInstanceDiskCache.CacheData(
|
|
videos: cache,
|
|
lastUpdated: lastUpdated
|
|
)
|
|
await HomeInstanceDiskCache.shared.save(cacheData)
|
|
}
|
|
}
|