mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Subscribing or unsubscribing, locally or via iCloud, only posted a notification, so the cached feed stayed valid and Yattee Server never received the new channel list until the cache expired. Invalidate the feed cache on every subscription add and removal, and skip the disk write when it is already stale.
122 lines
3.9 KiB
Swift
122 lines
3.9 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 {
|
|
// Already stale: skip the disk write (remote syncs invalidate once per record).
|
|
guard var data = cachedData, data.lastUpdated != .distantPast 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
|
|
}
|