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:
kingbri 2023-03-07 17:58:04 -05:00
parent a0632b0c16
commit 7202a95bb2
23 changed files with 840 additions and 431 deletions

View file

@ -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 */,

View 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())")
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -29,4 +29,9 @@ extension PluginManager {
enum PluginManagerError: Error {
case ListAddition(description: String)
}
struct AvailablePlugins {
let availableSources: [SourceJson]
let availableActions: [ActionJson]
}
}

View file

@ -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 {}
}

View file

@ -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)")
}
}
}

View file

@ -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()
}
}

View 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
}
}

View file

@ -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 {

View file

@ -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)")
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)")
}
}
}

View file

@ -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) {

View file

@ -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: {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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())
}
})

View 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()
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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")!)