From d046f0ab56dc7ac644d64c12f2dc6bda0d5c64b5 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 12 Aug 2021 00:39:28 -0600 Subject: [PATCH] Implement starter error messages --- JellyfinPlayer.xcodeproj/project.pbxproj | 24 +++ JellyfinPlayer/ConnectToServerView.swift | 4 +- Shared/Errors/ErrorMessage.swift | 35 +++++ Shared/Errors/NetworkError.swift | 148 ++++++++++++++++++ .../ViewModels/ConnectToServerViewModel.swift | 48 ++---- Shared/ViewModels/DetailItemViewModel.swift | 11 +- Shared/ViewModels/HomeViewModel.swift | 20 +-- Shared/ViewModels/LatestMediaViewModel.swift | 6 +- .../ViewModels/LibraryFilterViewModel.swift | 30 ++-- Shared/ViewModels/LibraryListViewModel.swift | 6 +- .../ViewModels/LibrarySearchViewModel.swift | 12 +- Shared/ViewModels/LibraryViewModel.swift | 22 +-- Shared/ViewModels/SeasonItemViewModel.swift | 3 +- Shared/ViewModels/SeriesItemViewModel.swift | 5 +- Shared/ViewModels/ViewModel.swift | 61 ++++---- 15 files changed, 301 insertions(+), 134 deletions(-) create mode 100644 Shared/Errors/ErrorMessage.swift create mode 100644 Shared/Errors/NetworkError.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index c07269ae..814dab25 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -197,6 +197,12 @@ 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; + E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; + E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; + E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; + E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; + E1FCD09926C4F358007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; + E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -376,6 +382,8 @@ D79953919FED0C4DF72BA578 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = ""; }; DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; + E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; + E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; EBFE1F64394BCC2EFFF1610D /* Pods_JellyfinPlayer_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -557,6 +565,7 @@ 535870752669D60C00D05A09 /* Shared */ = { isa = PBXGroup; children = ( + E1FCD08E26C466F3007C8DCF /* Errors */, 091B5A852683142E00D78B61 /* ServerLocator */, 62EC352A26766657000E9F2D /* Singleton */, 532175392671BCED005491E6 /* ViewModels */, @@ -760,6 +769,15 @@ path = Pods; sourceTree = ""; }; + E1FCD08E26C466F3007C8DCF /* Errors */ = { + isa = PBXGroup; + children = ( + E1FCD08726C35A0D007C8DCF /* NetworkError.swift */, + E1FCD09526C47118007C8DCF /* ErrorMessage.swift */, + ); + path = Errors; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1065,6 +1083,7 @@ 53ABFDDE267974E300886593 /* SplashView.swift in Sources */, 53ABFDE8267974EF00886593 /* SplashViewModel.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, + E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */, 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, @@ -1072,6 +1091,7 @@ 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, + E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, @@ -1144,6 +1164,7 @@ 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */, + E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, @@ -1188,6 +1209,7 @@ 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, + E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */, @@ -1206,8 +1228,10 @@ 6267B3DB2671139400A7371D /* ImageExtensions.swift in Sources */, 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */, 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */, + E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */, 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */, + E1FCD09926C4F358007C8DCF /* NetworkError.swift in Sources */, 62EC353226766849000E9F2D /* SessionManager.swift in Sources */, 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */, ); diff --git a/JellyfinPlayer/ConnectToServerView.swift b/JellyfinPlayer/ConnectToServerView.swift index 05a3fe73..0a565880 100644 --- a/JellyfinPlayer/ConnectToServerView.swift +++ b/JellyfinPlayer/ConnectToServerView.swift @@ -169,7 +169,9 @@ struct ConnectToServerView: View { viewModel.passwordSubject.send(password) } .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text("Error"), message: Text($viewModel.errorMessage.wrappedValue!), dismissButton: .default(Text("Try again"))) + Alert(title: Text("\(viewModel.errorMessage?.code ?? -1)\n\(viewModel.errorMessage?.title ?? "Error")"), + message: Text(viewModel.errorMessage?.displayMessage ?? "Error"), + dismissButton: .cancel()) } .navigationTitle(NSLocalizedString("Connect to Server", comment: "")) } diff --git a/Shared/Errors/ErrorMessage.swift b/Shared/Errors/ErrorMessage.swift new file mode 100644 index 00000000..b61e534b --- /dev/null +++ b/Shared/Errors/ErrorMessage.swift @@ -0,0 +1,35 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +struct ErrorMessage: Identifiable { + + let code: Int + let title: String + let displayMessage: String + let logMessage: String + let logLevel: LogLevel + let logTag: String + + var id: String { + return "\(code)\(title)\(logMessage)" + } + + /// If the given displayMessage is `nil`, it will be set to the given logMessage + init(code: Int, title: String, displayMessage: String?, logMessage: String, logLevel: LogLevel, logTag: String?) { + self.code = code + self.title = title + self.displayMessage = displayMessage ?? logMessage + self.logMessage = logMessage + self.logLevel = logLevel + self.logTag = logTag ?? "" + } +} diff --git a/Shared/Errors/NetworkError.swift b/Shared/Errors/NetworkError.swift new file mode 100644 index 00000000..d05148f2 --- /dev/null +++ b/Shared/Errors/NetworkError.swift @@ -0,0 +1,148 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +enum NetworkError: Error { + + /// For the case that the ErrorResponse object has a code of -1 + case URLError(response: ErrorResponse, displayMessage: String?, logLevel: LogLevel, tag: String?) + + /// For the case that the ErrorRespones object has a code of -2 + case HTTPURLError(response: ErrorResponse, displayMessage: String?, logLevel: LogLevel, tag: String?) + + /// For the case that the ErrorResponse object has a positive code + case JellyfinError(response: ErrorResponse, displayMessage: String?, logLevel: LogLevel, tag: String?) + + var errorMessage: ErrorMessage { + switch self { + case .URLError(response: let response, displayMessage: let displayMessage, let logLevel, let tag): + return NetworkError.parseURLError(from: response, displayMessage: displayMessage, logLevel: logLevel, logTag: tag) + case .HTTPURLError(response: let response, displayMessage: let displayMessage, let logLevel, let tag): + return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage, logLevel: logLevel, logTag: tag) + case .JellyfinError(response: let response, displayMessage: let displayMessage, let logLevel, let tag): + return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage, logLevel: logLevel, logTag: tag) + } + } + + func logMessage() { + let errorMessage = self.errorMessage + let logFunction: (@autoclosure () -> String, String, String, String, UInt) -> Void + + switch errorMessage.logLevel { + case .trace: + logFunction = LogManager.shared.log.trace + case .debug: + logFunction = LogManager.shared.log.debug + case .information: + logFunction = LogManager.shared.log.info + case .warning: + logFunction = LogManager.shared.log.warning + case .error: + logFunction = LogManager.shared.log.error + case .critical: + logFunction = LogManager.shared.log.critical + case ._none: + logFunction = LogManager.shared.log.debug + } + + logFunction(errorMessage.logMessage, "", "", "", 0) + } + + private static func parseURLError(from response: ErrorResponse, displayMessage: String?, logLevel: LogLevel, logTag: String?) -> ErrorMessage { + + let errorMessage: ErrorMessage + var logMessage = "An error has occurred." + + switch response { + case .error(_, _, _, let err): + + // These codes are currently referenced from: + // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes + switch err._code { + case -1001: + logMessage = "Network timed out." + errorMessage = ErrorMessage(code: err._code, + title: "Timed Out", + displayMessage: displayMessage, + logMessage: logMessage, + logLevel: logLevel, + logTag: logTag) + case -1004: + logMessage = "Cannot connect to host." + errorMessage = ErrorMessage(code: err._code, + title: "Error", + displayMessage: displayMessage, + logMessage: logMessage, + logLevel: logLevel, + logTag: logTag) + default: + errorMessage = ErrorMessage(code: err._code, + title: "Error", + displayMessage: displayMessage, + logMessage: logMessage, + logLevel: logLevel, + logTag: logTag) + } + } + + return errorMessage + } + + private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?, logLevel: LogLevel, logTag: String?) -> ErrorMessage { + + let errorMessage: ErrorMessage + let logMessage = "An HTTP URL error has occurred" + + // Not implemented as has not run into one of these errors as time of writing + switch response { + case .error(_, _, _, _): + errorMessage = ErrorMessage(code: 0, + title: "Error", + displayMessage: displayMessage, + logMessage: logMessage, + logLevel: logLevel, + logTag: logTag) + } + + return errorMessage + } + + private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?, logLevel: LogLevel, logTag: String?) -> ErrorMessage { + + let errorMessage: ErrorMessage + var logMessage = "An error has occurred." + + switch response { + case .error(let code, _, _, _): + + // Generic HTTP status codes + switch code { + case 401: + logMessage = "User is unauthorized." + errorMessage = ErrorMessage(code: code, + title: "Unauthorized", + displayMessage: displayMessage, + logMessage: logMessage, + logLevel: logLevel, + logTag: logTag) + default: + errorMessage = ErrorMessage(code: code, + title: "Error", + displayMessage: displayMessage, + logMessage: logMessage, + logLevel: logLevel, + logTag: logTag) + } + } + + return errorMessage + } +} diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index bb9c23aa..eca8a7b7 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -12,19 +12,16 @@ import Foundation import JellyfinAPI final class ConnectToServerViewModel: ViewModel { - @Published - var isConnectedServer = false + + @Published var isConnectedServer = false var uriSubject = CurrentValueSubject("") var usernameSubject = CurrentValueSubject("") var passwordSubject = CurrentValueSubject("") - @Published - var lastPublicUsers = [UserDto]() - @Published - var publicUsers = [UserDto]() - @Published - var selectedPublicUser = UserDto() + @Published var lastPublicUsers = [UserDto]() + @Published var publicUsers = [UserDto]() + @Published var selectedPublicUser = UserDto() private let discovery: ServerDiscovery = ServerDiscovery() @Published var servers: [ServerDiscovery.ServerLookupResponse] = [] @@ -41,7 +38,7 @@ final class ConnectToServerViewModel: ViewModel { UserAPI.getPublicUsers() .trackActivity(loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestCompletion(completion: completion) + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in self.publicUsers = response LogManager.shared.log.debug("Received \(String(response.count)) public users.", tag: "getPublicUsers") @@ -67,17 +64,8 @@ final class ConnectToServerViewModel: ViewModel { LogManager.shared.log.debug("Attempting to connect to server at \"\(uriSubject.value)\"", tag: "connectToServer") ServerEnvironment.current.create(with: uriSubject.value) .trackActivity(loading) - .sink(receiveCompletion: { result in - switch result { - case let .failure(error): - let err = error as NSError - LogManager.shared.log.critical("Error connecting to server at \"\(self.uriSubject.value)\"", tag: "connectToServer") - LogManager.shared.log.critical(err.debugDescription, tag: "login") - self.errorMessage = error.localizedDescription - break - default: - break - } + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", completion: completion) }, receiveValue: { _ in LogManager.shared.log.debug("Connected to server at \"\(self.uriSubject.value)\"", tag: "connectToServer") self.getPublicUsers() @@ -112,25 +100,7 @@ final class ConnectToServerViewModel: ViewModel { SessionManager.current.login(username: usernameSubject.value, password: passwordSubject.value) .trackActivity(loading) .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure(let error): - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - LogManager.shared.log.critical("Error connecting to server at \"\(self.uriSubject.value)\"", tag: "login") - LogManager.shared.log.critical("User provided invalid credentials, server returned a 401 error.", tag: "login") - self.errorMessage = "Invalid credentials" - case .error: - let err = error as NSError - LogManager.shared.log.critical("Error logging in to server at \"\(self.uriSubject.value)\"", tag: "login") - LogManager.shared.log.critical(err.debugDescription, tag: "login") - self.errorMessage = err.localizedDescription - } - } - break - } + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "login", completion: completion) }, receiveValue: { _ in }) diff --git a/Shared/ViewModels/DetailItemViewModel.swift b/Shared/ViewModels/DetailItemViewModel.swift index ce18e4d4..e87fca33 100644 --- a/Shared/ViewModels/DetailItemViewModel.swift +++ b/Shared/ViewModels/DetailItemViewModel.swift @@ -12,6 +12,7 @@ import Foundation import JellyfinAPI class DetailItemViewModel: ViewModel { + @Published var item: BaseItemDto @Published var similarItems: [BaseItemDto] = [] @@ -31,7 +32,7 @@ class DetailItemViewModel: ViewModel { LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.current.user.user_id!, limit: 20, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.similarItems = response.items ?? [] }) @@ -43,7 +44,7 @@ class DetailItemViewModel: ViewModel { PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] _ in self?.isWatched = false }) @@ -52,7 +53,7 @@ class DetailItemViewModel: ViewModel { PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] _ in self?.isWatched = true }) @@ -65,7 +66,7 @@ class DetailItemViewModel: ViewModel { UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] _ in self?.isFavorited = false }) @@ -74,7 +75,7 @@ class DetailItemViewModel: ViewModel { UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] _ in self?.isFavorited = true }) diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 6e24afe5..44571bbf 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -14,14 +14,10 @@ import JellyfinAPI final class HomeViewModel: ViewModel { - @Published - var librariesShowRecentlyAddedIDs = [String]() - @Published - var libraries = [BaseItemDto]() - @Published - var resumeItems = [BaseItemDto]() - @Published - var nextUpItems = [BaseItemDto]() + @Published var librariesShowRecentlyAddedIDs = [String]() + @Published var libraries = [BaseItemDto]() + @Published var resumeItems = [BaseItemDto]() + @Published var nextUpItems = [BaseItemDto]() // temp var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) @@ -37,7 +33,7 @@ final class HomeViewModel: ViewModel { UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id!) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestCompletion(completion: completion) + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in response.items!.forEach { item in LogManager.shared.log.debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")") @@ -49,7 +45,7 @@ final class HomeViewModel: ViewModel { UserAPI.getCurrentUser() .trackActivity(self.loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestCompletion(completion: completion) + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in self.libraries.forEach { library in if !(response.configuration?.latestItemsExcludes?.contains(library.id!))! { @@ -67,7 +63,7 @@ final class HomeViewModel: ViewModel { mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestCompletion(completion: completion) + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in LogManager.shared.log.debug("Retrieved \(String(response.items!.count)) resume items") self.resumeItems = response.items ?? [] @@ -78,7 +74,7 @@ final class HomeViewModel: ViewModel { fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestCompletion(completion: completion) + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in LogManager.shared.log.debug("Retrieved \(String(response.items!.count)) nextup items") self.nextUpItems = response.items ?? [] diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift index 83163c0b..82b42cf1 100644 --- a/Shared/ViewModels/LatestMediaViewModel.swift +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -12,8 +12,8 @@ import Foundation import JellyfinAPI final class LatestMediaViewModel: ViewModel { - @Published - var items = [BaseItemDto]() + + @Published var items = [BaseItemDto]() var libraryID: String @@ -38,7 +38,7 @@ final class LatestMediaViewModel: ViewModel { enableUserData: true, limit: 12) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.items = response LogManager.shared.log.debug("Retrieved \(String(self?.items.count ?? 0)) items") diff --git a/Shared/ViewModels/LibraryFilterViewModel.swift b/Shared/ViewModels/LibraryFilterViewModel.swift index 83f336d4..bb2d0241 100644 --- a/Shared/ViewModels/LibraryFilterViewModel.swift +++ b/Shared/ViewModels/LibraryFilterViewModel.swift @@ -20,25 +20,17 @@ enum FilterType { } final class LibraryFilterViewModel: ViewModel { - @Published - var modifiedFilters = LibraryFilters() + + @Published var modifiedFilters = LibraryFilters() - @Published - var possibleGenres = [NameGuidPair]() - @Published - var possibleTags = [String]() - @Published - var possibleSortOrders = APISortOrder.allCases - @Published - var possibleSortBys = SortBy.allCases - @Published - var possibleItemFilters = ItemFilter.supportedTypes - @Published - var enabledFilterType: [FilterType] - @Published - var selectedSortOrder: APISortOrder = .descending - @Published - var selectedSortBy: SortBy = .name + @Published var possibleGenres = [NameGuidPair]() + @Published var possibleTags = [String]() + @Published var possibleSortOrders = APISortOrder.allCases + @Published var possibleSortBys = SortBy.allCases + @Published var possibleItemFilters = ItemFilter.supportedTypes + @Published var enabledFilterType: [FilterType] + @Published var selectedSortOrder: APISortOrder = .descending + @Published var selectedSortBy: SortBy = .name var parentId: String = "" @@ -69,7 +61,7 @@ final class LibraryFilterViewModel: ViewModel { FilterAPI.getQueryFilters(userId: SessionManager.current.user.user_id!, parentId: self.parentId) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] queryFilters in guard let self = self else { return } self.possibleGenres = queryFilters.genres ?? [] diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 54ec7100..5a546f94 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -11,8 +11,8 @@ import Foundation import JellyfinAPI final class LibraryListViewModel: ViewModel { - @Published - var libraries = [BaseItemDto]() + + @Published var libraries = [BaseItemDto]() // temp var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) @@ -27,7 +27,7 @@ final class LibraryListViewModel: ViewModel { UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id ?? "val was nil") .trackActivity(loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestCompletion(completion: completion) + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in self.libraries.append(contentsOf: response.items ?? []) }) diff --git a/Shared/ViewModels/LibrarySearchViewModel.swift b/Shared/ViewModels/LibrarySearchViewModel.swift index 77f1cc03..9d0e035b 100644 --- a/Shared/ViewModels/LibrarySearchViewModel.swift +++ b/Shared/ViewModels/LibrarySearchViewModel.swift @@ -87,9 +87,11 @@ final class LibrarySearchViewModel: ViewModel { enableTotalRecordCount: false, enableImages: false) .trackActivity(loading) - .sink(receiveCompletion: handleAPIRequestCompletion(completion:)) { [weak self] response in + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in self?.suggestions = response.items ?? [] - } + }) .store(in: &cancellables) } @@ -100,7 +102,7 @@ final class LibrarySearchViewModel: ViewModel { includeItemTypes: [ItemType.movie.rawValue], sortBy: ["SortName"], enableUserData: true, enableImages: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.movieItems = response.items ?? [] }) @@ -111,7 +113,7 @@ final class LibrarySearchViewModel: ViewModel { includeItemTypes: [ItemType.series.rawValue], sortBy: ["SortName"], enableUserData: true, enableImages: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.showItems = response.items ?? [] }) @@ -122,7 +124,7 @@ final class LibrarySearchViewModel: ViewModel { includeItemTypes: [ItemType.episode.rawValue], sortBy: ["SortName"], enableUserData: true, enableImages: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.episodeItems = response.items ?? [] }) diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index d3f155af..f73d2803 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -17,21 +17,15 @@ final class LibraryViewModel: ViewModel { var genre: NameGuidPair? var studio: NameGuidPair? - @Published - var items = [BaseItemDto]() + @Published var items = [BaseItemDto]() - @Published - var totalPages = 0 - @Published - var currentPage = 0 - @Published - var hasNextPage = false - @Published - var hasPreviousPage = false + @Published var totalPages = 0 + @Published var currentPage = 0 + @Published var hasNextPage = false + @Published var hasPreviousPage = false // temp - @Published - var filters: LibraryFilters + @Published var filters: LibraryFilters var enabledFilterType: [FilterType] { if genre == nil { @@ -76,7 +70,7 @@ final class LibraryViewModel: ViewModel { enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) items in library \(self?.parentID ?? "nil")") guard let self = self else { return } @@ -106,7 +100,7 @@ final class LibraryViewModel: ViewModel { filters: filters.filters, sortBy: sortBy, tags: filters.tags, enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in guard let self = self else { return } let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0) diff --git a/Shared/ViewModels/SeasonItemViewModel.swift b/Shared/ViewModels/SeasonItemViewModel.swift index 499e7901..67efab76 100644 --- a/Shared/ViewModels/SeasonItemViewModel.swift +++ b/Shared/ViewModels/SeasonItemViewModel.swift @@ -12,6 +12,7 @@ import Foundation import JellyfinAPI final class SeasonItemViewModel: DetailItemViewModel { + @Published var episodes = [BaseItemDto]() override init(item: BaseItemDto) { @@ -28,7 +29,7 @@ final class SeasonItemViewModel: DetailItemViewModel { seasonId: item.id ?? "") .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.episodes = response.items ?? [] LogManager.shared.log.debug("Retrieved \(String(self?.episodes.count ?? 0)) episodes") diff --git a/Shared/ViewModels/SeriesItemViewModel.swift b/Shared/ViewModels/SeriesItemViewModel.swift index 7bbf86a1..a05ebdbe 100644 --- a/Shared/ViewModels/SeriesItemViewModel.swift +++ b/Shared/ViewModels/SeriesItemViewModel.swift @@ -12,6 +12,7 @@ import Foundation import JellyfinAPI final class SeriesItemViewModel: DetailItemViewModel { + @Published var seasons = [BaseItemDto]() @Published var nextUpItem: BaseItemDto? @@ -28,7 +29,7 @@ final class SeriesItemViewModel: DetailItemViewModel { TvShowsAPI.getNextUp(userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: self.item.id!, enableUserData: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.nextUpItem = response.items?.first ?? nil }) @@ -58,7 +59,7 @@ final class SeriesItemViewModel: DetailItemViewModel { TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestCompletion(completion: completion) + self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in self?.seasons = response.items ?? [] LogManager.shared.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 2f057641..3b2397bc 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -12,44 +12,45 @@ import Foundation import ActivityIndicator import JellyfinAPI -typealias ErrorMessage = String - -extension ErrorMessage: Identifiable { - public var id: String { - self - } -} - class ViewModel: ObservableObject { - var cancellables = Set() - @Published - var isLoading = true + + @Published var isLoading = true + @Published var errorMessage: ErrorMessage? + let loading = ActivityIndicator() - @Published - var errorMessage: ErrorMessage? + var cancellables = Set() init() { loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) } - - func handleAPIRequestCompletion(completion: Subscribers.Completion) { + + func handleAPIRequestError(displayMessage: String? = nil, logLevel: LogLevel = .error, tag: String = "", completion: Subscribers.Completion) { switch completion { - case .finished: - break - case .failure(let error): - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - LogManager.shared.log.error("Request failed: User unauthorized, server returned a 401 error code.") - self.errorMessage = err.localizedDescription - SessionManager.current.logout() - case .error: - LogManager.shared.log.error("Request failed.") - LogManager.shared.log.error((err as NSError).debugDescription) - self.errorMessage = err.localizedDescription - } + case .finished: + break + case .failure(let error): + if let errorResponse = error as? ErrorResponse { + + let networkError: NetworkError + + switch errorResponse { + case .error(-1, _, _, _): + networkError = .URLError(response: errorResponse, displayMessage: displayMessage, logLevel: logLevel, tag: tag) + // Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented + LogManager.shared.log.error("Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)") + case .error(-2, _, _, _): + networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage, logLevel: logLevel, tag: tag) + LogManager.shared.log.error("Request failed: HTTP URL request failed with description: \(errorResponse.localizedDescription)") + default: + networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage, logLevel: logLevel, tag: tag) + // Able to use user-facing friendly description here since just HTTP status codes + LogManager.shared.log.error("Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.logMessage)\n\(error.localizedDescription)") } - break + + self.errorMessage = networkError.errorMessage + + networkError.logMessage() + } } } }