mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
The two dictionaries are not guaranteed to agree, and walking only the video keys left a timestamp behind for any feed entry that had already lost its videos. Nothing reads it — validity requires the videos too — but it names a feed this login was never shown.
320 lines
12 KiB
Swift
320 lines
12 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 {
|
|
// Whatever asked for the purge has already done it — this is
|
|
// the only load there will be, so the request is spent too.
|
|
pendingFeedPurge = false
|
|
LoggingService.shared.info(
|
|
"Discarding a disk cache that belongs to the previous login",
|
|
category: .general
|
|
)
|
|
|
|
// Not installing it is only half of it. The purge that moved
|
|
// the generation ran while this was suspended, found the
|
|
// in-memory cache empty, and returned without rewriting the
|
|
// file — so the previous login's feeds are still on disk, and
|
|
// the identity check has already recorded the new owner. The
|
|
// next launch would find no mismatch and load them.
|
|
//
|
|
// Only the feeds are private, so only the feeds are dropped:
|
|
// saving the empty in-memory cache instead would throw away
|
|
// popular and trending, which are public and cost a cold start
|
|
// to fetch again. Anything the new login has already cached
|
|
// wins over what came off disk.
|
|
let isFeedKey = { (key: String) in
|
|
key.hasSuffix("_\(InstanceContentType.feed.rawValue)")
|
|
}
|
|
var videos = cacheData.videos
|
|
var timestamps = cacheData.lastUpdated
|
|
// Filtered independently: a timestamp can outlive its entry,
|
|
// and one left behind is a feed this login was never shown.
|
|
videos = videos.filter { !isFeedKey($0.key) }
|
|
timestamps = timestamps.filter { !isFeedKey($0.key) }
|
|
videos.merge(cache) { _, current in current }
|
|
timestamps.merge(lastUpdated) { _, current in current }
|
|
|
|
await HomeInstanceDiskCache.shared.save(
|
|
HomeInstanceDiskCache.CacheData(videos: videos, lastUpdated: timestamps)
|
|
)
|
|
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)
|
|
}
|
|
}
|