From fd418335321cf02ec1b4b5d83d7ef912e9c56cf5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 12 Feb 2026 05:47:43 +0100 Subject: [PATCH] Persist author cache to disk for instant channel info across restarts Back the in-memory authorCache with a JSON file in ~/Library/Caches/AuthorCache/. Disk is lazy-loaded on first lookup and saved asynchronously on each cache update. Capped at 500 entries to prevent unbounded growth. - Cache author data from video detail API responses (PlayerService, VideoInfoView) - Replace ChannelView's private CachedChannelHeader with shared CachedChannelData - Enrich author with cached avatar/subscriber count in VideoChannelRow, TVDetailsPanel, VideoInfoView --- Yattee/Models/CachedChannelData.swift | 152 ++++++++++++++++++ Yattee/Services/Player/PlayerService.swift | 1 + Yattee/Views/Channel/ChannelView.swift | 31 +--- .../Components/VideoInfoComponents.swift | 14 +- Yattee/Views/Player/tvOS/TVDetailsPanel.swift | 13 +- Yattee/Views/Video/VideoInfoView.swift | 11 +- 6 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 Yattee/Models/CachedChannelData.swift diff --git a/Yattee/Models/CachedChannelData.swift b/Yattee/Models/CachedChannelData.swift new file mode 100644 index 00000000..a39262cf --- /dev/null +++ b/Yattee/Models/CachedChannelData.swift @@ -0,0 +1,152 @@ +// +// CachedChannelData.swift +// Yattee +// +// Cached channel data from Subscription or RecentChannel for instant display. +// + +import Foundation + +/// Cached channel data loaded from local SwiftData stores (Subscription or RecentChannel). +/// Used to show channel info immediately while API responses are loading. +struct CachedChannelData: Codable { + let name: String + let thumbnailURL: URL? + let bannerURL: URL? + let subscriberCount: Int? + + /// In-memory cache of author data from video detail API responses. + @MainActor + private static var authorCache: [String: CachedChannelData] = [:] + + /// Whether the disk cache has been loaded into memory. + @MainActor + private static var diskLoaded = false + + /// Maximum number of cached author entries. + private static let maxCacheSize = 500 + + private static var cacheFileURL: URL { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return caches.appendingPathComponent("AuthorCache", isDirectory: true) + .appendingPathComponent("authors.json") + } + + init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?) { + self.name = name + self.thumbnailURL = thumbnailURL + self.bannerURL = bannerURL + self.subscriberCount = subscriberCount + } + + @MainActor + static func cacheAuthor(_ author: Author) { + guard author.thumbnailURL != nil || author.subscriberCount != nil else { return } + authorCache[author.id] = CachedChannelData( + name: author.name, + thumbnailURL: author.thumbnailURL, + bannerURL: nil, + subscriberCount: author.subscriberCount + ) + + // Evict oldest entries if over limit + if authorCache.count > maxCacheSize { + let excess = authorCache.count - maxCacheSize + let keysToRemove = Array(authorCache.keys.prefix(excess)) + for key in keysToRemove { + authorCache.removeValue(forKey: key) + } + } + + saveToDisk() + } + + // MARK: - Disk Persistence + + @MainActor + private static func loadFromDiskIfNeeded() { + guard !diskLoaded else { return } + diskLoaded = true + + let url = cacheFileURL + guard FileManager.default.fileExists(atPath: url.path) else { return } + + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode([String: CachedChannelData].self, from: data) + // Only fill entries not already present in memory + for (key, value) in decoded where authorCache[key] == nil { + authorCache[key] = value + } + } catch { + try? FileManager.default.removeItem(at: url) + } + } + + @MainActor + private static func saveToDisk() { + let snapshot = authorCache + Task.detached(priority: .utility) { + let url = cacheFileURL + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(snapshot) { + try? data.write(to: url, options: .atomic) + } + } + } + + init(from subscription: Subscription) { + name = subscription.name + thumbnailURL = subscription.avatarURL + bannerURL = subscription.bannerURL + subscriberCount = subscription.subscriberCount + } + + init(from recentChannel: RecentChannel) { + name = recentChannel.name + thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) } + bannerURL = nil // RecentChannel doesn't store banner + subscriberCount = recentChannel.subscriberCount + } + + /// Load cached data for a channel ID from Subscription or RecentChannel. + @MainActor + static func load(for channelID: String, using dataManager: DataManager) -> CachedChannelData? { + loadFromDiskIfNeeded() + if let subscription = dataManager.subscription(for: channelID) { + return CachedChannelData(from: subscription) + } + if let recentChannel = dataManager.recentChannelEntry(forChannelID: channelID) { + return CachedChannelData(from: recentChannel) + } + // Finally, check in-memory cache from video detail API responses + return authorCache[channelID] + } +} + +// MARK: - Author Enrichment + +extension Author { + /// Returns a new Author with missing fields filled in from cached channel data. + func enriched(from cached: CachedChannelData) -> Author { + Author( + id: id, + name: name, + thumbnailURL: thumbnailURL ?? cached.thumbnailURL, + subscriberCount: subscriberCount ?? cached.subscriberCount, + instance: instance, + url: url, + hasRealChannelInfo: hasRealChannelInfo + ) + } + + /// Convenience: looks up cached data for this author's ID and enriches if found. + @MainActor + func enriched(using dataManager: DataManager) -> Author { + guard let cached = CachedChannelData.load(for: id, using: dataManager) else { + return self + } + return enriched(from: cached) + } +} diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 1099d220..d526f32c 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -303,6 +303,7 @@ final class PlayerService { // Update state with full video details and selected stream state.setCurrentVideo(fullVideo, stream: selectedStream, audioStream: selectedAudioStream) + CachedChannelData.cacheAuthor(fullVideo.author) state.videoDetailsState = .loaded lockDurationIfNeeded(for: fullVideo, stream: selectedStream) diff --git a/Yattee/Views/Channel/ChannelView.swift b/Yattee/Views/Channel/ChannelView.swift index 71dd7d0d..38063129 100644 --- a/Yattee/Views/Channel/ChannelView.swift +++ b/Yattee/Views/Channel/ChannelView.swift @@ -8,25 +8,6 @@ import SwiftUI import NukeUI -/// Cached channel data for showing header immediately while loading. -private struct CachedChannelHeader { - let name: String - let thumbnailURL: URL? - let bannerURL: URL? - - init(from subscription: Subscription) { - name = subscription.name - thumbnailURL = subscription.avatarURL - bannerURL = subscription.bannerURL - } - - init(from recentChannel: RecentChannel) { - name = recentChannel.name - thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) } - bannerURL = nil // RecentChannel doesn't store banner - } -} - struct ChannelView: View { @Environment(\.appEnvironment) private var appEnvironment @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -52,7 +33,7 @@ struct ChannelView: View { @State private var showingUnsubscribeConfirmation = false @State private var scrollOffset: CGFloat = 0 @State private var scrollToTop: Bool = false - @State private var cachedHeader: CachedChannelHeader? + @State private var cachedHeader: CachedChannelData? // View options (persisted) @AppStorage("channel.layout") private var layout: VideoListLayout = .list @@ -393,7 +374,7 @@ struct ChannelView: View { /// Shows cached header with a spinner below while loading full channel data. @ViewBuilder - private func loadingContent(_ cached: CachedChannelHeader) -> some View { + private func loadingContent(_ cached: CachedChannelData) -> some View { GeometryReader { geometry in ScrollView { LazyVStack(spacing: 0) { @@ -1526,11 +1507,7 @@ struct ChannelView: View { isSubscribed = subscription != nil // Load cached header data for immediate display - if let subscription { - cachedHeader = CachedChannelHeader(from: subscription) - } else if let recentChannel = appEnvironment.dataManager.recentChannelEntry(forChannelID: channelID) { - cachedHeader = CachedChannelHeader(from: recentChannel) - } + cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager) // Fetch channel and videos independently to handle partial failures gracefully async let channelTask: Result = await { @@ -1576,7 +1553,7 @@ struct ChannelView: View { id: ChannelID(source: source, channelID: channelID), name: cached.name, description: nil, - subscriberCount: nil, + subscriberCount: cached.subscriberCount, thumbnailURL: cached.thumbnailURL, bannerURL: cached.bannerURL ) diff --git a/Yattee/Views/Components/VideoInfoComponents.swift b/Yattee/Views/Components/VideoInfoComponents.swift index 02c68844..490c4c49 100644 --- a/Yattee/Views/Components/VideoInfoComponents.swift +++ b/Yattee/Views/Components/VideoInfoComponents.swift @@ -92,6 +92,8 @@ struct VideoStatsRow: View { /// Displays channel info with avatar, name, subscriber count, and context menu. struct VideoChannelRow: View { + @Environment(\.appEnvironment) private var appEnvironment + let author: Author let source: ContentSource let yatteeServerURL: URL? @@ -101,6 +103,12 @@ struct VideoChannelRow: View { var showSubscriberCount: Bool = true var isLoadingDetails: Bool = false + /// Author enriched with cached channel data (avatar, subscriber count) from local stores. + private var enrichedAuthor: Author { + guard let dataManager = appEnvironment?.dataManager else { return author } + return author.enriched(using: dataManager) + } + var body: some View { HStack(spacing: 12) { if let onChannelTap { @@ -129,21 +137,21 @@ struct VideoChannelRow: View { private var channelContent: some View { HStack(spacing: 10) { ChannelAvatarView( - author: author, + author: enrichedAuthor, size: 40, yatteeServerURL: yatteeServerURL, source: source ) VStack(alignment: .leading, spacing: 2) { - Text(author.name) + Text(enrichedAuthor.name) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) if showSubscriberCount { Group { - if let subscribers = author.formattedSubscriberCount { + if let subscribers = enrichedAuthor.formattedSubscriberCount { Text(subscribers) } else if isLoadingDetails && video.supportsAPIStats { Text("1.2M subscribers") diff --git a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift index 8f3ebf36..dedad25b 100644 --- a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift +++ b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift @@ -152,10 +152,17 @@ struct TVDetailsPanel: View { // MARK: - Channel Row + /// Author enriched with cached channel data (avatar, subscriber count) from local stores. + private var enrichedAuthor: Author? { + guard let video else { return nil } + guard let dataManager = appEnvironment?.dataManager else { return video.author } + return video.author.enriched(using: dataManager) + } + private var channelRow: some View { HStack(spacing: 16) { // Channel avatar - if let thumbnailURL = video?.author.thumbnailURL { + if let thumbnailURL = enrichedAuthor?.thumbnailURL { AsyncImage(url: thumbnailURL) { image in image .resizable() @@ -179,12 +186,12 @@ struct TVDetailsPanel: View { VStack(alignment: .leading, spacing: 4) { // Channel name - Text(video?.author.name ?? "") + Text(enrichedAuthor?.name ?? "") .font(.headline) .foregroundStyle(.white) // Subscriber count - if let subscriberCount = video?.author.subscriberCount { + if let subscriberCount = enrichedAuthor?.subscriberCount { Text("channel.subscriberCount \(CountFormatter.compact(subscriberCount))") .font(.subheadline) .foregroundStyle(.white.opacity(0.6)) diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 2be5732c..45e06924 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -765,21 +765,22 @@ struct VideoInfoView: View { /// Channel row content used in both tappable and non-tappable variants private func channelRowContent(for video: Video) -> some View { - HStack(spacing: 10) { + let enrichedAuthor = appEnvironment.map { video.author.enriched(using: $0.dataManager) } ?? video.author + return HStack(spacing: 10) { ChannelAvatarView( - author: video.author, + author: enrichedAuthor, size: 40, yatteeServerURL: yatteeServerURL, source: video.authorSource ) VStack(alignment: .leading, spacing: 2) { - Text(video.author.name) + Text(enrichedAuthor.name) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) - if let subscribers = video.author.formattedSubscriberCount { + if let subscribers = enrichedAuthor.formattedSubscriberCount { Text(subscribers) .font(.caption) .foregroundStyle(.secondary) @@ -1784,6 +1785,7 @@ struct VideoInfoView: View { // Use extractURL method - just use the video part let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance) loadedVideoDetails[videoID] = fullVideo + CachedChannelData.cacheAuthor(fullVideo.author) } catch { // Fail silently - use partial video data we have } @@ -1807,6 +1809,7 @@ struct VideoInfoView: View { instance: instance ) loadedVideoDetails[videoID] = fullVideo + CachedChannelData.cacheAuthor(fullVideo.author) } catch { // Fail silently - just use the partial video data we have }