Files
yattee/Yattee/Core/FeedCache.swift
2026-02-08 18:33:56 +01:00

121 lines
3.8 KiB
Swift

//
// FeedCache.swift
// Yattee
//
// Persistent on-device feed cache for subscription videos.
// Stores feed data on disk to enable fast loading on app launch.
//
import Foundation
/// Persistent feed cache that stores subscription videos on disk.
/// This is a local-only cache (not synced to iCloud) for fast feed loading.
actor FeedCache {
static let shared = FeedCache()
private let fileManager = FileManager.default
private let cacheDirectory: URL
private let cacheFileName = "subscription_feed.json"
/// In-memory cache of the feed data.
private var cachedData: FeedCacheData?
private init() {
// Use Caches directory - not backed up, not synced
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDirectory = caches.appendingPathComponent("FeedCache", isDirectory: true)
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
private var cacheFileURL: URL {
cacheDirectory.appendingPathComponent(cacheFileName)
}
// MARK: - Public API
/// Loads the cached feed from disk.
/// Returns nil if no cache exists or if it's corrupted.
func load() async -> FeedCacheData? {
// Return in-memory cache if available
if let cachedData {
return cachedData
}
// Try to load from disk
guard fileManager.fileExists(atPath: cacheFileURL.path) else {
return nil
}
do {
let data = try Data(contentsOf: cacheFileURL)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let cacheData = try decoder.decode(FeedCacheData.self, from: data)
// Store in memory
cachedData = cacheData
return cacheData
} catch {
// Cache is corrupted, remove it
try? fileManager.removeItem(at: cacheFileURL)
return nil
}
}
/// Saves the feed to disk.
func save(videos: [Video], lastUpdated: Date) async {
let cacheData = FeedCacheData(videos: videos, lastUpdated: lastUpdated)
// Update in-memory cache
cachedData = cacheData
// Write to disk
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(cacheData)
let sizeMB = Double(data.count) / (1024 * 1024)
try data.write(to: cacheFileURL, options: .atomic)
await MainActor.run {
LoggingService.shared.debug(
"FeedCache.save: Wrote \(videos.count) videos (\(String(format: "%.2f", sizeMB)) MB) to disk, lastUpdated: \(lastUpdated)",
category: .general
)
}
} catch {
await MainActor.run {
LoggingService.shared.error(
"FeedCache.save: Failed to write to disk",
category: .general,
details: error.localizedDescription
)
}
}
}
/// Clears the feed cache from both memory and disk.
func clear() async {
cachedData = nil
try? fileManager.removeItem(at: cacheFileURL)
}
/// Invalidates the cache by clearing the lastUpdated timestamp.
/// The cached videos remain available but will be considered stale.
func invalidate() async {
guard var data = cachedData else { return }
data.lastUpdated = .distantPast
cachedData = data
await save(videos: data.videos, lastUpdated: .distantPast)
}
}
// MARK: - Cache Data Model
/// Data structure for the feed cache.
struct FeedCacheData: Codable {
var videos: [Video]
var lastUpdated: Date
}