Ferrite: Parallel tasks and logging
Make all tasks run in parallel to increase responsiveness and efficiency when fetching new data. However, parallel tasks means that toast errors are no longer feasible. Instead, add a logging system which has a more detailed view of app messages and direct the user there if there is an error. Signed-off-by: kingbri <bdashore3@proton.me>
This commit is contained in:
parent
a0632b0c16
commit
7202a95bb2
23 changed files with 840 additions and 431 deletions
|
|
@ -49,6 +49,8 @@
|
|||
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */; };
|
||||
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; };
|
||||
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
|
||||
0C5708E929B8E61C00BE07F9 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708E829B8E61C00BE07F9 /* Logger.swift */; };
|
||||
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; };
|
||||
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */; };
|
||||
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */; };
|
||||
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
|
||||
|
|
@ -103,7 +105,7 @@
|
|||
0CA148E3288903F000DE2211 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CB288903F000DE2211 /* Task.swift */; };
|
||||
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CD288903F000DE2211 /* DebridManager.swift */; };
|
||||
0CA148E6288903F000DE2211 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CE288903F000DE2211 /* WebView.swift */; };
|
||||
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* ToastViewModel.swift */; };
|
||||
0CA148E7288903F000DE2211 /* LoggingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* LoggingManager.swift */; };
|
||||
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */; };
|
||||
0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; };
|
||||
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; };
|
||||
|
|
@ -179,6 +181,8 @@
|
|||
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = "<group>"; };
|
||||
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
0C5708E829B8E61C00BE07F9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; };
|
||||
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppearHandler.swift; sourceTree = "<group>"; };
|
||||
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = "<group>"; };
|
||||
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -230,7 +234,7 @@
|
|||
0CA148CB288903F000DE2211 /* Task.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
|
||||
0CA148CD288903F000DE2211 /* DebridManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebridManager.swift; sourceTree = "<group>"; };
|
||||
0CA148CE288903F000DE2211 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
|
||||
0CA148CF288903F000DE2211 /* ToastViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = "<group>"; };
|
||||
0CA148CF288903F000DE2211 /* LoggingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingManager.swift; sourceTree = "<group>"; };
|
||||
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealDebridWrapper.swift; sourceTree = "<group>"; };
|
||||
0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -387,6 +391,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
0C44E2A728D4DDDC007711AE /* Application.swift */,
|
||||
0C5708E829B8E61C00BE07F9 /* Logger.swift */,
|
||||
);
|
||||
path = Classes;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -450,6 +455,7 @@
|
|||
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
|
||||
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */,
|
||||
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
|
||||
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -544,7 +550,7 @@
|
|||
children = (
|
||||
0CA148CD288903F000DE2211 /* DebridManager.swift */,
|
||||
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */,
|
||||
0CA148CF288903F000DE2211 /* ToastViewModel.swift */,
|
||||
0CA148CF288903F000DE2211 /* LoggingManager.swift */,
|
||||
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */,
|
||||
0CA05458288EE9E600850554 /* PluginManager.swift */,
|
||||
0C44E2AC28D51C63007711AE /* BackupManager.swift */,
|
||||
|
|
@ -753,6 +759,7 @@
|
|||
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
|
||||
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
|
||||
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
|
||||
0C5708E929B8E61C00BE07F9 /* Logger.swift in Sources */,
|
||||
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */,
|
||||
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
|
||||
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
|
||||
|
|
@ -781,6 +788,7 @@
|
|||
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
|
||||
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
|
||||
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
|
||||
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */,
|
||||
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
|
||||
0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */,
|
||||
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */,
|
||||
|
|
@ -793,7 +801,7 @@
|
|||
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
|
||||
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
|
||||
0CA148E3288903F000DE2211 /* Task.swift in Sources */,
|
||||
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */,
|
||||
0CA148E7288903F000DE2211 /* LoggingManager.swift in Sources */,
|
||||
0C6771FC29B3E0DB005D38D2 /* HybridSecureField.swift in Sources */,
|
||||
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
|
||||
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */,
|
||||
|
|
|
|||
65
Ferrite/Classes/Logger.swift
Normal file
65
Ferrite/Classes/Logger.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// Logger.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 3/8/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Logger {
|
||||
var messageArray: [Log] = []
|
||||
|
||||
struct Log: Hashable {
|
||||
let level: LogLevel
|
||||
let description: String
|
||||
let timeStamp: Date = .init()
|
||||
|
||||
func toMessage() -> String {
|
||||
"[\(level.rawValue)]: \(description)"
|
||||
}
|
||||
}
|
||||
|
||||
enum LogLevel: String, Identifiable {
|
||||
var id: Int {
|
||||
hashValue
|
||||
}
|
||||
|
||||
case info = "INFO"
|
||||
case warn = "WARN"
|
||||
case error = "ERROR"
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
let log = Log(
|
||||
level: .info,
|
||||
description: message
|
||||
)
|
||||
|
||||
messageArray.append(log)
|
||||
|
||||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
func warn(_ message: String) {
|
||||
let log = Log(
|
||||
level: .warn,
|
||||
description: message
|
||||
)
|
||||
|
||||
messageArray.append(log)
|
||||
|
||||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
let log = Log(
|
||||
level: .error,
|
||||
description: message
|
||||
)
|
||||
|
||||
messageArray.append(log)
|
||||
|
||||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ struct FerriteApp: App {
|
|||
let persistenceController = PersistenceController.shared
|
||||
|
||||
@StateObject var scrapingModel: ScrapingViewModel = .init()
|
||||
@StateObject var toastModel: ToastViewModel = .init()
|
||||
@StateObject var logManager: LoggingManager = .init()
|
||||
@StateObject var debridManager: DebridManager = .init()
|
||||
@StateObject var navModel: NavigationViewModel = .init()
|
||||
@StateObject var pluginManager: PluginManager = .init()
|
||||
|
|
@ -22,15 +22,15 @@ struct FerriteApp: App {
|
|||
WindowGroup {
|
||||
MainView()
|
||||
.backport.onAppear {
|
||||
scrapingModel.toastModel = toastModel
|
||||
debridManager.toastModel = toastModel
|
||||
pluginManager.toastModel = toastModel
|
||||
backupManager.toastModel = toastModel
|
||||
navModel.toastModel = toastModel
|
||||
scrapingModel.logManager = logManager
|
||||
debridManager.logManager = logManager
|
||||
pluginManager.logManager = logManager
|
||||
backupManager.logManager = logManager
|
||||
navModel.logManager = logManager
|
||||
}
|
||||
.environmentObject(debridManager)
|
||||
.environmentObject(scrapingModel)
|
||||
.environmentObject(toastModel)
|
||||
.environmentObject(logManager)
|
||||
.environmentObject(navModel)
|
||||
.environmentObject(pluginManager)
|
||||
.environmentObject(backupManager)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ extension Kodi {
|
|||
}
|
||||
|
||||
// MARK: - RPC payload
|
||||
|
||||
struct RPCPayload: Encodable {
|
||||
let jsonrpc: String = "2.0"
|
||||
let id: String = "1"
|
||||
|
|
@ -24,11 +25,13 @@ extension Kodi {
|
|||
}
|
||||
|
||||
// MARK: - RPC Params
|
||||
|
||||
struct Params: Codable {
|
||||
let item: Item
|
||||
}
|
||||
|
||||
// MARK: - RPC Item
|
||||
|
||||
struct Item: Codable {
|
||||
let file: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,4 +29,9 @@ extension PluginManager {
|
|||
enum PluginManagerError: Error {
|
||||
case ListAddition(description: String)
|
||||
}
|
||||
|
||||
struct AvailablePlugins {
|
||||
let availableSources: [SourceJson]
|
||||
let availableActions: [ActionJson]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
// A raw search result structure displayed on the UI
|
||||
public struct SearchResult: Codable, Hashable, Sendable {
|
||||
let title: String?
|
||||
let source: String
|
||||
|
|
@ -15,3 +16,13 @@ public struct SearchResult: Codable, Hashable, Sendable {
|
|||
let seeders: String?
|
||||
let leechers: String?
|
||||
}
|
||||
|
||||
extension ScrapingViewModel {
|
||||
// Contains both search results and magnet links for scalability
|
||||
struct SearchRequestResult: Sendable {
|
||||
let results: [SearchResult]
|
||||
let magnets: [Magnet]
|
||||
}
|
||||
|
||||
struct ScrapingError: Error {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public class BackupManager: ObservableObject {
|
|||
// Constant variable for backup versions
|
||||
let latestBackupVersion: Int = 2
|
||||
|
||||
var toastModel: ToastViewModel?
|
||||
var logManager: LoggingManager?
|
||||
|
||||
@Published var showRestoreAlert = false
|
||||
@Published var showRestoreCompletedAlert = false
|
||||
|
|
@ -110,17 +110,18 @@ public class BackupManager: ObservableObject {
|
|||
|
||||
await updateBackupUrls(newUrl: writeUrl)
|
||||
} catch {
|
||||
await toastModel?.updateToastDescription("Backup error: \(error)")
|
||||
print("Backup error: \(error)")
|
||||
await logManager?.error("Backup: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Backup is in local documents directory, so no need to restore it from the shared URL
|
||||
// Pass the pluginManager reference since it's not used throughout the class like toastModel
|
||||
// Pass the pluginManager reference since it's not used throughout the class like logManager
|
||||
func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async {
|
||||
guard let backupUrl = selectedBackupUrl else {
|
||||
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
|
||||
print("Backup restore error: Could not find backup in app directory.")
|
||||
await logManager?.error(
|
||||
"Backup restore: Could not find backup in app directory.",
|
||||
description: "Could not find the selected backup in the local directory."
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -194,8 +195,10 @@ public class BackupManager: ObservableObject {
|
|||
await toggleRestoreCompletedAlert()
|
||||
}
|
||||
} catch {
|
||||
await toastModel?.updateToastDescription("Backup restore error: \(error)")
|
||||
print("Backup restore error: \(error)")
|
||||
await logManager?.error(
|
||||
"Backup restore: \(error)",
|
||||
description: "A backup restore error was logged"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,8 +215,8 @@ public class BackupManager: ObservableObject {
|
|||
}
|
||||
} catch {
|
||||
Task {
|
||||
await toastModel?.updateToastDescription("Backup removal error: \(error)")
|
||||
print("Backup removal error: \(error)")
|
||||
await logManager?.error("Backup removal: \(error)")
|
||||
print("Backup removal: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -242,7 +245,7 @@ public class BackupManager: ObservableObject {
|
|||
selectedBackupUrl = localBackupPath
|
||||
} catch {
|
||||
Task {
|
||||
await toastModel?.updateToastDescription("Backup copy: \(error)")
|
||||
await logManager?.error("Backup copy: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import SwiftUI
|
|||
@MainActor
|
||||
public class DebridManager: ObservableObject {
|
||||
// Linked classes
|
||||
var toastModel: ToastViewModel?
|
||||
var logManager: LoggingManager?
|
||||
let realDebrid: RealDebrid = .init()
|
||||
let allDebrid: AllDebrid = .init()
|
||||
let premiumize: Premiumize = .init()
|
||||
|
|
@ -122,17 +122,30 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
// Wrapper function to match error descriptions
|
||||
// Error can be suppressed to end user but must be printed in logs
|
||||
func sendDebridError(_ error: Error, prefix: String, presentError: Bool = true, cancelString: String? = nil) async {
|
||||
func sendDebridError(
|
||||
_ error: Error,
|
||||
prefix: String,
|
||||
presentError: Bool = true,
|
||||
cancelString: String? = nil
|
||||
) async {
|
||||
let error = error as NSError
|
||||
if presentError {
|
||||
if let cancelString, error.code == -999 {
|
||||
toastModel?.updateToastDescription(cancelString, newToastType: .info)
|
||||
} else if error.code != -999 {
|
||||
toastModel?.updateToastDescription("\(prefix): \(error)")
|
||||
switch error.code {
|
||||
case -1009:
|
||||
logManager?.info(
|
||||
"DebridManager: The connection is offline",
|
||||
description: "The connection is offline"
|
||||
)
|
||||
case -999:
|
||||
if let cancelString {
|
||||
logManager?.info(cancelString, description: cancelString)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
default:
|
||||
logManager?.error("\(prefix): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
print("\(prefix): \(error)")
|
||||
}
|
||||
|
||||
// Cleans all cached IA values in the event of a full IA refresh
|
||||
|
|
@ -273,7 +286,7 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
public func selectDebridResult(magnet: Magnet) -> Bool {
|
||||
guard let magnetHash = magnet.hash else {
|
||||
toastModel?.updateToastDescription("Could not find the torrent magnet hash")
|
||||
logManager?.error("DebridManager: Could not find the torrent magnet hash")
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +296,7 @@ public class DebridManager: ObservableObject {
|
|||
selectedRealDebridItem = realDebridItem
|
||||
return true
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
|
||||
logManager?.error("DebridManager: Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
|
||||
return false
|
||||
}
|
||||
case .allDebrid:
|
||||
|
|
@ -291,7 +304,7 @@ public class DebridManager: ObservableObject {
|
|||
selectedAllDebridItem = allDebridItem
|
||||
return true
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not find the associated AllDebrid entry for magnet hash \(magnetHash)")
|
||||
logManager?.error("DebridManager: Could not find the associated AllDebrid entry for magnet hash \(magnetHash)")
|
||||
return false
|
||||
}
|
||||
case .premiumize:
|
||||
|
|
@ -299,7 +312,7 @@ public class DebridManager: ObservableObject {
|
|||
selectedPremiumizeItem = premiumizeItem
|
||||
return true
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not find the associated Premiumize entry for magnet hash \(magnetHash)")
|
||||
logManager?.error("DebridManager: Could not find the associated Premiumize entry for magnet hash \(magnetHash)")
|
||||
return false
|
||||
}
|
||||
case .none:
|
||||
|
|
@ -353,7 +366,7 @@ public class DebridManager: ObservableObject {
|
|||
// Wrapper function to validate and present an auth URL to the user
|
||||
@discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
|
||||
guard let url else {
|
||||
toastModel?.updateToastDescription("Authentication Error: Invalid URL created: \(String(describing: url))")
|
||||
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -475,7 +488,10 @@ public class DebridManager: ObservableObject {
|
|||
allDebrid.deleteTokens()
|
||||
enabledDebrids.remove(.allDebrid)
|
||||
|
||||
toastModel?.updateToastDescription("Please manually delete the AllDebrid API key", newToastType: .info)
|
||||
logManager?.info(
|
||||
"AllDebrid: Logged out, API key needs to be removed",
|
||||
description: "Please manually delete the AllDebrid API key"
|
||||
)
|
||||
}
|
||||
|
||||
private func logoutPm() {
|
||||
|
|
@ -490,10 +506,10 @@ public class DebridManager: ObservableObject {
|
|||
public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
|
||||
defer {
|
||||
currentDebridTask = nil
|
||||
toastModel?.hideIndeterminateToast()
|
||||
logManager?.hideIndeterminateToast()
|
||||
}
|
||||
|
||||
toastModel?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||
self.currentDebridTask?.cancel()
|
||||
self.currentDebridTask = nil
|
||||
})
|
||||
|
|
@ -553,7 +569,10 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
downloadUrl = downloadLink
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
|
||||
logManager?.error(
|
||||
"RealDebrid: Could not cache torrent with hash \(String(describing: magnet.hash))",
|
||||
description: "Could not cache this torrent. Aborting."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw RealDebrid.RDError.FailedRequest(description: "Could not fetch your file from RealDebrid's cache or API")
|
||||
|
|
@ -571,7 +590,7 @@ public class DebridManager: ObservableObject {
|
|||
await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false)
|
||||
}
|
||||
|
||||
toastModel?.hideIndeterminateToast()
|
||||
logManager?.hideIndeterminateToast()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
136
Ferrite/ViewModels/LoggingManager.swift
Normal file
136
Ferrite/ViewModels/LoggingManager.swift
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//
|
||||
// ToastViewModel.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/19/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class LoggingManager: ObservableObject {
|
||||
struct Log: Hashable {
|
||||
let level: LogLevel
|
||||
let message: String
|
||||
let timeStamp: Date = .init()
|
||||
|
||||
func toMessage() -> String {
|
||||
"[\(level.rawValue)]: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
enum LogLevel: String, Identifiable {
|
||||
var id: Int {
|
||||
hashValue
|
||||
}
|
||||
|
||||
case info = "INFO"
|
||||
case warn = "WARN"
|
||||
case error = "ERROR"
|
||||
}
|
||||
|
||||
@Published var messageArray: [Log] = []
|
||||
|
||||
// Toast variables
|
||||
@Published var toastDescription: String? = nil {
|
||||
didSet {
|
||||
Task {
|
||||
try? await Task.sleep(seconds: 0.1)
|
||||
showToast = true
|
||||
|
||||
try? await Task.sleep(seconds: 3)
|
||||
|
||||
showToast = false
|
||||
toastType = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var showToast: Bool = false
|
||||
// Default the toast type to error since the majority of toasts are errors
|
||||
@Published var toastType: Logger.LogLevel = .error
|
||||
var showErrorToasts: Bool {
|
||||
UserDefaults.standard.bool(forKey: "Debug.ShowErrorToasts")
|
||||
}
|
||||
|
||||
@Published var indeterminateToastDescription: String? = nil
|
||||
@Published var indeterminateCancelAction: (() -> Void)? = nil
|
||||
@Published var showIndeterminateToast: Bool = false
|
||||
|
||||
// MARK: - Logging functions
|
||||
|
||||
public func info(_ message: String,
|
||||
description: String? = nil)
|
||||
{
|
||||
let log = Log(
|
||||
level: .info,
|
||||
message: message
|
||||
)
|
||||
|
||||
if let description {
|
||||
toastType = .info
|
||||
toastDescription = description
|
||||
}
|
||||
|
||||
messageArray.append(log)
|
||||
|
||||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
public func warn(_ message: String,
|
||||
description: String? = nil)
|
||||
{
|
||||
let log = Log(
|
||||
level: .warn,
|
||||
message: message
|
||||
)
|
||||
|
||||
if let description {
|
||||
toastType = .warn
|
||||
toastDescription = description
|
||||
}
|
||||
|
||||
messageArray.append(log)
|
||||
|
||||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
public func error(_ message: String,
|
||||
description: String? = nil,
|
||||
showToast: Bool = true)
|
||||
{
|
||||
let log = Log(
|
||||
level: .error,
|
||||
message: message
|
||||
)
|
||||
|
||||
// If a task is run in parallel, don't show a toast on error
|
||||
if showToast && showErrorToasts {
|
||||
toastDescription = description.map { $0 } ?? "An error was logged"
|
||||
}
|
||||
|
||||
messageArray.append(log)
|
||||
|
||||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
// MARK: - Indeterminate functions
|
||||
|
||||
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
||||
indeterminateToastDescription = description
|
||||
|
||||
if let cancelAction {
|
||||
indeterminateCancelAction = cancelAction
|
||||
}
|
||||
|
||||
if !showIndeterminateToast {
|
||||
showIndeterminateToast = true
|
||||
}
|
||||
}
|
||||
|
||||
public func hideIndeterminateToast() {
|
||||
showIndeterminateToast = false
|
||||
indeterminateToastDescription = ""
|
||||
indeterminateCancelAction = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import SwiftUI
|
|||
|
||||
@MainActor
|
||||
public class NavigationViewModel: ObservableObject {
|
||||
var toastModel: ToastViewModel?
|
||||
var logManager: LoggingManager?
|
||||
|
||||
// Used between SearchResultsView and MagnetChoiceView
|
||||
public enum ChoiceSheetType: Identifiable {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import Foundation
|
|||
import SwiftUI
|
||||
|
||||
public class PluginManager: ObservableObject {
|
||||
var toastModel: ToastViewModel?
|
||||
var logManager: LoggingManager?
|
||||
let kodi: Kodi = .init()
|
||||
|
||||
@Published var availableSources: [SourceJson] = []
|
||||
|
|
@ -22,79 +22,134 @@ public class PluginManager: ObservableObject {
|
|||
@Published var actionSuccessAlertMessage: String = ""
|
||||
|
||||
@MainActor
|
||||
func cleanAvailablePlugins() {
|
||||
availableSources = []
|
||||
availableActions = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
|
||||
availableSources += newPlugins.availableSources
|
||||
availableActions += newPlugins.availableActions
|
||||
}
|
||||
|
||||
public func fetchPluginsFromUrl() async {
|
||||
let pluginListRequest = PluginList.fetchRequest()
|
||||
do {
|
||||
let pluginLists = try PersistenceController.shared.backgroundContext.fetch(pluginListRequest)
|
||||
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
|
||||
await logManager?.error("PluginManager: No plugin lists found")
|
||||
return
|
||||
}
|
||||
|
||||
// Clean availablePlugin arrays for repopulation
|
||||
availableSources = []
|
||||
availableActions = []
|
||||
// Clean availablePlugin arrays for repopulation
|
||||
await cleanAvailablePlugins()
|
||||
|
||||
await logManager?.info("Starting fetch of plugin lists")
|
||||
|
||||
await withTaskGroup(of: (AvailablePlugins?, String).self) { group in
|
||||
for pluginList in pluginLists {
|
||||
guard let url = URL(string: pluginList.urlString) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Always get the up-to-date source list
|
||||
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
group.addTask {
|
||||
var availablePlugins: AvailablePlugins?
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
||||
do {
|
||||
availablePlugins = try await self.fetchPluginList(pluginList: pluginList, url: url)
|
||||
} catch {
|
||||
let error = error as NSError
|
||||
|
||||
if let sources = pluginResponse.sources {
|
||||
// Faster and more performant to map instead of a for loop
|
||||
availableSources += sources.compactMap { inputJson in
|
||||
if checkAppVersion(minVersion: inputJson.minVersion) {
|
||||
return SourceJson(
|
||||
name: inputJson.name,
|
||||
version: inputJson.version,
|
||||
minVersion: inputJson.minVersion,
|
||||
baseUrl: inputJson.baseUrl,
|
||||
fallbackUrls: inputJson.fallbackUrls,
|
||||
dynamicBaseUrl: inputJson.dynamicBaseUrl,
|
||||
trackers: inputJson.trackers,
|
||||
api: inputJson.api,
|
||||
jsonParser: inputJson.jsonParser,
|
||||
rssParser: inputJson.rssParser,
|
||||
htmlParser: inputJson.htmlParser,
|
||||
author: pluginList.author,
|
||||
listId: pluginList.id,
|
||||
tags: inputJson.tags
|
||||
)
|
||||
} else {
|
||||
return nil
|
||||
switch error.code {
|
||||
case -999:
|
||||
await self.logManager?.info("PluginManager: \(pluginList.name): List fetch cancelled")
|
||||
case -1009:
|
||||
await self.logManager?.info("PluginManager: \(pluginList.name): The connection is offline")
|
||||
default:
|
||||
await self.logManager?.error("Plugin fetch: \(pluginList.name): \(error)", showToast: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let actions = pluginResponse.actions {
|
||||
availableActions += actions.compactMap { inputJson in
|
||||
if checkAppVersion(minVersion: inputJson.minVersion) {
|
||||
return ActionJson(
|
||||
name: inputJson.name,
|
||||
version: inputJson.version,
|
||||
minVersion: inputJson.minVersion,
|
||||
requires: inputJson.requires,
|
||||
deeplink: inputJson.deeplink,
|
||||
author: pluginList.author,
|
||||
listId: pluginList.id,
|
||||
tags: inputJson.tags
|
||||
)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return (availablePlugins, pluginList.name)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let error = error as NSError
|
||||
if error.code != -999 {
|
||||
toastModel?.updateToastDescription("Plugin fetch error: \(error)")
|
||||
|
||||
var failedLists: [String] = []
|
||||
for await (availablePlugins, pluginListName) in group {
|
||||
if let availablePlugins {
|
||||
await updateAvailablePlugins(availablePlugins)
|
||||
} else {
|
||||
failedLists.append(pluginListName)
|
||||
}
|
||||
}
|
||||
|
||||
print("Plugin fetch error: \(error)")
|
||||
if !failedLists.isEmpty {
|
||||
let joinedLists = failedLists.joined(separator: ", ")
|
||||
await logManager?.info(
|
||||
"Plugins: Errors in plugin lists \(joinedLists). See above.",
|
||||
description: "There were errors in plugin lists \(joinedLists). Check the logs for more details."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await logManager?.info("Plugin list fetch finished")
|
||||
}
|
||||
|
||||
func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
|
||||
var tempSources: [SourceJson] = []
|
||||
var tempActions: [ActionJson] = []
|
||||
|
||||
// Always get the up-to-date source list
|
||||
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
||||
|
||||
if let sources = pluginResponse.sources {
|
||||
// Faster and more performant to map instead of a for loop
|
||||
tempSources += sources.compactMap { inputJson in
|
||||
if checkAppVersion(minVersion: inputJson.minVersion) {
|
||||
return SourceJson(
|
||||
name: inputJson.name,
|
||||
version: inputJson.version,
|
||||
minVersion: inputJson.minVersion,
|
||||
baseUrl: inputJson.baseUrl,
|
||||
fallbackUrls: inputJson.fallbackUrls,
|
||||
dynamicBaseUrl: inputJson.dynamicBaseUrl,
|
||||
trackers: inputJson.trackers,
|
||||
api: inputJson.api,
|
||||
jsonParser: inputJson.jsonParser,
|
||||
rssParser: inputJson.rssParser,
|
||||
htmlParser: inputJson.htmlParser,
|
||||
author: pluginList.author,
|
||||
listId: pluginList.id,
|
||||
tags: inputJson.tags
|
||||
)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let actions = pluginResponse.actions {
|
||||
tempActions += actions.compactMap { inputJson in
|
||||
if checkAppVersion(minVersion: inputJson.minVersion) {
|
||||
return ActionJson(
|
||||
name: inputJson.name,
|
||||
version: inputJson.version,
|
||||
minVersion: inputJson.minVersion,
|
||||
requires: inputJson.requires,
|
||||
deeplink: inputJson.deeplink,
|
||||
author: pluginList.author,
|
||||
listId: pluginList.id,
|
||||
tags: inputJson.tags
|
||||
)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AvailablePlugins(availableSources: tempSources, availableActions: tempActions)
|
||||
}
|
||||
|
||||
// forType required to guide generic inferences
|
||||
|
|
@ -285,23 +340,19 @@ public class PluginManager: ObservableObject {
|
|||
|
||||
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
||||
guard let actionJson else {
|
||||
await toastModel?.updateToastDescription("Action addition error: No action present. Contact the app dev!")
|
||||
await logManager?.error("Action addition: No action present. Contact the app dev!")
|
||||
return
|
||||
}
|
||||
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
if actionJson.requires.count < 1 {
|
||||
await toastModel?.updateToastDescription("Action addition error: actions must require an input. Please contact the action dev!")
|
||||
print("Action name \(actionJson.name) does not have a requires parameter")
|
||||
|
||||
await logManager?.error("Action addition: actions must require an input. Please contact the action dev!")
|
||||
return
|
||||
}
|
||||
|
||||
guard let deeplink = actionJson.deeplink else {
|
||||
await toastModel?.updateToastDescription("Action addition error: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
|
||||
print("Action name \(actionJson.name) did not have a deeplink")
|
||||
|
||||
await logManager?.error("Action addition: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -313,8 +364,7 @@ public class PluginManager: ObservableObject {
|
|||
if doUpsert {
|
||||
PersistenceController.shared.delete(existingAction, context: backgroundContext)
|
||||
} else {
|
||||
await toastModel?.updateToastDescription("Could not install action with name \(actionJson.name) because it is already installed")
|
||||
print("Action name \(actionJson.name) already exists in user's DB")
|
||||
await logManager?.error("Action addition: Could not install action with name \(actionJson.name) because it is already installed")
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -344,14 +394,13 @@ public class PluginManager: ObservableObject {
|
|||
do {
|
||||
try backgroundContext.save()
|
||||
} catch {
|
||||
await toastModel?.updateToastDescription("Action addition error: \(error)")
|
||||
print("Action addition error: \(error)")
|
||||
await logManager?.error("Action addition: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
||||
guard let sourceJson else {
|
||||
await toastModel?.updateToastDescription("Source addition error: No source present. Contact the app dev!")
|
||||
await logManager?.error("Source addition: No source present. Contact the app dev!")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -360,9 +409,7 @@ public class PluginManager: ObservableObject {
|
|||
// If there's no base URL and it isn't dynamic, return before any transactions occur
|
||||
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
|
||||
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
|
||||
await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
|
||||
print("Not adding source \(sourceJson.name) because base URL parameters are malformed")
|
||||
|
||||
await logManager?.error("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -375,9 +422,7 @@ public class PluginManager: ObservableObject {
|
|||
if doUpsert {
|
||||
PersistenceController.shared.delete(existingSource, context: backgroundContext)
|
||||
} else {
|
||||
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
|
||||
print("Source name \(sourceJson.name) already exists")
|
||||
|
||||
await logManager?.error("Source addition: Could not install source with name \(sourceJson.name) because it is already installed.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -435,8 +480,7 @@ public class PluginManager: ObservableObject {
|
|||
do {
|
||||
try backgroundContext.save()
|
||||
} catch {
|
||||
await toastModel?.updateToastDescription("Source addition error: \(error)")
|
||||
print("Source addition error: \(error)")
|
||||
await logManager?.error("Source addition error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,18 +13,21 @@ import SwiftyJSON
|
|||
|
||||
class ScrapingViewModel: ObservableObject {
|
||||
// Link the toast view model for single-directional communication
|
||||
var toastModel: ToastViewModel?
|
||||
var logManager: LoggingManager?
|
||||
let byteCountFormatter: ByteCountFormatter = .init()
|
||||
|
||||
var runningSearchTask: Task<Void, Error>?
|
||||
func cancelCurrentTask() {
|
||||
runningSearchTask?.cancel()
|
||||
runningSearchTask = nil
|
||||
}
|
||||
|
||||
@Published var searchResults: [SearchResult] = []
|
||||
@Published var filteredSource: Source?
|
||||
@Published var currentSourceName: String?
|
||||
|
||||
// Only add results with valid magnet hashes to the search results array
|
||||
@MainActor
|
||||
func updateSearchResults(newResults: [SearchResult]) {
|
||||
searchResults += newResults.filter { $0.magnet.hash != nil }
|
||||
searchResults += newResults
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -32,170 +35,238 @@ class ScrapingViewModel: ObservableObject {
|
|||
searchResults = []
|
||||
}
|
||||
|
||||
func cancelCurrentTask() {
|
||||
runningSearchTask?.cancel()
|
||||
runningSearchTask = nil
|
||||
@Published var currentSourceNames: Set<String> = []
|
||||
@MainActor
|
||||
func updateCurrentSourceNames(_ newName: String) {
|
||||
currentSourceNames.insert(newName)
|
||||
logManager?.updateIndeterminateToast(
|
||||
"Loading \(currentSourceNames.joined(separator: ", "))",
|
||||
cancelAction: nil
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func removeCurrentSourceName(_ removedName: String) {
|
||||
currentSourceNames.remove(removedName)
|
||||
logManager?.updateIndeterminateToast(
|
||||
"Loading \(currentSourceNames.joined(separator: ", "))",
|
||||
cancelAction: nil
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func clearCurrentSourceNames() {
|
||||
currentSourceNames = []
|
||||
logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil)
|
||||
}
|
||||
|
||||
@Published var filteredSource: Source?
|
||||
|
||||
// Utility function to print source specific errors
|
||||
func sendSourceError(_ description: String, newToastType: ToastViewModel.ToastType? = nil) async {
|
||||
let newDescription = "\(currentSourceName ?? "No source given"): \(description)"
|
||||
await toastModel?.updateToastDescription(
|
||||
newDescription,
|
||||
newToastType: newToastType
|
||||
)
|
||||
|
||||
print(newDescription)
|
||||
func sendSourceError(_ description: String) async {
|
||||
await logManager?.error(description, showToast: false)
|
||||
}
|
||||
|
||||
public func scanSources(sources: [Source], searchText: String) async {
|
||||
if sources.isEmpty {
|
||||
await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info)
|
||||
public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
|
||||
await logManager?.info("Started scanning sources for query \"\(searchText)\"")
|
||||
|
||||
if sources.isEmpty {
|
||||
await logManager?.info(
|
||||
"ScrapingModel: No sources found",
|
||||
description: "There are no sources to search!"
|
||||
)
|
||||
|
||||
print("There are no sources to search!")
|
||||
return
|
||||
}
|
||||
|
||||
if await !debridManager.enabledDebrids.isEmpty {
|
||||
await debridManager.clearIAValues()
|
||||
}
|
||||
|
||||
await clearSearchResults()
|
||||
|
||||
await toastModel?.updateIndeterminateToast("Loading", cancelAction: {
|
||||
await logManager?.updateIndeterminateToast("Loading sources", cancelAction: {
|
||||
self.cancelCurrentTask()
|
||||
})
|
||||
|
||||
for source in sources {
|
||||
// If the search is cancelled, return
|
||||
if let runningSearchTask, runningSearchTask.isCancelled {
|
||||
return
|
||||
// Run all tasks in parallel for speed
|
||||
await withTaskGroup(of: (SearchRequestResult?, String).self) { group in
|
||||
// TODO: Maybe chunk sources to groups of 5 to not overwhelm the app
|
||||
for source in sources {
|
||||
// If the search is cancelled, return
|
||||
if let runningSearchTask, runningSearchTask.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
if source.enabled {
|
||||
group.addTask {
|
||||
await self.updateCurrentSourceNames(source.name)
|
||||
let requestResult = await self.executeParser(source: source, searchText: searchText)
|
||||
|
||||
return (requestResult, source.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if source.enabled {
|
||||
await toastModel?.updateIndeterminateToast("Loading \(source.name)", cancelAction: nil)
|
||||
// Let the user know that there was an error in the source
|
||||
var failedSourceNames: [String] = []
|
||||
for await (requestResult, sourceName) in group {
|
||||
if let requestResult {
|
||||
if await !debridManager.enabledDebrids.isEmpty {
|
||||
await debridManager.populateDebridIA(requestResult.magnets)
|
||||
}
|
||||
|
||||
guard let baseUrl = source.baseUrl else {
|
||||
await toastModel?.updateToastDescription("The base URL could not be found for source \(source.name)")
|
||||
|
||||
print("The base URL could not be found for source \(source.name)")
|
||||
continue
|
||||
await self.updateSearchResults(newResults: requestResult.results)
|
||||
} else {
|
||||
failedSourceNames.append(sourceName)
|
||||
}
|
||||
|
||||
// Default to HTML scraping
|
||||
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
|
||||
await removeCurrentSourceName(sourceName)
|
||||
}
|
||||
|
||||
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
await sendSourceError("Could not process search query, invalid characters present.")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
switch preferredParser {
|
||||
case .scraping:
|
||||
if let htmlParser = source.htmlParser {
|
||||
let replacedSearchUrl = htmlParser.searchUrl
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
|
||||
let data = await handleUrls(
|
||||
baseUrl: baseUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls
|
||||
)
|
||||
|
||||
if let data,
|
||||
let html = String(data: data, encoding: .utf8)
|
||||
{
|
||||
let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html)
|
||||
await updateSearchResults(newResults: sourceResults)
|
||||
}
|
||||
}
|
||||
case .rss:
|
||||
if let rssParser = source.rssParser {
|
||||
let replacedSearchUrl = rssParser.searchUrl
|
||||
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
|
||||
// Do not use fallback URLs if the base URL isn't used
|
||||
let data: Data?
|
||||
if let rssUrl = rssParser.rssUrl {
|
||||
data = await fetchWebsiteData(urlString: rssUrl + replacedSearchUrl)
|
||||
} else {
|
||||
data = await handleUrls(
|
||||
baseUrl: baseUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls
|
||||
)
|
||||
}
|
||||
|
||||
if let data,
|
||||
let rss = String(data: data, encoding: .utf8)
|
||||
{
|
||||
let sourceResults = await scrapeRss(source: source, rss: rss)
|
||||
await updateSearchResults(newResults: sourceResults)
|
||||
}
|
||||
}
|
||||
case .siteApi:
|
||||
if let jsonParser = source.jsonParser {
|
||||
var replacedSearchUrl = jsonParser.searchUrl
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
|
||||
// Handle anything API related including tokens, client IDs, and appending the API URL
|
||||
// The source API key is for APIs that require extra credentials or use a different URL
|
||||
if let sourceApi = source.api {
|
||||
if let clientIdInfo = sourceApi.clientId {
|
||||
if let newSearchUrl = await handleApiCredential(clientIdInfo,
|
||||
replacement: "{clientId}",
|
||||
searchUrl: replacedSearchUrl,
|
||||
apiUrl: sourceApi.apiUrl,
|
||||
baseUrl: baseUrl)
|
||||
{
|
||||
replacedSearchUrl = newSearchUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Works exactly the same as the client ID check
|
||||
if let clientSecretInfo = sourceApi.clientSecret {
|
||||
if let newSearchUrl = await handleApiCredential(clientSecretInfo,
|
||||
replacement: "{secret}",
|
||||
searchUrl: replacedSearchUrl,
|
||||
apiUrl: sourceApi.apiUrl,
|
||||
baseUrl: baseUrl)
|
||||
{
|
||||
replacedSearchUrl = newSearchUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let passedUrl = source.api?.apiUrl ?? baseUrl
|
||||
let data = await handleUrls(
|
||||
baseUrl: passedUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls
|
||||
)
|
||||
|
||||
if let data {
|
||||
let sourceResults = await scrapeJson(source: source, jsonData: data)
|
||||
await updateSearchResults(newResults: sourceResults)
|
||||
}
|
||||
}
|
||||
case .none:
|
||||
continue
|
||||
}
|
||||
if !failedSourceNames.isEmpty {
|
||||
let joinedSourceNames = failedSourceNames.joined(separator: ", ")
|
||||
await logManager?.info(
|
||||
"Scraping: Errors in sources \(joinedSourceNames). See above.",
|
||||
description: "There were errors in sources \(joinedSourceNames). Check the logs for more details."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await clearCurrentSourceNames()
|
||||
await logManager?.info("Source scan finished")
|
||||
|
||||
// If the search is cancelled, return
|
||||
if let searchTask = runningSearchTask, searchTask.isCancelled {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func executeParser(source: Source, searchText: String) async -> SearchRequestResult? {
|
||||
guard let baseUrl = source.baseUrl else {
|
||||
await logManager?.error("Scraping: The base URL could not be found for source \(source.name)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default to HTML scraping
|
||||
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
|
||||
|
||||
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
await sendSourceError("\(source.name): Could not process search query, invalid characters present.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
switch preferredParser {
|
||||
case .scraping:
|
||||
if let htmlParser = source.htmlParser {
|
||||
let replacedSearchUrl = htmlParser.searchUrl
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
|
||||
let data = await handleUrls(
|
||||
baseUrl: baseUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls,
|
||||
sourceName: source.name
|
||||
)
|
||||
|
||||
if let data,
|
||||
let html = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return await scrapeHtml(source: source, baseUrl: baseUrl, html: html)
|
||||
}
|
||||
}
|
||||
case .rss:
|
||||
if let rssParser = source.rssParser {
|
||||
let replacedSearchUrl = rssParser.searchUrl
|
||||
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
|
||||
// Do not use fallback URLs if the base URL isn't used
|
||||
let data: Data?
|
||||
if let rssUrl = rssParser.rssUrl {
|
||||
data = await fetchWebsiteData(
|
||||
urlString: rssUrl + replacedSearchUrl,
|
||||
sourceName: source.name
|
||||
)
|
||||
} else {
|
||||
data = await handleUrls(
|
||||
baseUrl: baseUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls,
|
||||
sourceName: source.name
|
||||
)
|
||||
}
|
||||
|
||||
if let data,
|
||||
let rss = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return await scrapeRss(source: source, rss: rss)
|
||||
}
|
||||
}
|
||||
case .siteApi:
|
||||
if let jsonParser = source.jsonParser {
|
||||
var replacedSearchUrl = jsonParser.searchUrl
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
|
||||
// Handle anything API related including tokens, client IDs, and appending the API URL
|
||||
// The source API key is for APIs that require extra credentials or use a different URL
|
||||
if let sourceApi = source.api {
|
||||
if let clientIdInfo = sourceApi.clientId {
|
||||
if let newSearchUrl = await handleApiCredential(clientIdInfo,
|
||||
replacement: "{clientId}",
|
||||
searchUrl: replacedSearchUrl,
|
||||
apiUrl: sourceApi.apiUrl,
|
||||
baseUrl: baseUrl,
|
||||
sourceName: source.name)
|
||||
{
|
||||
replacedSearchUrl = newSearchUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Works exactly the same as the client ID check
|
||||
if let clientSecretInfo = sourceApi.clientSecret {
|
||||
if let newSearchUrl = await handleApiCredential(clientSecretInfo,
|
||||
replacement: "{secret}",
|
||||
searchUrl: replacedSearchUrl,
|
||||
apiUrl: sourceApi.apiUrl,
|
||||
baseUrl: baseUrl,
|
||||
sourceName: source.name)
|
||||
{
|
||||
replacedSearchUrl = newSearchUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let passedUrl = source.api?.apiUrl ?? baseUrl
|
||||
let data = await handleUrls(
|
||||
baseUrl: passedUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls,
|
||||
sourceName: source.name
|
||||
)
|
||||
|
||||
if let data {
|
||||
return await scrapeJson(source: source, jsonData: data)
|
||||
}
|
||||
}
|
||||
case .none:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Checks the base URL for any website data then iterates through the fallback URLs
|
||||
func handleUrls(baseUrl: String, replacedSearchUrl: String, fallbackUrls: [String]?) async -> Data? {
|
||||
if let data = await fetchWebsiteData(urlString: baseUrl + replacedSearchUrl) {
|
||||
func handleUrls(baseUrl: String, replacedSearchUrl: String, fallbackUrls: [String]?, sourceName: String) async -> Data? {
|
||||
if let data = await fetchWebsiteData(urlString: baseUrl + replacedSearchUrl, sourceName: sourceName) {
|
||||
return data
|
||||
}
|
||||
|
||||
if let fallbackUrls {
|
||||
for fallbackUrl in fallbackUrls {
|
||||
if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl) {
|
||||
if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl, sourceName: sourceName) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
@ -208,7 +279,8 @@ class ScrapingViewModel: ObservableObject {
|
|||
replacement: String,
|
||||
searchUrl: String,
|
||||
apiUrl: String?,
|
||||
baseUrl: String) async -> String?
|
||||
baseUrl: String,
|
||||
sourceName: String) async -> String?
|
||||
{
|
||||
// Is the credential expired
|
||||
var isExpired = false
|
||||
|
|
@ -227,7 +299,8 @@ class ScrapingViewModel: ObservableObject {
|
|||
let credentialUrl = credential.urlString,
|
||||
let newValue = await fetchApiCredential(
|
||||
urlString: (apiUrl ?? baseUrl) + credentialUrl,
|
||||
credential: credential
|
||||
credential: credential,
|
||||
sourceName: sourceName
|
||||
)
|
||||
{
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
|
@ -244,12 +317,12 @@ class ScrapingViewModel: ObservableObject {
|
|||
return nil
|
||||
}
|
||||
|
||||
public func fetchApiCredential(urlString: String, credential: SourceApiCredential) async -> String? {
|
||||
public func fetchApiCredential(urlString: String,
|
||||
credential: SourceApiCredential,
|
||||
sourceName: String) async -> String?
|
||||
{
|
||||
guard let url = URL(string: urlString) else {
|
||||
Task { @MainActor in
|
||||
toastModel?.updateToastDescription("This token URL is invalid.")
|
||||
}
|
||||
print("Token url \(urlString) is invalid!")
|
||||
await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -276,11 +349,13 @@ class ScrapingViewModel: ObservableObject {
|
|||
|
||||
switch error.code {
|
||||
case -999:
|
||||
await toastModel?.updateToastDescription("Search cancelled", newToastType: .info)
|
||||
await logManager?.info("Scraping: Search cancelled")
|
||||
case -1001:
|
||||
await sendSourceError("Credentials request timed out")
|
||||
await sendSourceError("\(sourceName): Credentials request timed out")
|
||||
case -1009:
|
||||
await logManager?.info("\(sourceName): The connection is offline")
|
||||
default:
|
||||
await sendSourceError("Error in fetching an API credential \(error)")
|
||||
await sendSourceError("\(sourceName): Error in fetching an API credential \(error)")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -288,9 +363,9 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// Fetches the data for a URL
|
||||
public func fetchWebsiteData(urlString: String) async -> Data? {
|
||||
public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? {
|
||||
guard let url = URL(string: urlString) else {
|
||||
await sendSourceError("Source doesn't contain a valid URL, contact the source dev!")
|
||||
await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -305,22 +380,22 @@ class ScrapingViewModel: ObservableObject {
|
|||
|
||||
switch error.code {
|
||||
case -999:
|
||||
await toastModel?.updateToastDescription("Search cancelled", newToastType: .info)
|
||||
await logManager?.info("Scraping: Search cancelled")
|
||||
case -1001:
|
||||
await sendSourceError("Data request timed out. Trying fallback URLs if present.")
|
||||
await sendSourceError("\(sourceName): Data request timed out. Trying fallback URLs if present.")
|
||||
case -1009:
|
||||
await logManager?.info("\(sourceName): The connection is offline")
|
||||
default:
|
||||
await sendSourceError("Error in fetching website data \(error)")
|
||||
await sendSourceError("\(sourceName): Error in fetching website data \(error)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func scrapeJson(source: Source, jsonData: Data) async -> [SearchResult] {
|
||||
var tempResults: [SearchResult] = []
|
||||
|
||||
public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
||||
guard let jsonParser = source.jsonParser else {
|
||||
return tempResults
|
||||
return nil
|
||||
}
|
||||
|
||||
var jsonResults: [JSON] = []
|
||||
|
|
@ -335,19 +410,20 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
} catch {
|
||||
if let api = source.api {
|
||||
await cleanApiCreds(api: api)
|
||||
|
||||
print("JSON parsing error, couldn't fetch results: \(error)")
|
||||
await sendSourceError("\(source.name): JSON parsing, couldn't fetch results: \(error)")
|
||||
await cleanApiCreds(api: api, sourceName: source.name)
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no results and the client secret isn't dynamic, just clear out the token
|
||||
if let api = source.api, jsonResults.isEmpty {
|
||||
await cleanApiCreds(api: api)
|
||||
|
||||
print("JSON results were empty!")
|
||||
await sendSourceError("\(source.name): JSON results were empty")
|
||||
await cleanApiCreds(api: api, sourceName: source.name)
|
||||
}
|
||||
|
||||
var tempResults: [SearchResult] = []
|
||||
var magnets: [Magnet] = []
|
||||
|
||||
// Iterate through results and grab what we can
|
||||
for result in jsonResults {
|
||||
var subResults: [JSON] = []
|
||||
|
|
@ -358,6 +434,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
// Otherwise append the applied result if it exists
|
||||
// Better to be redundant with checks rather than another for loop or filter
|
||||
if let subResultsQuery = jsonParser.subResults {
|
||||
// TODO: Add a for loop with subResultsQueries for further drilling into JSON
|
||||
subResults = result[subResultsQuery.components(separatedBy: ".")].arrayValue
|
||||
|
||||
for subResult in subResults {
|
||||
|
|
@ -373,6 +450,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
!tempResults.contains(newSearchResult)
|
||||
{
|
||||
tempResults.append(newSearchResult)
|
||||
magnets.append(newSearchResult.magnet)
|
||||
}
|
||||
}
|
||||
} else if
|
||||
|
|
@ -382,13 +460,18 @@ class ScrapingViewModel: ObservableObject {
|
|||
!tempResults.contains(searchResult)
|
||||
{
|
||||
tempResults.append(searchResult)
|
||||
magnets.append(searchResult.magnet)
|
||||
}
|
||||
}
|
||||
|
||||
return tempResults
|
||||
return SearchRequestResult(results: tempResults, magnets: magnets)
|
||||
}
|
||||
|
||||
public func parseJsonResult(_ result: JSON, jsonParser: SourceJsonParser, source: Source, existingSearchResult: SearchResult? = nil) -> SearchResult? {
|
||||
public func parseJsonResult(_ result: JSON,
|
||||
jsonParser: SourceJsonParser,
|
||||
source: Source,
|
||||
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
||||
{
|
||||
var magnetHash: String? = existingSearchResult?.magnet.hash
|
||||
if let magnetHashParser = jsonParser.magnetHash {
|
||||
let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue
|
||||
|
|
@ -398,6 +481,12 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
var link: String? = existingSearchResult?.magnet.link
|
||||
if let magnetLinkParser = jsonParser.magnetLink, link == nil {
|
||||
let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue
|
||||
link = rawLink is NSNull ? nil : String(describing: rawLink)
|
||||
}
|
||||
|
||||
var title: String? = existingSearchResult?.title
|
||||
if let titleParser = jsonParser.title {
|
||||
if let existingTitle = existingSearchResult?.title,
|
||||
|
|
@ -414,18 +503,18 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Return if no magnet hash exists
|
||||
let magnet = Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers)
|
||||
if magnet.hash == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subName: String?
|
||||
if let subNameParser = jsonParser.subName {
|
||||
let rawSubName = result[subNameParser.query.components(separatedBy: ".")].rawValue
|
||||
subName = rawSubName is NSNull ? nil : String(describing: rawSubName)
|
||||
}
|
||||
|
||||
var link: String? = existingSearchResult?.magnet.link
|
||||
if let magnetLinkParser = jsonParser.magnetLink, link == nil {
|
||||
let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue
|
||||
link = rawLink is NSNull ? nil : String(describing: rawLink)
|
||||
}
|
||||
|
||||
var size: String? = existingSearchResult?.size
|
||||
if let sizeParser = jsonParser.size, existingSearchResult?.size == nil {
|
||||
let rawSize = result[sizeParser.query.components(separatedBy: ".")].rawValue
|
||||
|
|
@ -455,7 +544,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
title: title,
|
||||
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
||||
size: size,
|
||||
magnet: Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers),
|
||||
magnet: magnet,
|
||||
seeders: seeders,
|
||||
leechers: leechers
|
||||
)
|
||||
|
|
@ -464,11 +553,9 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// RSS feed scraper
|
||||
public func scrapeRss(source: Source, rss: String) async -> [SearchResult] {
|
||||
var tempResults: [SearchResult] = []
|
||||
|
||||
public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
|
||||
guard let rssParser = source.rssParser else {
|
||||
return tempResults
|
||||
return nil
|
||||
}
|
||||
|
||||
var items = Elements()
|
||||
|
|
@ -477,11 +564,14 @@ class ScrapingViewModel: ObservableObject {
|
|||
let document = try SwiftSoup.parse(rss, "", Parser.xmlParser())
|
||||
items = try document.getElementsByTag(rssParser.items)
|
||||
} catch {
|
||||
await sendSourceError("RSS scraping error, couldn't fetch items: \(error)")
|
||||
await sendSourceError("\(source.name): RSS scraping error, couldn't fetch items: \(error)")
|
||||
|
||||
return tempResults
|
||||
return nil
|
||||
}
|
||||
|
||||
var tempResults: [SearchResult] = []
|
||||
var magnets: [Magnet] = []
|
||||
|
||||
for item in items {
|
||||
// Parse magnet link or translate hash
|
||||
var magnetHash: String?
|
||||
|
|
@ -495,6 +585,28 @@ class ScrapingViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
var href: String?
|
||||
if let magnetLinkParser = rssParser.magnetLink {
|
||||
href = try? runRssComplexQuery(
|
||||
item: item,
|
||||
query: magnetLinkParser.query,
|
||||
attribute: magnetLinkParser.attribute,
|
||||
discriminator: magnetLinkParser.discriminator,
|
||||
regexString: magnetLinkParser.regex
|
||||
)
|
||||
}
|
||||
|
||||
var title: String?
|
||||
if let titleParser = rssParser.title {
|
||||
title = try? runRssComplexQuery(
|
||||
item: item,
|
||||
query: titleParser.query,
|
||||
attribute: titleParser.attribute,
|
||||
discriminator: titleParser.discriminator,
|
||||
regexString: titleParser.regex
|
||||
)
|
||||
}
|
||||
|
||||
// Fetches the subName for the source if there is one
|
||||
var subName: String?
|
||||
if let subNameParser = rssParser.subName {
|
||||
|
|
@ -507,26 +619,11 @@ class ScrapingViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
var title: String?
|
||||
if let titleParser = rssParser.title {
|
||||
title = try? runRssComplexQuery(
|
||||
item: item,
|
||||
query: titleParser.query,
|
||||
attribute: titleParser.attribute,
|
||||
discriminator: titleParser.discriminator,
|
||||
regexString: titleParser.regex
|
||||
)
|
||||
}
|
||||
|
||||
var href: String?
|
||||
if let magnetLinkParser = rssParser.magnetLink {
|
||||
href = try? runRssComplexQuery(
|
||||
item: item,
|
||||
query: magnetLinkParser.query,
|
||||
attribute: magnetLinkParser.attribute,
|
||||
discriminator: magnetLinkParser.discriminator,
|
||||
regexString: magnetLinkParser.regex
|
||||
)
|
||||
// Continue if the magnet isn't valid
|
||||
// TODO: Possibly append magnet to a separate magnet array for debrid IA check
|
||||
let magnet = Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers)
|
||||
if magnet.hash == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var size: String?
|
||||
|
|
@ -572,21 +669,27 @@ class ScrapingViewModel: ObservableObject {
|
|||
title: title ?? "No title",
|
||||
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
||||
size: size ?? "",
|
||||
magnet: Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers),
|
||||
magnet: magnet,
|
||||
seeders: seeders,
|
||||
leechers: leechers
|
||||
)
|
||||
|
||||
if !tempResults.contains(result) {
|
||||
tempResults.append(result)
|
||||
magnets.append(result.magnet)
|
||||
}
|
||||
}
|
||||
|
||||
return tempResults
|
||||
return SearchRequestResult(results: tempResults, magnets: magnets)
|
||||
}
|
||||
|
||||
// Complex query parsing for RSS scraping
|
||||
func runRssComplexQuery(item: Element, query: String, attribute: String, discriminator: String?, regexString: String?) throws -> String? {
|
||||
func runRssComplexQuery(item: Element,
|
||||
query: String,
|
||||
attribute: String,
|
||||
discriminator: String?,
|
||||
regexString: String?) throws -> String?
|
||||
{
|
||||
var parsedValue: String?
|
||||
|
||||
switch attribute {
|
||||
|
|
@ -615,11 +718,9 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// HTML scraper
|
||||
public func scrapeHtml(source: Source, baseUrl: String, html: String) async -> [SearchResult] {
|
||||
var tempResults: [SearchResult] = []
|
||||
|
||||
public func scrapeHtml(source: Source, baseUrl: String, html: String) async -> SearchRequestResult? {
|
||||
guard let htmlParser = source.htmlParser else {
|
||||
return tempResults
|
||||
return nil
|
||||
}
|
||||
|
||||
var rows = Elements()
|
||||
|
|
@ -628,11 +729,14 @@ class ScrapingViewModel: ObservableObject {
|
|||
let document = try SwiftSoup.parse(html)
|
||||
rows = try document.select(htmlParser.rows)
|
||||
} catch {
|
||||
await sendSourceError("Scraping error, couldn't fetch rows: \(error)")
|
||||
await sendSourceError("\(source.name): couldn't fetch rows: \(error)")
|
||||
|
||||
return tempResults
|
||||
return nil
|
||||
}
|
||||
|
||||
var tempResults: [SearchResult] = []
|
||||
var magnets: [Magnet] = []
|
||||
|
||||
// If there's an error, continue instead of returning with nothing
|
||||
for row in rows {
|
||||
do {
|
||||
|
|
@ -647,7 +751,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
if let externalMagnetQuery = magnetParser.externalLinkQuery, !externalMagnetQuery.isEmpty {
|
||||
guard
|
||||
let externalMagnetLink = try row.select(externalMagnetQuery).first()?.attr("href"),
|
||||
let data = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink),
|
||||
let data = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink, sourceName: source.name),
|
||||
let magnetHtml = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
continue
|
||||
|
|
@ -676,6 +780,12 @@ class ScrapingViewModel: ObservableObject {
|
|||
href = link
|
||||
}
|
||||
|
||||
// Continue if the magnet isn't valid
|
||||
let magnet = Magnet(hash: nil, link: href)
|
||||
if magnet.hash == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetches the episode/movie title
|
||||
var title: String?
|
||||
if let titleParser = htmlParser.title {
|
||||
|
|
@ -752,26 +862,31 @@ class ScrapingViewModel: ObservableObject {
|
|||
title: title ?? "No title",
|
||||
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
||||
size: size ?? "",
|
||||
magnet: Magnet(hash: nil, link: href),
|
||||
magnet: magnet,
|
||||
seeders: seeders,
|
||||
leechers: leechers
|
||||
)
|
||||
|
||||
if !tempResults.contains(result) {
|
||||
tempResults.append(result)
|
||||
magnets.append(result.magnet)
|
||||
}
|
||||
} catch {
|
||||
await sendSourceError("Scraping error: \(error)")
|
||||
await sendSourceError("\(source.name): \(error)")
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return tempResults
|
||||
return SearchRequestResult(results: tempResults, magnets: magnets)
|
||||
}
|
||||
|
||||
// Complex query parsing for HTML scraping
|
||||
func runHtmlComplexQuery(row: Element, query: String, attribute: String, regexString: String?) throws -> String? {
|
||||
func runHtmlComplexQuery(row: Element,
|
||||
query: String,
|
||||
attribute: String,
|
||||
regexString: String?) throws -> String?
|
||||
{
|
||||
var parsedValue: String?
|
||||
|
||||
let result = try row.select(query).first()
|
||||
|
|
@ -816,7 +931,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func cleanApiCreds(api: SourceApi) async {
|
||||
func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let hasCredentials = api.clientId != nil || api.clientSecret != nil
|
||||
|
|
@ -861,7 +976,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
await sendSourceError(responseArray.joined(separator: " "))
|
||||
await sendSourceError("\(sourceName): \(responseArray.joined(separator: " "))")
|
||||
|
||||
PersistenceController.shared.save(backgroundContext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
//
|
||||
// ToastViewModel.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/19/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class ToastViewModel: ObservableObject {
|
||||
enum ToastType: Identifiable {
|
||||
var id: Int {
|
||||
hashValue
|
||||
}
|
||||
|
||||
case info
|
||||
case error
|
||||
}
|
||||
|
||||
// Toast variables
|
||||
@Published var toastDescription: String? = nil {
|
||||
didSet {
|
||||
Task {
|
||||
try? await Task.sleep(seconds: 0.1)
|
||||
showToast = true
|
||||
|
||||
try? await Task.sleep(seconds: 5)
|
||||
|
||||
showToast = false
|
||||
toastType = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var showToast: Bool = false
|
||||
|
||||
@Published var indeterminateToastDescription: String? = nil
|
||||
@Published var indeterminateCancelAction: (() -> Void)? = nil
|
||||
@Published var showIndeterminateToast: Bool = false
|
||||
|
||||
public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) {
|
||||
if let newToastType {
|
||||
toastType = newToastType
|
||||
}
|
||||
|
||||
toastDescription = description
|
||||
}
|
||||
|
||||
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
||||
indeterminateToastDescription = description
|
||||
|
||||
if let cancelAction {
|
||||
indeterminateCancelAction = cancelAction
|
||||
}
|
||||
|
||||
if !showIndeterminateToast {
|
||||
showIndeterminateToast = true
|
||||
}
|
||||
}
|
||||
|
||||
public func hideIndeterminateToast() {
|
||||
showIndeterminateToast = false
|
||||
indeterminateToastDescription = ""
|
||||
indeterminateCancelAction = nil
|
||||
}
|
||||
|
||||
// Default the toast type to error since the majority of toasts are errors
|
||||
@Published var toastType: ToastType = .error
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HistoryActionsView: View {
|
||||
@EnvironmentObject var toastModel: ToastViewModel
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
|
||||
@State private var showActionSheet = false
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ struct HistoryActionsView: View {
|
|||
do {
|
||||
try PersistenceController.shared.batchDeleteHistory(range: deleteRange)
|
||||
} catch {
|
||||
toastModel.updateToastDescription("History delete error: \(error)")
|
||||
logManager.error("History delete error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HistoryButtonView: View {
|
||||
@EnvironmentObject var toastModel: ToastViewModel
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
@EnvironmentObject var navModel: NavigationViewModel
|
||||
@EnvironmentObject var debridManager: DebridManager
|
||||
@EnvironmentObject var pluginManager: PluginManager
|
||||
|
|
@ -40,7 +40,10 @@ struct HistoryButtonView: View {
|
|||
)
|
||||
}
|
||||
} else {
|
||||
toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.")
|
||||
logManager.error(
|
||||
"History: URL for name \(String(describing: entry.name)) is invalid",
|
||||
description: "URL invalid. Cannot load this history entry. Please delete it."
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct DefaultActionPickerView: View {
|
||||
@EnvironmentObject var toastModel: ToastViewModel
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
|
||||
let actionRequirement: ActionRequirement
|
||||
@Binding var defaultActionName: String?
|
||||
|
|
@ -38,8 +38,8 @@ struct DefaultActionPickerView: View {
|
|||
defaultActionName = action.name
|
||||
defaultActionList = actionListId
|
||||
} else {
|
||||
toastModel.updateToastDescription(
|
||||
"Default action error: This action doesn't have a corresponding plugin list! Please uninstall the action"
|
||||
logManager.error(
|
||||
"Default action: This action doesn't have a corresponding plugin list! Please uninstall the action"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SettingsAppVersionView: View {
|
||||
@EnvironmentObject var toastModel: ToastViewModel
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
|
||||
@State private var viewTask: Task<Void, Never>?
|
||||
@State private var releases: [Github.Release] = []
|
||||
|
|
@ -36,10 +36,10 @@ struct SettingsAppVersionView: View {
|
|||
if let fetchedReleases = try await Github().fetchReleases() {
|
||||
releases = fetchedReleases
|
||||
} else {
|
||||
toastModel.updateToastDescription("Github error: No releases found")
|
||||
logManager.error("Github: No releases found")
|
||||
}
|
||||
} catch {
|
||||
toastModel.updateToastDescription("Github error: \(error)")
|
||||
logManager.error("Github: \(error)")
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ struct SettingsDebridInfoView: View {
|
|||
} label: {
|
||||
Text(
|
||||
debridManager.enabledDebrids.contains(debridType)
|
||||
? "Logout"
|
||||
: (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login")
|
||||
? "Logout"
|
||||
: (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login")
|
||||
)
|
||||
.foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ struct SettingsKodiView: View {
|
|||
footer: Text("Enter your Kodi server's http URL here including the port.")
|
||||
) {
|
||||
TextField("http://...", text: $kodiUrl, onEditingChanged: { isFocused in
|
||||
if !isFocused && kodiUrl.last == "/" {
|
||||
if !isFocused, kodiUrl.last == "/" {
|
||||
kodiUrl = String(kodiUrl.dropLast())
|
||||
}
|
||||
})
|
||||
|
|
|
|||
33
Ferrite/Views/ComponentViews/Settings/SettingsLogView.swift
Normal file
33
Ferrite/Views/ComponentViews/Settings/SettingsLogView.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// SettingsLogView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 3/8/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsLogView: View {
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
List {
|
||||
ForEach(logManager.messageArray, id: \.self) { log in
|
||||
Text(log.toMessage())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Logs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsLogView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsLogView()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ struct ContentView: View {
|
|||
@EnvironmentObject var debridManager: DebridManager
|
||||
@EnvironmentObject var navModel: NavigationViewModel
|
||||
@EnvironmentObject var pluginManager: PluginManager
|
||||
@EnvironmentObject var toastModel: ToastViewModel
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
|
||||
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText: Bool = false
|
||||
|
||||
|
|
@ -44,7 +44,11 @@ struct ContentView: View {
|
|||
.listStyle(.insetGrouped)
|
||||
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 20 : -20)
|
||||
.overlay {
|
||||
if scrapingModel.searchResults.isEmpty, isSearching, scrapingModel.runningSearchTask == nil {
|
||||
if
|
||||
scrapingModel.searchResults.isEmpty,
|
||||
isSearching,
|
||||
scrapingModel.runningSearchTask == nil
|
||||
{
|
||||
Text("No results found")
|
||||
}
|
||||
}
|
||||
|
|
@ -85,16 +89,13 @@ struct ContentView: View {
|
|||
isSearching = true
|
||||
|
||||
let sources = pluginManager.fetchInstalledSources()
|
||||
await scrapingModel.scanSources(sources: sources, searchText: searchText)
|
||||
await scrapingModel.scanSources(
|
||||
sources: sources,
|
||||
searchText: searchText,
|
||||
debridManager: debridManager
|
||||
)
|
||||
|
||||
if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty {
|
||||
debridManager.clearIAValues()
|
||||
|
||||
let magnets = scrapingModel.searchResults.map(\.magnet)
|
||||
await debridManager.populateDebridIA(magnets)
|
||||
}
|
||||
|
||||
toastModel.hideIndeterminateToast()
|
||||
logManager.hideIndeterminateToast()
|
||||
scrapingModel.runningSearchTask = nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import SwiftUIX
|
|||
|
||||
struct MainView: View {
|
||||
@EnvironmentObject var navModel: NavigationViewModel
|
||||
@EnvironmentObject var toastModel: ToastViewModel
|
||||
@EnvironmentObject var logManager: LoggingManager
|
||||
@EnvironmentObject var debridManager: DebridManager
|
||||
@EnvironmentObject var scrapingModel: ScrapingViewModel
|
||||
@EnvironmentObject var backupManager: BackupManager
|
||||
|
|
@ -77,10 +77,19 @@ struct MainView: View {
|
|||
autoUpdateNotifs,
|
||||
Application.shared.osVersion.majorVersion >= Application.shared.minVersion.majorVersion
|
||||
{
|
||||
// MARK: If scope bar duplication happens, this may be the problem
|
||||
logManager.info("Ferrite started")
|
||||
|
||||
viewTask = Task {
|
||||
// Sleep for 2 seconds to allow for view layout and app init
|
||||
try? await Task.sleep(seconds: 2)
|
||||
|
||||
do {
|
||||
guard let latestRelease = try await Github().fetchLatestRelease() else {
|
||||
toastModel.updateToastDescription("Github error: No releases found")
|
||||
logManager.error(
|
||||
"Github: No releases found",
|
||||
description: "Github error: No releases found"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -91,8 +100,22 @@ struct MainView: View {
|
|||
showUpdateAlert.toggle()
|
||||
}
|
||||
} catch {
|
||||
toastModel.updateToastDescription("Github error: \(error)")
|
||||
let error = error as NSError
|
||||
|
||||
if error.code == -1009 {
|
||||
logManager.info(
|
||||
"Github: The connection is offline",
|
||||
description: "The connection is offline"
|
||||
)
|
||||
} else {
|
||||
logManager.error(
|
||||
"Github: \(error)",
|
||||
description: "A Github error was logged"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logManager.info("Github release updates checked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -159,13 +182,15 @@ struct MainView: View {
|
|||
.overlay {
|
||||
VStack {
|
||||
Spacer()
|
||||
if toastModel.showToast {
|
||||
if logManager.showToast {
|
||||
Group {
|
||||
switch toastModel.toastType {
|
||||
switch logManager.toastType {
|
||||
case .info:
|
||||
Text(toastModel.toastDescription ?? "This shouldn't be showing up... Contact the dev!")
|
||||
Text(logManager.toastDescription ?? "This shouldn't be showing up... Contact the dev!")
|
||||
case .warn:
|
||||
Text("Warn: \(logManager.toastDescription ?? "This shouldn't be showing up... Contact the dev!")")
|
||||
case .error:
|
||||
Text("Error: \(toastModel.toastDescription ?? "This shouldn't be showing up... Contact the dev!")")
|
||||
Text("Error: \(logManager.toastDescription ?? "This shouldn't be showing up... Contact the dev!")")
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
|
@ -176,17 +201,18 @@ struct MainView: View {
|
|||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
if toastModel.showIndeterminateToast {
|
||||
if logManager.showIndeterminateToast {
|
||||
VStack {
|
||||
Text(toastModel.indeterminateToastDescription ?? "Loading...")
|
||||
Text(logManager.indeterminateToastDescription ?? "Loading...")
|
||||
.lineLimit(1)
|
||||
|
||||
HStack {
|
||||
IndeterminateProgressView()
|
||||
|
||||
if let cancelAction = toastModel.indeterminateCancelAction {
|
||||
if let cancelAction = logManager.indeterminateCancelAction {
|
||||
Button("Cancel") {
|
||||
cancelAction()
|
||||
toastModel.hideIndeterminateToast()
|
||||
logManager.hideIndeterminateToast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -204,7 +230,7 @@ struct MainView: View {
|
|||
.foregroundColor(.clear)
|
||||
.frame(height: 60)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: toastModel.showToast || toastModel.showIndeterminateToast)
|
||||
.animation(.easeInOut(duration: 0.3), value: logManager.showToast || logManager.showIndeterminateToast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ struct SettingsView: View {
|
|||
@AppStorage("Actions.DefaultMagnetName") var defaultMagnetActionName: String?
|
||||
@AppStorage("Actions.DefaultMagnetList") var defaultMagnetActionList: String?
|
||||
|
||||
@AppStorage("Debug.ShowErrorToasts") var showErrorToasts = true
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
Form {
|
||||
|
|
@ -131,6 +133,11 @@ struct SettingsView: View {
|
|||
ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues")
|
||||
NavigationLink("About", destination: AboutView())
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Debug")) {
|
||||
NavigationLink("Logs", destination: SettingsLogView())
|
||||
Toggle("Show error alerts", isOn: $showErrorToasts)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $debridManager.showWebView) {
|
||||
LoginWebView(url: debridManager.authUrl ?? URL(string: "https://google.com")!)
|
||||
|
|
|
|||
Loading…
Reference in a new issue