Files
yattee/Yattee/Services/HomeInstanceCache.swift
Arkadiusz Fal 7c966b18a9 Keep deleted credentials and cached feeds from outliving their account
A deletion can reach a device before the account it deletes does — the
tombstone and the account are separate records, and iCloud Keychain runs
on its own timeline — and the credential cleanup was skipped whenever
the account was unknown here. The scoped key needs only the account ID,
so both credential managers are now asked to drop it; the unscoped
legacy item still needs the instance and is left alone.

Cached data is invalidated on the data context rather than on the legacy
selection it maps to. Two logins on the same instance collapse onto the
same legacy value, so switching between them cleared nothing and the
subscriptions feed kept showing the previous account's videos. Home
feeds are keyed by instance alone and had the same hole across accounts
and across Apple accounts; they are dropped with it.

The third source-deletion path now cascades to accounts like the other
two, and the Invidious private-playlist lookup resolves its session
through the account — a login created after the revamp writes only an
account-scoped credential, so the instance-keyed lookup missed it and
fell through to the public endpoint.
2026-08-25 23:59:17 +02:00

235 lines
8.5 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
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
if let cacheData = await HomeInstanceDiskCache.shared.load() {
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)
}
}
/// 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)
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)
}
// 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) {
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() {
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)
}
}