From bc42a2fa88900294cb988697b63115c31a15249b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 11 Dec 2022 18:04:39 +0100 Subject: [PATCH] User playlists cache --- Model/Applications/InvidiousAPI.swift | 3 +- Model/Applications/PeerTubeAPI.swift | 3 +- Model/Cache/CacheModel.swift | 26 ++++++++-- Model/Cache/FeedCacheModel.swift | 24 +-------- Model/Cache/PlaylistsCacheModel.swift | 73 +++++++++++++++++++++++++++ Model/FeedModel.swift | 18 +------ Model/Playlist.swift | 37 +++++++++++--- Model/PlaylistsModel.swift | 51 +++++++++++++------ Model/SubscribedChannelsModel.swift | 26 ++-------- Shared/Playlists/PlaylistsView.swift | 14 ++++- Yattee.xcodeproj/project.pbxproj | 8 +++ 11 files changed, 190 insertions(+), 93 deletions(-) create mode 100644 Model/Cache/PlaylistsCacheModel.swift diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index d8ff1be5..c01b2132 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -114,8 +114,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity) -> Playlist in - // hacky, to verify if possible to get it in easier way - Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!)) + self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!)) } configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity) -> [Video] in diff --git a/Model/Applications/PeerTubeAPI.swift b/Model/Applications/PeerTubeAPI.swift index 72d8ce93..4988faa9 100644 --- a/Model/Applications/PeerTubeAPI.swift +++ b/Model/Applications/PeerTubeAPI.swift @@ -121,8 +121,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity) -> Playlist in - // hacky, to verify if possible to get it in easier way - Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!)) + self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!)) } configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity) -> [Video] in diff --git a/Model/Cache/CacheModel.swift b/Model/Cache/CacheModel.swift index 2c6058ef..ed2f8c36 100644 --- a/Model/Cache/CacheModel.swift +++ b/Model/Cache/CacheModel.swift @@ -13,18 +13,36 @@ struct CacheModel { func clear() { FeedCacheModel.shared.clear() VideosCacheModel.shared.clear() + PlaylistsCacheModel.shared.clear() } var totalSize: Int { (FeedCacheModel.shared.storage.totalDiskStorageSize ?? 0) + - (VideosCacheModel.shared.storage.totalDiskStorageSize ?? 0) + (VideosCacheModel.shared.storage.totalDiskStorageSize ?? 0) + + (PlaylistsCacheModel.shared.storage.totalDiskStorageSize ?? 0) } var totalSizeFormatted: String { - totalSizeFormatter.string(fromByteCount: Int64(totalSize)) + byteCountFormatter.string(fromByteCount: Int64(totalSize)) } - private var totalSizeFormatter: ByteCountFormatter { - .init() + var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + + return formatter } + + var dateFormatterForTimeOnly: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + + return formatter + } + + var iso8601DateFormatter: ISO8601DateFormatter { .init() } + + private var byteCountFormatter: ByteCountFormatter { .init() } } diff --git a/Model/Cache/FeedCacheModel.swift b/Model/Cache/FeedCacheModel.swift index 2f23cf62..cdda7ead 100644 --- a/Model/Cache/FeedCacheModel.swift +++ b/Model/Cache/FeedCacheModel.swift @@ -18,7 +18,7 @@ struct FeedCacheModel { ) func storeFeed(account: Account, videos: [Video]) { - let date = iso8601DateFormatter.string(from: Date()) + let date = CacheModel.shared.iso8601DateFormatter.string(from: Date()) logger.info("caching feed \(account.feedCacheKey) -- \(date)") let feedTimeObject: JSON = ["date": date] let videosObject: JSON = ["videos": videos.prefix(Self.limit).map { $0.json.object }] @@ -41,7 +41,7 @@ struct FeedCacheModel { func getFeedTime(account: Account) -> Date? { if let json = try? storage.object(forKey: feedTimeCacheKey(account.feedCacheKey)), let string = json.dictionaryValue["date"]?.string, - let date = iso8601DateFormatter.date(from: string) + let date = CacheModel.shared.iso8601DateFormatter.date(from: string) { return date } @@ -56,24 +56,4 @@ struct FeedCacheModel { private func feedTimeCacheKey(_ feedCacheKey: String) -> String { "\(feedCacheKey)-feedTime" } - - private var iso8601DateFormatter: ISO8601DateFormatter { - .init() - } - - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - - return formatter - } - - private var dateFormatterForTimeOnly: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .medium - - return formatter - } } diff --git a/Model/Cache/PlaylistsCacheModel.swift b/Model/Cache/PlaylistsCacheModel.swift new file mode 100644 index 00000000..880e89cc --- /dev/null +++ b/Model/Cache/PlaylistsCacheModel.swift @@ -0,0 +1,73 @@ +import Cache +import Foundation +import Logging +import SwiftyJSON + +struct PlaylistsCacheModel { + static let shared = PlaylistsCacheModel() + static let limit = 30 + let logger = Logger(label: "stream.yattee.cache.playlists") + + static let diskConfig = DiskConfig(name: "playlists") + static let memoryConfig = MemoryConfig() + + let storage = try! Storage( + diskConfig: Self.diskConfig, + memoryConfig: Self.memoryConfig, + transformer: CacheModel.jsonTransformer + ) + + func storePlaylist(account: Account, playlists: [Playlist]) { + let date = CacheModel.shared.iso8601DateFormatter.string(from: Date()) + logger.info("caching \(playlistCacheKey(account)) -- \(date)") + let feedTimeObject: JSON = ["date": date] + let playlistsObject: JSON = ["playlists": playlists.map { $0.json.object }] + try? storage.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account)) + try? storage.setObject(playlistsObject, forKey: playlistCacheKey(account)) + } + + func retrievePlaylists(account: Account) -> [Playlist] { + logger.info("retrieving cache for \(playlistCacheKey(account))") + + if let json = try? storage.object(forKey: playlistCacheKey(account)), + let playlists = json.dictionaryValue["playlists"] + { + return playlists.arrayValue.map { Playlist.from($0) } + } + + return [] + } + + func getPlaylistsTime(account: Account) -> Date? { + if let json = try? storage.object(forKey: playlistTimeCacheKey(account)), + let string = json.dictionaryValue["date"]?.string, + let date = CacheModel.shared.iso8601DateFormatter.date(from: string) + { + return date + } + + return nil + } + + func getFormattedPlaylistTime(account: Account) -> String { + if let time = getPlaylistsTime(account: account) { + let isSameDay = Calendar(identifier: .iso8601).isDate(time, inSameDayAs: Date()) + let formatter = isSameDay ? CacheModel.shared.dateFormatterForTimeOnly : CacheModel.shared.dateFormatter + return formatter.string(from: time) + } + + return "" + } + + func clear() { + try? storage.removeAll() + } + + private func playlistCacheKey(_ account: Account) -> String { + "playlists-\(account.id)" + } + + private func playlistTimeCacheKey(_ account: Account) -> String { + "\(playlistCacheKey(account))-time" + } +} diff --git a/Model/FeedModel.swift b/Model/FeedModel.swift index 2af207db..de2e948d 100644 --- a/Model/FeedModel.swift +++ b/Model/FeedModel.swift @@ -106,7 +106,7 @@ final class FeedModel: ObservableObject { var formattedFeedTime: String { if let feedTime { let isSameDay = Calendar(identifier: .iso8601).isDate(feedTime, inSameDayAs: Date()) - let formatter = isSameDay ? dateFormatterForTimeOnly : dateFormatter + let formatter = isSameDay ? CacheModel.shared.dateFormatterForTimeOnly : CacheModel.shared.dateFormatter return formatter.string(from: feedTime) } @@ -123,22 +123,6 @@ final class FeedModel: ObservableObject { } } - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - - return formatter - } - - private var dateFormatterForTimeOnly: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .medium - - return formatter - } - private func request(_ resource: Resource, force: Bool = false) -> Request? { if force { return resource.load() diff --git a/Model/Playlist.swift b/Model/Playlist.swift index 9bbb1766..dc74e159 100644 --- a/Model/Playlist.swift +++ b/Model/Playlist.swift @@ -23,7 +23,14 @@ struct Playlist: Identifiable, Equatable, Hashable { var videos = [Video]() - init(id: String, title: String, visibility: Visibility, editable: Bool = true, updated: TimeInterval? = nil, videos: [Video] = []) { + init( + id: String, + title: String, + visibility: Visibility, + editable: Bool = true, + updated: TimeInterval? = nil, + videos: [Video] = [] + ) { self.id = id self.title = title self.visibility = visibility @@ -32,11 +39,29 @@ struct Playlist: Identifiable, Equatable, Hashable { self.videos = videos } - init(_ json: JSON) { - id = json["playlistId"].stringValue - title = json["title"].stringValue - visibility = json["isListed"].boolValue ? .public : .private - updated = json["updated"].doubleValue + var json: JSON { + let dateFormatter = ISO8601DateFormatter() + + return [ + "id": id, + "title": title, + "visibility": visibility.rawValue, + "editable": editable ? "editable" : "", + "updated": updated ?? "", + "videos": videos.map(\.json).map(\.object) + ] + } + + static func from(_ json: JSON) -> Self { + let dateFormatter = ISO8601DateFormatter() + + return .init( + id: json["id"].stringValue, + title: json["title"].stringValue, + visibility: .init(rawValue: json["visibility"].stringValue) ?? .public, + updated: json["updated"].doubleValue, + videos: json["videos"].arrayValue.map { Video.from($0) } + ) } static func == (lhs: Playlist, rhs: Playlist) -> Bool { diff --git a/Model/PlaylistsModel.swift b/Model/PlaylistsModel.swift index 0914724b..da84fa93 100644 --- a/Model/PlaylistsModel.swift +++ b/Model/PlaylistsModel.swift @@ -6,6 +6,7 @@ import SwiftUI final class PlaylistsModel: ObservableObject { static var shared = PlaylistsModel() + @Published var isLoading = false @Published var playlists = [Playlist]() @Published var reloadPlaylists = false @@ -36,29 +37,49 @@ final class PlaylistsModel: ObservableObject { } func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) { - guard accounts.app.supportsUserPlaylists, accounts.signedIn else { + guard accounts.app.supportsUserPlaylists, accounts.signedIn, let account = accounts.current else { playlists = [] return } - let request = force ? resource?.load() : resource?.loadIfNeeded() + loadCachedPlaylists(account) - guard !request.isNil else { - onSuccess() - return - } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let request = force ? self.resource?.load() : self.resource?.loadIfNeeded() - request? - .onSuccess { resource in - if let playlists: [Playlist] = resource.typedContent() { - self.playlists = playlists - onSuccess() + guard !request.isNil else { + onSuccess() + return + } + + self.isLoading = true + + request? + .onCompletion { [weak self] _ in + self?.isLoading = false } + .onSuccess { resource in + if let playlists: [Playlist] = resource.typedContent() { + self.playlists = playlists + PlaylistsCacheModel.shared.storePlaylist(account: account, playlists: playlists) + onSuccess() + } + } + .onFailure { error in + self.playlists = [] + NavigationModel.shared.presentAlert(title: "Could not refresh Playlists", message: error.userMessage) + } + } + } + + private func loadCachedPlaylists(_ account: Account) { + let cache = PlaylistsCacheModel.shared.retrievePlaylists(account: account) + if !cache.isEmpty { + DispatchQueue.main.async(qos: .userInteractive) { + self.playlists = cache } - .onFailure { error in - self.playlists = [] - NavigationModel.shared.presentAlert(title: "Could not refresh Playlists", message: error.userMessage) - } + } } func addVideo( diff --git a/Model/SubscribedChannelsModel.swift b/Model/SubscribedChannelsModel.swift index ef5bef2f..a065f71d 100644 --- a/Model/SubscribedChannelsModel.swift +++ b/Model/SubscribedChannelsModel.swift @@ -89,7 +89,7 @@ final class SubscribedChannelsModel: ObservableObject { } func storeChannels(account: Account, channels: [Channel]) { - let date = iso8601DateFormatter.string(from: Date()) + let date = CacheModel.shared.iso8601DateFormatter.string(from: Date()) logger.info("caching channels \(channelsDateCacheKey(account)) -- \(date)") let dateObject: JSON = ["date": date] @@ -117,10 +117,6 @@ final class SubscribedChannelsModel: ObservableObject { } } - private var iso8601DateFormatter: ISO8601DateFormatter { - .init() - } - private func channelsCacheKey(_ account: Account) -> String { "channels-\(account.id)" } @@ -132,7 +128,7 @@ final class SubscribedChannelsModel: ObservableObject { func getFeedTime(account: Account) -> Date? { if let json = try? storage.object(forKey: channelsDateCacheKey(account)), let string = json.dictionaryValue["date"]?.string, - let date = iso8601DateFormatter.date(from: string) + let date = CacheModel.shared.iso8601DateFormatter.date(from: string) { return date } @@ -151,26 +147,10 @@ final class SubscribedChannelsModel: ObservableObject { var formattedCacheTime: String { if let feedTime { let isSameDay = Calendar(identifier: .iso8601).isDate(feedTime, inSameDayAs: Date()) - let formatter = isSameDay ? dateFormatterForTimeOnly : dateFormatter + let formatter = isSameDay ? CacheModel.shared.dateFormatterForTimeOnly : CacheModel.shared.dateFormatter return formatter.string(from: feedTime) } return "" } - - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - - return formatter - } - - private var dateFormatterForTimeOnly: DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .medium - - return formatter - } } diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index 8f38b6fa..b01816ec 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -17,6 +17,7 @@ struct PlaylistsView: View { @ObservedObject private var accounts = AccountsModel.shared private var player = PlayerModel.shared @ObservedObject private var model = PlaylistsModel.shared + private var cache = PlaylistsCacheModel.shared @Namespace private var focusNamespace @@ -73,8 +74,17 @@ struct PlaylistsView: View { .padding(.top, 40) Spacer() #else - VerticalCells(items: items) - .environment(\.scrollViewBottomPadding, 70) + VerticalCells(items: items) { + HStack { + Spacer() + + CacheStatusHeader( + refreshTime: cache.getFormattedPlaylistTime(account: accounts.current), + isLoading: model.isLoading + ) + } + } + .environment(\.scrollViewBottomPadding, 70) #endif } .environment(\.currentPlaylistID, currentPlaylist?.id) diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index a6033470..779514e8 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -534,6 +534,9 @@ 3776924E294630110055EC18 /* ChannelAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776924D294630110055EC18 /* ChannelAvatarView.swift */; }; 3776924F294630110055EC18 /* ChannelAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776924D294630110055EC18 /* ChannelAvatarView.swift */; }; 37769250294630110055EC18 /* ChannelAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776924D294630110055EC18 /* ChannelAvatarView.swift */; }; + 3776925229463C310055EC18 /* PlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */; }; + 3776925329463C310055EC18 /* PlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */; }; + 3776925429463C310055EC18 /* PlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */; }; 3776ADD6287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; 3776ADD7287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; 3776ADD8287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; @@ -1252,6 +1255,7 @@ 37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; 37737785276F9858000521C1 /* Windows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = ""; }; 3776924D294630110055EC18 /* ChannelAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAvatarView.swift; sourceTree = ""; }; + 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsCacheModel.swift; sourceTree = ""; }; 3776ADD5287381240078EBC4 /* Captions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Captions.swift; path = Model/Captions.swift; sourceTree = SOURCE_ROOT; }; 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = ""; }; 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstancesManifest.swift; sourceTree = ""; }; @@ -2019,6 +2023,7 @@ 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */, 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */, 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */, + 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */, 377F9F7A294403F20043F856 /* VideosCacheModel.swift */, ); path = Cache; @@ -2959,6 +2964,7 @@ 377ABC40286E4AD5009C986F /* InstancesManifest.swift in Sources */, 37BD07B52698AA4D003EBB87 /* ContentView.swift in Sources */, 37D2E0D428B67EFC00F64D52 /* Delay.swift in Sources */, + 3776925229463C310055EC18 /* PlaylistsCacheModel.swift in Sources */, 3759234628C26C7B00C052EC /* Refreshable+Backport.swift in Sources */, 374924ED2921669B0017D862 /* PreferenceKeys.swift in Sources */, 37130A5B277657090033018A /* Yattee.xcdatamodeld in Sources */, @@ -3253,6 +3259,7 @@ 3752069A285E8DD300CA655F /* Chapter.swift in Sources */, 373EBD69291F252D002ADB9C /* EditFavorites.swift in Sources */, 37484C1A26FC837400287258 /* PlayerSettings.swift in Sources */, + 3776925329463C310055EC18 /* PlaylistsCacheModel.swift in Sources */, 37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */, 37484C3226FCB8F900287258 /* AccountValidator.swift in Sources */, 378E9C4129455A5800B2D696 /* ChannelsView.swift in Sources */, @@ -3523,6 +3530,7 @@ 3782B95727557E6E00990149 /* SearchSuggestions.swift in Sources */, 3776ADD8287381240078EBC4 /* Captions.swift in Sources */, 37F0F4EC286F397E00C06C2E /* SettingsModel.swift in Sources */, + 3776925429463C310055EC18 /* PlaylistsCacheModel.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 37BC50AA2778A84700510953 /* HistorySettings.swift in Sources */, 37F13B64285E43C000B137E4 /* ControlsOverlay.swift in Sources */,