From 3b31f21c81b21c0ebf997c67091dc21fa78836aa Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 14 Dec 2022 00:07:32 +0100 Subject: [PATCH] Channels caching --- Fixtures/Comment+Fixtures.swift | 2 +- Fixtures/Video+Fixtures.swift | 1 + Model/Applications/InvidiousAPI.swift | 3 +- Model/Applications/PeerTubeAPI.swift | 3 +- Model/Applications/PipedAPI.swift | 5 ++- Model/Cache/BaseCacheModel.swift | 1 + Model/Cache/ChannelsCacheModel.swift | 46 +++++++++++++++++++++++ Model/Cache/SubscribedChannelsModel.swift | 14 ++++++- Model/Cache/VideosCacheModel.swift | 2 + Model/Channel.swift | 27 +++++++++++++ Model/RecentsModel.swift | 2 +- Model/Video.swift | 4 +- Shared/Channels/ChannelVideosView.swift | 13 ++++++- Shared/Home/FavoriteItemView.swift | 4 +- Shared/Subscriptions/ChannelsView.swift | 2 +- Shared/Videos/VideoBanner.swift | 7 +++- Shared/Videos/VideoCell.swift | 25 ++++++++++-- Yattee.xcodeproj/project.pbxproj | 8 ++++ 18 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 Model/Cache/ChannelsCacheModel.swift diff --git a/Fixtures/Comment+Fixtures.swift b/Fixtures/Comment+Fixtures.swift index 6b539d6c..d30ca95a 100644 --- a/Fixtures/Comment+Fixtures.swift +++ b/Fixtures/Comment+Fixtures.swift @@ -12,7 +12,7 @@ extension Comment { likeCount: 30032, text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ", repliesPage: "some url", - channel: .init(id: "", name: "") + channel: .init(app: .invidious, id: "", name: "") ) } } diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 318b5d8a..fcf1b5fb 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -20,6 +20,7 @@ extension Video { description: "Some relaxing live piano music", genre: "Music", channel: Channel( + app: .invidious, id: fixtureChannelID, name: "The Channel", bannerURL: URL(string: bannerURL)!, diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index f87bf233..65f18a82 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -519,6 +519,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } return Channel( + app: .invidious, id: json["authorId"].stringValue, name: json["author"].stringValue, bannerURL: json["authorBanners"].arrayValue.first?.dictionaryValue["url"]?.url, @@ -666,7 +667,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { likeCount: details["likeCount"]?.int ?? 0, text: details["content"]?.string ?? "", repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string, - channel: Channel(id: channelId, name: author) + channel: Channel(app: .invidious, id: channelId, name: author) ) } diff --git a/Model/Applications/PeerTubeAPI.swift b/Model/Applications/PeerTubeAPI.swift index 04302d8e..41766447 100644 --- a/Model/Applications/PeerTubeAPI.swift +++ b/Model/Applications/PeerTubeAPI.swift @@ -472,6 +472,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { func extractChannel(from json: JSON) -> Channel { Channel( + app: .peerTube, id: json["id"].stringValue, name: json["name"].stringValue ) @@ -572,7 +573,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { likeCount: details["likeCount"]?.int ?? 0, text: details["content"]?.string ?? "", repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string, - channel: Channel(id: channelId, name: author) + channel: Channel(app: .peerTube, id: channelId, name: author) ) } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 1e83633a..2fcacb75 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -410,6 +410,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } ?? [Channel.Tab]() return Channel( + app: .piped, id: id, name: name, bannerURL: attributes["bannerUrl"]?.url, @@ -488,7 +489,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { published: published ?? "", views: details["views"]?.int ?? 0, description: description, - channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount), + channel: Channel(app: .piped, id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount), thumbnails: thumbnails, live: live, likes: details["likes"]?.int, @@ -667,7 +668,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { likeCount: details["likeCount"]?.int ?? 0, text: extractCommentText(from: details["commentText"]?.stringValue), repliesPage: details["repliesPage"]?.string, - channel: Channel(id: channelId, name: author) + channel: Channel(app: .piped, id: channelId, name: author) ) } diff --git a/Model/Cache/BaseCacheModel.swift b/Model/Cache/BaseCacheModel.swift index ac75875d..9b021fce 100644 --- a/Model/Cache/BaseCacheModel.swift +++ b/Model/Cache/BaseCacheModel.swift @@ -14,6 +14,7 @@ struct BaseCacheModel { [ FeedCacheModel.shared, VideosCacheModel.shared, + ChannelsCacheModel.shared, PlaylistsCacheModel.shared, ChannelPlaylistsCacheModel.shared, SubscribedChannelsModel.shared diff --git a/Model/Cache/ChannelsCacheModel.swift b/Model/Cache/ChannelsCacheModel.swift new file mode 100644 index 00000000..49e2f3e7 --- /dev/null +++ b/Model/Cache/ChannelsCacheModel.swift @@ -0,0 +1,46 @@ +import Cache +import Foundation +import Logging +import SwiftyJSON + +struct ChannelsCacheModel: CacheModel { + static let shared = ChannelsCacheModel() + let logger = Logger(label: "stream.yattee.cache.channels") + + static let diskConfig = DiskConfig(name: "channels") + static let memoryConfig = MemoryConfig() + + let storage = try? Storage( + diskConfig: Self.diskConfig, + memoryConfig: Self.memoryConfig, + transformer: BaseCacheModel.jsonTransformer + ) + + func store(_ channel: Channel) { + guard channel.hasExtendedDetails else { + logger.warning("not caching \(channel.cacheKey)") + return + } + + logger.info("caching \(channel.cacheKey)") + try? storage?.setObject(channel.json, forKey: channel.cacheKey) + } + + func storeIfMissing(_ channel: Channel) { + guard let storage, !storage.objectExists(forKey: channel.cacheKey) else { + return + } + + store(channel) + } + + func retrieve(_ cacheKey: String) -> Channel? { + logger.info("retrieving cache for \(cacheKey)") + + if let json = try? storage?.object(forKey: cacheKey) { + return Channel.from(json) + } + + return nil + } +} diff --git a/Model/Cache/SubscribedChannelsModel.swift b/Model/Cache/SubscribedChannelsModel.swift index e13b8cf6..4685aaad 100644 --- a/Model/Cache/SubscribedChannelsModel.swift +++ b/Model/Cache/SubscribedChannelsModel.swift @@ -69,6 +69,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel { .onSuccess { resource in if let channels: [Channel] = resource.typedContent() { self.channels = channels + channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) } self.storeChannels(account: account, channels: channels) FeedModel.shared.calculateUnwatchedFeed() onSuccess() @@ -93,6 +94,8 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel { let date = iso8601DateFormatter.string(from: Date()) logger.info("caching channels \(channelsDateCacheKey(account)) -- \(date)") + channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) } + let dateObject: JSON = ["date": date] let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)] @@ -106,7 +109,16 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel { if let json = try? storage?.object(forKey: channelsCacheKey(account)), let channels = json.dictionaryValue["channels"] { - return channels.arrayValue.map { Channel.from($0) } + return channels.arrayValue.map { json in + let channel = Channel.from(json) + if !channel.hasExtendedDetails, + let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey) + { + return cache + } + + return channel + } } return [] diff --git a/Model/Cache/VideosCacheModel.swift b/Model/Cache/VideosCacheModel.swift index 6ec80eea..91366920 100644 --- a/Model/Cache/VideosCacheModel.swift +++ b/Model/Cache/VideosCacheModel.swift @@ -19,6 +19,8 @@ struct VideosCacheModel: CacheModel { func storeVideo(_ video: Video) { logger.info("caching \(video.cacheKey)") try? storage?.setObject(video.json, forKey: video.cacheKey) + + ChannelsCacheModel.shared.storeIfMissing(video.channel) } func retrieveVideo(_ cacheKey: String) -> Video? { diff --git a/Model/Channel.swift b/Model/Channel.swift index 6919580f..4545dfd6 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -64,6 +64,10 @@ struct Channel: Identifiable, Hashable { } } + var app: VideosApp + var instanceID: Instance.ID? + var instanceURL: URL? + var id: String var name: String var bannerURL: URL? @@ -112,14 +116,37 @@ struct Channel: Identifiable, Hashable { var json: JSON { [ + "app": app.rawValue, "id": id, "name": name, "thumbnailURL": thumbnailURL?.absoluteString ?? "" ] } + var cacheKey: String { + switch app { + case .local: + return id + case .invidious: + return "youtube-\(id)" + case .piped: + return "youtube-\(id)" + case .peerTube: + return "peertube-\(instanceURL?.absoluteString ?? "unknown-instance")-\(id)" + } + } + + var hasExtendedDetails: Bool { + thumbnailURL != nil + } + + var thumbnailURLOrCached: URL? { + thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.thumbnailURL + } + static func from(_ json: JSON) -> Self { .init( + app: VideosApp(rawValue: json["app"].stringValue) ?? .local, id: json["id"].stringValue, name: json["name"].stringValue, thumbnailURL: json["thumbnailURL"].url diff --git a/Model/RecentsModel.swift b/Model/RecentsModel.swift index 23cd9ba7..6aee9c4f 100644 --- a/Model/RecentsModel.swift +++ b/Model/RecentsModel.swift @@ -98,7 +98,7 @@ struct RecentItem: Defaults.Serializable, Identifiable { return nil } - return Channel(id: id, name: title) + return Channel(app: .invidious, id: id, name: title) } var playlist: ChannelPlaylist? { diff --git a/Model/Video.swift b/Model/Video.swift index c61b776e..06bca4fe 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -69,7 +69,7 @@ struct Video: Identifiable, Equatable, Hashable { views: Int = 0, description: String? = nil, genre: String? = nil, - channel: Channel = .init(id: "", name: ""), + channel: Channel? = nil, thumbnails: [Thumbnail] = [], indexID: String? = nil, live: Bool = false, @@ -96,7 +96,7 @@ struct Video: Identifiable, Equatable, Hashable { self.views = views self.description = description self.genre = genre - self.channel = channel + self.channel = channel ?? .init(app: app, id: "", name: "") self.thumbnails = thumbnails self.indexID = indexID self.live = live diff --git a/Shared/Channels/ChannelVideosView.swift b/Shared/Channels/ChannelVideosView.swift index 6020f5dd..5f2daf72 100644 --- a/Shared/Channels/ChannelVideosView.swift +++ b/Shared/Channels/ChannelVideosView.swift @@ -133,7 +133,18 @@ struct ChannelVideosView: View { } #endif .onAppear { - resource?.loadIfNeeded() + if let channel, + let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey), + store.item.isNil + { + store.replace(cache) + } + + resource?.loadIfNeeded()?.onSuccess { response in + if let channel: Channel = response.typedContent() { + ChannelsCacheModel.shared.store(channel) + } + } } .onChange(of: contentType) { _ in resource?.load() diff --git a/Shared/Home/FavoriteItemView.swift b/Shared/Home/FavoriteItemView.swift index 6649ca1b..aa4b01f9 100644 --- a/Shared/Home/FavoriteItemView.swift +++ b/Shared/Home/FavoriteItemView.swift @@ -121,7 +121,7 @@ struct FavoriteItemView: View { Group { switch item.section { case let .channel(_, id, name): - ChannelVideosView(channel: .init(id: id, name: name)) + ChannelVideosView(channel: .init(app: .invidious, id: id, name: name)) case let .channelPlaylist(_, id, title): ChannelPlaylistView(playlist: .init(id: id, title: title)) case let .playlist(_, id): @@ -140,7 +140,7 @@ struct FavoriteItemView: View { func itemButtonAction() { switch item.section { case let .channel(_, id, name): - NavigationModel.shared.openChannel(.init(id: id, name: name), navigationStyle: navigationStyle) + NavigationModel.shared.openChannel(.init(app: .invidious, id: id, name: name), navigationStyle: navigationStyle) case let .channelPlaylist(_, id, title): NavigationModel.shared.openChannelPlaylist(.init(id: id, title: title), navigationStyle: navigationStyle) case .subscriptions: diff --git a/Shared/Subscriptions/ChannelsView.swift b/Shared/Subscriptions/ChannelsView.swift index e3383af2..0a3cd8da 100644 --- a/Shared/Subscriptions/ChannelsView.swift +++ b/Shared/Subscriptions/ChannelsView.swift @@ -15,7 +15,7 @@ struct ChannelsView: View { ForEach(subscriptions.all) { channel in NavigationLink(destination: ChannelVideosView(channel: channel).modifier(PlayerOverlayModifier())) { HStack { - if let url = channel.thumbnailURL { + if let url = channel.thumbnailURLOrCached { ThumbnailView(url: url) .frame(width: 35, height: 35) .clipShape(RoundedRectangle(cornerRadius: 35)) diff --git a/Shared/Videos/VideoBanner.swift b/Shared/Videos/VideoBanner.swift index 7c5e46c1..2b4c455f 100644 --- a/Shared/Videos/VideoBanner.swift +++ b/Shared/Videos/VideoBanner.swift @@ -74,8 +74,11 @@ struct VideoBanner: View { HStack { HStack { - if !inChannelView { - ThumbnailView(url: video?.channel.thumbnailURL) + if !inChannelView, + let video, + let url = video.channel.thumbnailURLOrCached + { + ThumbnailView(url: url) .frame(width: 30, height: 30) .clipShape(Circle()) } diff --git a/Shared/Videos/VideoCell.swift b/Shared/Videos/VideoCell.swift index 000867ac..08a059c8 100644 --- a/Shared/Videos/VideoCell.swift +++ b/Shared/Videos/VideoCell.swift @@ -166,8 +166,18 @@ struct VideoCell: View { videoDetail(video.displayTitle, lineLimit: 5) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - if !channelOnThumbnail, !inChannelView { - channelControl(badge: false) + HStack(spacing: 12) { + if !inChannelView, + let video, + let url = video.channel.thumbnailURLOrCached + { + ThumbnailView(url: url) + .frame(width: 30, height: 30) + .clipShape(Circle()) + } + if !channelOnThumbnail, !inChannelView { + channelControl(badge: false) + } } if additionalDetailsAvailable { @@ -271,6 +281,15 @@ struct VideoCell: View { .padding(.bottom, 4) HStack(spacing: 8) { + if !inChannelView, + let video, + let url = video.channel.thumbnailURLOrCached + { + ThumbnailView(url: url) + .frame(width: 30, height: 30) + .clipShape(Circle()) + } + if let date = video.publishedDate { HStack(spacing: 2) { Text(date) @@ -512,7 +531,7 @@ struct VideoCell_Preview: PreviewProvider { #if os(macOS) .frame(maxWidth: 300, maxHeight: 250) #elseif os(iOS) - .frame(maxWidth: 300, maxHeight: 200) + .frame(maxWidth: 600, maxHeight: 200) #endif .injectFixtureEnvironmentObjects() } diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index ad0a90d5..a3c06233 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -849,6 +849,9 @@ 37D6025A28C17375009E8D98 /* PlaybackStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D6025828C17375009E8D98 /* PlaybackStatsView.swift */; }; 37D6025B28C17375009E8D98 /* PlaybackStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D6025828C17375009E8D98 /* PlaybackStatsView.swift */; }; 37D6025D28C17719009E8D98 /* ControlsOverlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D6025C28C17719009E8D98 /* ControlsOverlayButton.swift */; }; + 37D836BC294927E700005E5E /* ChannelsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D836BB294927E700005E5E /* ChannelsCacheModel.swift */; }; + 37D836BD294927E700005E5E /* ChannelsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D836BB294927E700005E5E /* ChannelsCacheModel.swift */; }; + 37D836BE294927E700005E5E /* ChannelsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D836BB294927E700005E5E /* ChannelsCacheModel.swift */; }; 37DA0F20291DD6B8009B38CF /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 37DA0F1F291DD6B8009B38CF /* Logging */; }; 37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; }; 37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; }; @@ -1410,6 +1413,7 @@ 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosAPI.swift; sourceTree = ""; }; 37D6025828C17375009E8D98 /* PlaybackStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackStatsView.swift; sourceTree = ""; }; 37D6025C28C17719009E8D98 /* ControlsOverlayButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlsOverlayButton.swift; sourceTree = ""; }; + 37D836BB294927E700005E5E /* ChannelsCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsCacheModel.swift; sourceTree = ""; }; 37D9169A27388A81002B1BAA /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = ""; }; 37DD9DA22785BBC900539416 /* NoCommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCommentsView.swift; sourceTree = ""; }; @@ -2060,6 +2064,7 @@ 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */, 37A2B345294723850050933E /* CacheModel.swift */, 377692552946476F0055EC18 /* ChannelPlaylistsCacheModel.swift */, + 37D836BB294927E700005E5E /* ChannelsCacheModel.swift */, 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */, 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */, 37E64DD026D597EB00C71877 /* SubscribedChannelsModel.swift */, @@ -3052,6 +3057,7 @@ 376A33E02720CAD6000C1D6B /* VideosApp.swift in Sources */, 374AB3DB28BCAF7E00DF56FB /* SeekType.swift in Sources */, 37192D5728B179D60012EEDD /* ChaptersView.swift in Sources */, + 37D836BC294927E700005E5E /* ChannelsCacheModel.swift in Sources */, 37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */, 37C0698227260B2100F7F6CB /* ThumbnailsModel.swift in Sources */, 37BC50A82778A84700510953 /* HistorySettings.swift in Sources */, @@ -3254,6 +3260,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 37D836BD294927E700005E5E /* ChannelsCacheModel.swift in Sources */, 3727B74B27872B880021C15E /* VisualEffectBlur-macOS.swift in Sources */, 374710062755291C00CE0F87 /* SearchTextField.swift in Sources */, 37F0F4EB286F397E00C06C2E /* SettingsModel.swift in Sources */, @@ -3753,6 +3760,7 @@ 37141675267A8E10006CA35D /* Country.swift in Sources */, 370F500C27CC1821001B35DC /* MPVViewController.swift in Sources */, 3782B9542755667600990149 /* String+Format.swift in Sources */, + 37D836BE294927E700005E5E /* ChannelsCacheModel.swift in Sources */, 37152EEC26EFEB95004FB96D /* LazyView.swift in Sources */, 37EF9A78275BEB8E0043B585 /* CommentView.swift in Sources */, 37484C2726FC83E000287258 /* InstanceForm.swift in Sources */,