mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
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
This commit is contained in:
152
Yattee/Models/CachedChannelData.swift
Normal file
152
Yattee/Models/CachedChannelData.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -303,6 +303,7 @@ final class PlayerService {
|
|||||||
|
|
||||||
// Update state with full video details and selected stream
|
// Update state with full video details and selected stream
|
||||||
state.setCurrentVideo(fullVideo, stream: selectedStream, audioStream: selectedAudioStream)
|
state.setCurrentVideo(fullVideo, stream: selectedStream, audioStream: selectedAudioStream)
|
||||||
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
||||||
state.videoDetailsState = .loaded
|
state.videoDetailsState = .loaded
|
||||||
lockDurationIfNeeded(for: fullVideo, stream: selectedStream)
|
lockDurationIfNeeded(for: fullVideo, stream: selectedStream)
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NukeUI
|
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 {
|
struct ChannelView: View {
|
||||||
@Environment(\.appEnvironment) private var appEnvironment
|
@Environment(\.appEnvironment) private var appEnvironment
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@@ -52,7 +33,7 @@ struct ChannelView: View {
|
|||||||
@State private var showingUnsubscribeConfirmation = false
|
@State private var showingUnsubscribeConfirmation = false
|
||||||
@State private var scrollOffset: CGFloat = 0
|
@State private var scrollOffset: CGFloat = 0
|
||||||
@State private var scrollToTop: Bool = false
|
@State private var scrollToTop: Bool = false
|
||||||
@State private var cachedHeader: CachedChannelHeader?
|
@State private var cachedHeader: CachedChannelData?
|
||||||
|
|
||||||
// View options (persisted)
|
// View options (persisted)
|
||||||
@AppStorage("channel.layout") private var layout: VideoListLayout = .list
|
@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.
|
/// Shows cached header with a spinner below while loading full channel data.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func loadingContent(_ cached: CachedChannelHeader) -> some View {
|
private func loadingContent(_ cached: CachedChannelData) -> some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
@@ -1526,11 +1507,7 @@ struct ChannelView: View {
|
|||||||
isSubscribed = subscription != nil
|
isSubscribed = subscription != nil
|
||||||
|
|
||||||
// Load cached header data for immediate display
|
// Load cached header data for immediate display
|
||||||
if let subscription {
|
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
|
||||||
cachedHeader = CachedChannelHeader(from: subscription)
|
|
||||||
} else if let recentChannel = appEnvironment.dataManager.recentChannelEntry(forChannelID: channelID) {
|
|
||||||
cachedHeader = CachedChannelHeader(from: recentChannel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch channel and videos independently to handle partial failures gracefully
|
// Fetch channel and videos independently to handle partial failures gracefully
|
||||||
async let channelTask: Result<Channel, Error> = await {
|
async let channelTask: Result<Channel, Error> = await {
|
||||||
@@ -1576,7 +1553,7 @@ struct ChannelView: View {
|
|||||||
id: ChannelID(source: source, channelID: channelID),
|
id: ChannelID(source: source, channelID: channelID),
|
||||||
name: cached.name,
|
name: cached.name,
|
||||||
description: nil,
|
description: nil,
|
||||||
subscriberCount: nil,
|
subscriberCount: cached.subscriberCount,
|
||||||
thumbnailURL: cached.thumbnailURL,
|
thumbnailURL: cached.thumbnailURL,
|
||||||
bannerURL: cached.bannerURL
|
bannerURL: cached.bannerURL
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ struct VideoStatsRow: View {
|
|||||||
|
|
||||||
/// Displays channel info with avatar, name, subscriber count, and context menu.
|
/// Displays channel info with avatar, name, subscriber count, and context menu.
|
||||||
struct VideoChannelRow: View {
|
struct VideoChannelRow: View {
|
||||||
|
@Environment(\.appEnvironment) private var appEnvironment
|
||||||
|
|
||||||
let author: Author
|
let author: Author
|
||||||
let source: ContentSource
|
let source: ContentSource
|
||||||
let yatteeServerURL: URL?
|
let yatteeServerURL: URL?
|
||||||
@@ -101,6 +103,12 @@ struct VideoChannelRow: View {
|
|||||||
var showSubscriberCount: Bool = true
|
var showSubscriberCount: Bool = true
|
||||||
var isLoadingDetails: Bool = false
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if let onChannelTap {
|
if let onChannelTap {
|
||||||
@@ -129,21 +137,21 @@ struct VideoChannelRow: View {
|
|||||||
private var channelContent: some View {
|
private var channelContent: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ChannelAvatarView(
|
ChannelAvatarView(
|
||||||
author: author,
|
author: enrichedAuthor,
|
||||||
size: 40,
|
size: 40,
|
||||||
yatteeServerURL: yatteeServerURL,
|
yatteeServerURL: yatteeServerURL,
|
||||||
source: source
|
source: source
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(author.name)
|
Text(enrichedAuthor.name)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if showSubscriberCount {
|
if showSubscriberCount {
|
||||||
Group {
|
Group {
|
||||||
if let subscribers = author.formattedSubscriberCount {
|
if let subscribers = enrichedAuthor.formattedSubscriberCount {
|
||||||
Text(subscribers)
|
Text(subscribers)
|
||||||
} else if isLoadingDetails && video.supportsAPIStats {
|
} else if isLoadingDetails && video.supportsAPIStats {
|
||||||
Text("1.2M subscribers")
|
Text("1.2M subscribers")
|
||||||
|
|||||||
@@ -152,10 +152,17 @@ struct TVDetailsPanel: View {
|
|||||||
|
|
||||||
// MARK: - Channel Row
|
// 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 {
|
private var channelRow: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Channel avatar
|
// Channel avatar
|
||||||
if let thumbnailURL = video?.author.thumbnailURL {
|
if let thumbnailURL = enrichedAuthor?.thumbnailURL {
|
||||||
AsyncImage(url: thumbnailURL) { image in
|
AsyncImage(url: thumbnailURL) { image in
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -179,12 +186,12 @@ struct TVDetailsPanel: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
// Channel name
|
// Channel name
|
||||||
Text(video?.author.name ?? "")
|
Text(enrichedAuthor?.name ?? "")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Subscriber count
|
// Subscriber count
|
||||||
if let subscriberCount = video?.author.subscriberCount {
|
if let subscriberCount = enrichedAuthor?.subscriberCount {
|
||||||
Text("channel.subscriberCount \(CountFormatter.compact(subscriberCount))")
|
Text("channel.subscriberCount \(CountFormatter.compact(subscriberCount))")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
|||||||
@@ -765,21 +765,22 @@ struct VideoInfoView: View {
|
|||||||
|
|
||||||
/// Channel row content used in both tappable and non-tappable variants
|
/// Channel row content used in both tappable and non-tappable variants
|
||||||
private func channelRowContent(for video: Video) -> some View {
|
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(
|
ChannelAvatarView(
|
||||||
author: video.author,
|
author: enrichedAuthor,
|
||||||
size: 40,
|
size: 40,
|
||||||
yatteeServerURL: yatteeServerURL,
|
yatteeServerURL: yatteeServerURL,
|
||||||
source: video.authorSource
|
source: video.authorSource
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(video.author.name)
|
Text(enrichedAuthor.name)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let subscribers = video.author.formattedSubscriberCount {
|
if let subscribers = enrichedAuthor.formattedSubscriberCount {
|
||||||
Text(subscribers)
|
Text(subscribers)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -1784,6 +1785,7 @@ struct VideoInfoView: View {
|
|||||||
// Use extractURL method - just use the video part
|
// Use extractURL method - just use the video part
|
||||||
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
|
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
|
||||||
loadedVideoDetails[videoID] = fullVideo
|
loadedVideoDetails[videoID] = fullVideo
|
||||||
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
||||||
} catch {
|
} catch {
|
||||||
// Fail silently - use partial video data we have
|
// Fail silently - use partial video data we have
|
||||||
}
|
}
|
||||||
@@ -1807,6 +1809,7 @@ struct VideoInfoView: View {
|
|||||||
instance: instance
|
instance: instance
|
||||||
)
|
)
|
||||||
loadedVideoDetails[videoID] = fullVideo
|
loadedVideoDetails[videoID] = fullVideo
|
||||||
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
||||||
} catch {
|
} catch {
|
||||||
// Fail silently - just use the partial video data we have
|
// Fail silently - just use the partial video data we have
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user