From 1eef8202ca3cc6ff99276af3e3ca13b04f97055d Mon Sep 17 00:00:00 2001 From: kingbri Date: Mon, 25 Jul 2022 21:43:20 -0400 Subject: [PATCH] Ferrite: Decouple torrent sources These sources will be converted to be more flexible with JavaScript in the future. The source catalog is populated by adding a source list in settings then installing a source from the catalog. Sources can be enabled or disabled when using Ferrite. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 97 ++++++++++++++++++- Ferrite/API/RealDebridWrapper.swift | 3 +- .../Classes/TorrentSource+CoreDataClass.swift | 15 +++ .../TorrentSource+CoreDataProperties.swift | 31 ++++++ .../TorrentSourceUrl+CoreDataClass.swift | 15 +++ .../TorrentSourceUrl+CoreDataProperties.swift | 27 ++++++ .../FerriteDB.xcdatamodel/contents | 17 ++++ .../PersistenceController.swift | 81 ++++++++++++++++ Ferrite/FerriteApp.swift | 6 ++ .../{API => Models}/RealDebridModels.swift | 0 Ferrite/Models/SourceModels.swift | 29 ++++++ .../DebridManager.swift | 0 .../NavigationViewModel.swift | 0 .../ScrapingViewModel.swift | 75 +++++++------- Ferrite/ViewModels/SourceManager.swift | 76 +++++++++++++++ .../ToastViewModel.swift | 0 Ferrite/Views/ContentView.swift | 18 ++-- Ferrite/Views/MainView.swift | 8 ++ Ferrite/Views/SettingsView.swift | 6 ++ .../SettingsViews/SettingsSourceUrlView.swift | 58 +++++++++++ .../SettingsViews/SourceListEditorView.swift | 97 +++++++++++++++++++ Ferrite/Views/SourceListView.swift | 79 +++++++++++++++ 22 files changed, 686 insertions(+), 52 deletions(-) create mode 100644 Ferrite/DataManagement/Classes/TorrentSource+CoreDataClass.swift create mode 100644 Ferrite/DataManagement/Classes/TorrentSource+CoreDataProperties.swift create mode 100644 Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataClass.swift create mode 100644 Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataProperties.swift create mode 100644 Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents create mode 100644 Ferrite/DataManagement/PersistenceController.swift rename Ferrite/{API => Models}/RealDebridModels.swift (100%) create mode 100644 Ferrite/Models/SourceModels.swift rename Ferrite/{Models => ViewModels}/DebridManager.swift (100%) rename Ferrite/{Models => ViewModels}/NavigationViewModel.swift (100%) rename Ferrite/{Models => ViewModels}/ScrapingViewModel.swift (73%) create mode 100644 Ferrite/ViewModels/SourceManager.swift rename Ferrite/{Models => ViewModels}/ToastViewModel.swift (100%) create mode 100644 Ferrite/Views/SettingsViews/SettingsSourceUrlView.swift create mode 100644 Ferrite/Views/SettingsViews/SourceListEditorView.swift create mode 100644 Ferrite/Views/SourceListView.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index 0a3a738..2324834 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -7,9 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 0C0D50E1288DF7700035ECC8 /* TorrentSource+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50DF288DF7700035ECC8 /* TorrentSource+CoreDataClass.swift */; }; + 0C0D50E2288DF7700035ECC8 /* TorrentSource+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E0288DF7700035ECC8 /* TorrentSource+CoreDataProperties.swift */; }; + 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; }; + 0C0D50E7288DFF850035ECC8 /* SourceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourceListView.swift */; }; 0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; }; 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; }; 0C90E32C2888E5D000C0BC89 /* ActivityView in Frameworks */ = {isa = PBXBuildFile; productRef = 0C90E32B2888E5D000C0BC89 /* ActivityView */; }; + 0CA05457288EE58200850554 /* SettingsSourceUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsSourceUrlView.swift */; }; + 0CA05459288EE9E600850554 /* SourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* SourceManager.swift */; }; + 0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */; }; 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; }; 0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; }; 0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */; }; @@ -33,10 +40,21 @@ 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; }; 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; + 0CBC7702288DE4400054BE44 /* FerriteDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7700288DE4400054BE44 /* FerriteDB.xcdatamodeld */; }; + 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; }; + 0CE37ABA288E4AE900428C2D /* TorrentSourceUrl+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE37AB8288E4AE900428C2D /* TorrentSourceUrl+CoreDataClass.swift */; }; + 0CE37ABB288E4AE900428C2D /* TorrentSourceUrl+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE37AB9288E4AE900428C2D /* TorrentSourceUrl+CoreDataProperties.swift */; }; 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0C0D50DF288DF7700035ECC8 /* TorrentSource+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSource+CoreDataClass.swift"; sourceTree = ""; }; + 0C0D50E0288DF7700035ECC8 /* TorrentSource+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSource+CoreDataProperties.swift"; sourceTree = ""; }; + 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = ""; }; + 0C0D50E6288DFF850035ECC8 /* SourceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListView.swift; sourceTree = ""; }; + 0CA05456288EE58200850554 /* SettingsSourceUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSourceUrlView.swift; sourceTree = ""; }; + 0CA05458288EE9E600850554 /* SourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManager.swift; sourceTree = ""; }; + 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListEditorView.swift; sourceTree = ""; }; 0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = ""; }; 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagnetChoiceView.swift; sourceTree = ""; }; @@ -60,6 +78,10 @@ 0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = ""; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; + 0CBC7701288DE4400054BE44 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = ""; }; + 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + 0CE37AB8288E4AE900428C2D /* TorrentSourceUrl+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSourceUrl+CoreDataClass.swift"; sourceTree = ""; }; + 0CE37AB9288E4AE900428C2D /* TorrentSourceUrl+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSourceUrl+CoreDataProperties.swift"; sourceTree = ""; }; 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -78,11 +100,42 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0C0D50DE288DF72D0035ECC8 /* Classes */ = { + isa = PBXGroup; + children = ( + 0CE37AB8288E4AE900428C2D /* TorrentSourceUrl+CoreDataClass.swift */, + 0CE37AB9288E4AE900428C2D /* TorrentSourceUrl+CoreDataProperties.swift */, + 0C0D50DF288DF7700035ECC8 /* TorrentSource+CoreDataClass.swift */, + 0C0D50E0288DF7700035ECC8 /* TorrentSource+CoreDataProperties.swift */, + ); + path = Classes; + sourceTree = ""; + }; + 0C0D50E3288DFE6E0035ECC8 /* Models */ = { + isa = PBXGroup; + children = ( + 0CA148C4288903F000DE2211 /* RealDebridModels.swift */, + 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + 0CA0545C288F7CB200850554 /* SettingsViews */ = { + isa = PBXGroup; + children = ( + 0CA05456288EE58200850554 /* SettingsSourceUrlView.swift */, + 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */, + ); + path = SettingsViews; + sourceTree = ""; + }; 0CA148BA288903F000DE2211 /* Ferrite */ = { isa = PBXGroup; children = ( + 0CBC7703288DE7E90054BE44 /* DataManagement */, 0CA148F12889066000DE2211 /* API */, - 0CA148EF2889061600DE2211 /* Models */, + 0C0D50E3288DFE6E0035ECC8 /* Models */, + 0CA148EF2889061600DE2211 /* ViewModels */, 0CA148EE2889061200DE2211 /* Views */, 0CA148C8288903F000DE2211 /* Extensions */, 0CA148C5288903F000DE2211 /* Preview Content */, @@ -124,6 +177,7 @@ children = ( 0CA148F02889062700DE2211 /* RepresentableViews */, 0CA148C0288903F000DE2211 /* CommonViews */, + 0CA0545C288F7CB200850554 /* SettingsViews */, 0CA148D3288903F000DE2211 /* SearchResultsView.swift */, 0CA148D4288903F000DE2211 /* ContentView.swift */, 0CA148D1288903F000DE2211 /* MainView.swift */, @@ -132,19 +186,21 @@ 0CA148BC288903F000DE2211 /* LoginWebView.swift */, 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, + 0C0D50E6288DFF850035ECC8 /* SourceListView.swift */, ); path = Views; sourceTree = ""; }; - 0CA148EF2889061600DE2211 /* Models */ = { + 0CA148EF2889061600DE2211 /* ViewModels */ = { isa = PBXGroup; children = ( 0CA148CD288903F000DE2211 /* DebridManager.swift */, 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */, 0CA148CF288903F000DE2211 /* ToastViewModel.swift */, 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */, + 0CA05458288EE9E600850554 /* SourceManager.swift */, ); - path = Models; + path = ViewModels; sourceTree = ""; }; 0CA148F02889062700DE2211 /* RepresentableViews */ = { @@ -159,7 +215,6 @@ isa = PBXGroup; children = ( 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, - 0CA148C4288903F000DE2211 /* RealDebridModels.swift */, ); path = API; sourceTree = ""; @@ -180,6 +235,16 @@ name = Products; sourceTree = ""; }; + 0CBC7703288DE7E90054BE44 /* DataManagement */ = { + isa = PBXGroup; + children = ( + 0C0D50DE288DF72D0035ECC8 /* Classes */, + 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */, + 0CBC7700288DE4400054BE44 /* FerriteDB.xcdatamodeld */, + ); + path = DataManagement; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -262,9 +327,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0CE37ABB288E4AE900428C2D /* TorrentSourceUrl+CoreDataProperties.swift in Sources */, + 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */, 0CA148DB288903F000DE2211 /* NavView.swift in Sources */, + 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */, + 0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */, 0CA148E9288903F000DE2211 /* MainView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, + 0C0D50E7288DFF850035ECC8 /* SourceListView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, @@ -272,16 +342,22 @@ 0CA148E3288903F000DE2211 /* Task.swift in Sources */, 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */, 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */, + 0CE37ABA288E4AE900428C2D /* TorrentSourceUrl+CoreDataClass.swift in Sources */, 0CA148E6288903F000DE2211 /* WebView.swift in Sources */, 0CA148E2288903F000DE2211 /* Data.swift in Sources */, + 0C0D50E1288DF7700035ECC8 /* TorrentSource+CoreDataClass.swift in Sources */, + 0CA05459288EE9E600850554 /* SourceManager.swift in Sources */, + 0CA05457288EE58200850554 /* SettingsSourceUrlView.swift in Sources */, 0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */, 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */, 0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */, + 0CBC7702288DE4400054BE44 /* FerriteDB.xcdatamodeld in Sources */, 0CA148D9288903F000DE2211 /* CardView.swift in Sources */, 0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */, 0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */, + 0C0D50E2288DF7700035ECC8 /* TorrentSource+CoreDataProperties.swift in Sources */, 0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -545,6 +621,19 @@ productName = SwiftSoup; }; /* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 0CBC7700288DE4400054BE44 /* FerriteDB.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 0CBC7701288DE4400054BE44 /* FerriteDB.xcdatamodel */, + ); + currentVersion = 0CBC7701288DE4400054BE44 /* FerriteDB.xcdatamodel */; + path = FerriteDB.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 0CAF1C60286F5C0D00296F86 /* Project object */; } diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index fdcfa09..fa0c90d 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -146,7 +146,7 @@ public class RealDebrid: ObservableObject { keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken") keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") - let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn) + let accessTimestamp = Date().timeIntervalSince1970 + (Double(rawResponse.expiresIn)) UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") // Set AppStorage variable @@ -161,7 +161,6 @@ public class RealDebrid: ObservableObject { if Date().timeIntervalSince1970 > accessTokenStamp { do { if let refreshToken = keychain.get("RealDebrid.RefreshToken") { - print("Refresh token found") try await getTokens(deviceCode: refreshToken) } } catch { diff --git a/Ferrite/DataManagement/Classes/TorrentSource+CoreDataClass.swift b/Ferrite/DataManagement/Classes/TorrentSource+CoreDataClass.swift new file mode 100644 index 0000000..1f9ee6d --- /dev/null +++ b/Ferrite/DataManagement/Classes/TorrentSource+CoreDataClass.swift @@ -0,0 +1,15 @@ +// +// TorrentSource+CoreDataClass.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// +// + +import Foundation +import CoreData + +@objc(TorrentSource) +public class TorrentSource: NSManagedObject { + +} diff --git a/Ferrite/DataManagement/Classes/TorrentSource+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/TorrentSource+CoreDataProperties.swift new file mode 100644 index 0000000..7bdf37b --- /dev/null +++ b/Ferrite/DataManagement/Classes/TorrentSource+CoreDataProperties.swift @@ -0,0 +1,31 @@ +// +// TorrentSource+CoreDataProperties.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// +// + +import Foundation +import CoreData + + +extension TorrentSource { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TorrentSource") + } + + @NSManaged public var enabled: Bool + @NSManaged public var linkQuery: String + @NSManaged public var name: String? + @NSManaged public var rowQuery: String + @NSManaged public var sizeQuery: String? + @NSManaged public var titleQuery: String? + @NSManaged public var url: String + +} + +extension TorrentSource : Identifiable { + +} diff --git a/Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataClass.swift b/Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataClass.swift new file mode 100644 index 0000000..0f715ec --- /dev/null +++ b/Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataClass.swift @@ -0,0 +1,15 @@ +// +// TorrentSourceUrl+CoreDataClass.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// +// + +import Foundation +import CoreData + +@objc(TorrentSourceUrl) +public class TorrentSourceUrl: NSManagedObject { + +} diff --git a/Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataProperties.swift new file mode 100644 index 0000000..8c92975 --- /dev/null +++ b/Ferrite/DataManagement/Classes/TorrentSourceUrl+CoreDataProperties.swift @@ -0,0 +1,27 @@ +// +// TorrentSourceUrl+CoreDataProperties.swift +// Ferrite +// +// Created by Brian Dashore on 7/25/22. +// +// + +import Foundation +import CoreData + + +extension TorrentSourceUrl { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TorrentSourceUrl") + } + + @NSManaged public var urlString: String + @NSManaged public var repoName: String? + @NSManaged public var repoAuthor: String? + +} + +extension TorrentSourceUrl : Identifiable { + +} diff --git a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents new file mode 100644 index 0000000..84c1bc5 --- /dev/null +++ b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Ferrite/DataManagement/PersistenceController.swift b/Ferrite/DataManagement/PersistenceController.swift new file mode 100644 index 0000000..dd7bcb7 --- /dev/null +++ b/Ferrite/DataManagement/PersistenceController.swift @@ -0,0 +1,81 @@ +// +// PersistenceController.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// + +import CoreData + +// No iCloud until finalized sources +struct PersistenceController { + static let shared = PersistenceController() + + // Coredata storage + let container: NSPersistentContainer + + // Background context for writes + let backgroundContext: NSManagedObjectContext + + // Coredata load + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "FerriteDB") + + if inMemory { + container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") + } + + guard let description = container.persistentStoreDescriptions.first else { + fatalError("CoreData: Failed to find a persistent store description") + } + + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + + container.viewContext.automaticallyMergesChangesFromParent = true + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + try? container.viewContext.setQueryGenerationFrom(.current) + + backgroundContext = container.newBackgroundContext() + backgroundContext.automaticallyMergesChangesFromParent = true + backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + try? backgroundContext.setQueryGenerationFrom(.current) + + container.loadPersistentStores { _, error in + if let error = error { + fatalError("Error: \(error.localizedDescription)") + } + } + } + + func save(_ context: NSManagedObjectContext? = nil) { + let context = context ?? container.viewContext + + if context.hasChanges { + do { + try context.save() + } catch { + debugPrint("Error in CoreData saving! \(error.localizedDescription)") + } + } + } + + // By default, delete objects using the ViewContext unless specified + func delete(_ object: NSManagedObject, context: NSManagedObjectContext? = nil) { + let context = context ?? container.viewContext + + if context != container.viewContext { + let wrappedObject = try? context.existingObject(with: object.objectID) + + if let backgroundObject = wrappedObject { + context.delete(backgroundObject) + save(context) + + return + } + } + + container.viewContext.delete(object) + save() + } +} diff --git a/Ferrite/FerriteApp.swift b/Ferrite/FerriteApp.swift index 5171831..56cf03f 100644 --- a/Ferrite/FerriteApp.swift +++ b/Ferrite/FerriteApp.swift @@ -9,10 +9,13 @@ import SwiftUI @main struct FerriteApp: App { + let persistenceController = PersistenceController.shared + @StateObject var scrapingModel: ScrapingViewModel = .init() @StateObject var toastModel: ToastViewModel = .init() @StateObject var debridManager: DebridManager = .init() @StateObject var navigationModel: NavigationViewModel = .init() + @StateObject var sourceManager: SourceManager = .init() var body: some Scene { WindowGroup { @@ -20,11 +23,14 @@ struct FerriteApp: App { .onAppear { scrapingModel.toastModel = toastModel debridManager.toastModel = toastModel + sourceManager.toastModel = toastModel } .environmentObject(debridManager) .environmentObject(scrapingModel) .environmentObject(toastModel) .environmentObject(navigationModel) + .environmentObject(sourceManager) + .environment(\.managedObjectContext, persistenceController.container.viewContext) } } } diff --git a/Ferrite/API/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift similarity index 100% rename from Ferrite/API/RealDebridModels.swift rename to Ferrite/Models/RealDebridModels.swift diff --git a/Ferrite/Models/SourceModels.swift b/Ferrite/Models/SourceModels.swift new file mode 100644 index 0000000..964b361 --- /dev/null +++ b/Ferrite/Models/SourceModels.swift @@ -0,0 +1,29 @@ +// +// SourceModels.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// + +import Foundation + +public struct SourceJson: Codable { + let repoName: String? + let repoAuthor: String? + let sources: [TorrentSourceJson] + + enum CodingKeys: String, CodingKey { + case repoName = "name" + case repoAuthor = "author" + case sources = "sources" + } +} + +public struct TorrentSourceJson: Codable, Hashable { + let name: String? + let url: String + let rowQuery: String + let linkQuery: String + let titleQuery: String? + let sizeQuery: String? +} diff --git a/Ferrite/Models/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift similarity index 100% rename from Ferrite/Models/DebridManager.swift rename to Ferrite/ViewModels/DebridManager.swift diff --git a/Ferrite/Models/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift similarity index 100% rename from Ferrite/Models/NavigationViewModel.swift rename to Ferrite/ViewModels/NavigationViewModel.swift diff --git a/Ferrite/Models/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift similarity index 73% rename from Ferrite/Models/ScrapingViewModel.swift rename to Ferrite/ViewModels/ScrapingViewModel.swift index d74d29d..2372c03 100644 --- a/Ferrite/Models/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -17,45 +17,39 @@ public struct SearchResult: Hashable, Codable { let magnetHash: String? } -public struct TorrentSource: Hashable, Codable { - let name: String - let url: String - let rowQuery: String - let linkQuery: String - let titleQuery: String - let sizeQuery: String -} - class ScrapingViewModel: ObservableObject { @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false // Link the toast view model for single-directional communication var toastModel: ToastViewModel? - // Decopule this in the future - let sources = [ - // TorrentSource( - // name: "Nyaa", - // url: "https://nyaa.si", - // rowQuery: ".torrent-list tbody tr", - // linkQuery: "td:nth-child(3) > a:nth-child(2))", - // titleQuery: "td:nth-child(2) > a:last-child" - // ), - TorrentSource( - name: "AnimeTosho", - url: "https://animetosho.org/search?q=", - rowQuery: "#content .home_list_entry", - linkQuery: ".links > a:nth-child(4)", - titleQuery: ".link", - sizeQuery: ".size" - ) - ] - @Published var searchResults: [SearchResult] = [] @Published var debridHashes: [String] = [] @Published var searchText: String = "" @Published var selectedSearchResult: SearchResult? + @MainActor + public func scanSources(sources: FetchedResults) async { + if sources.isEmpty { + print("Sources empty") + } + + var tempResults: [SearchResult] = [] + + for source in sources { + if source.enabled { + guard let html = await fetchWebsiteHtml(source: source) else { + continue + } + + let sourceResults = await scrapeWebsite(source: source, html: html) + tempResults += sourceResults + } + } + + searchResults = tempResults + } + // Fetches the HTML body for the source website @MainActor public func fetchWebsiteHtml(source: TorrentSource) async -> String? { @@ -88,7 +82,7 @@ class ScrapingViewModel: ObservableObject { // Returns results to UI // Results must have a link and title, but other parameters aren't required @MainActor - public func scrapeWebsite(source: TorrentSource, html: String) async { + public func scrapeWebsite(source: TorrentSource, html: String) async -> [SearchResult] { var tempResults: [SearchResult] = [] var hashes: [String] = [] @@ -110,15 +104,24 @@ class ScrapingViewModel: ObservableObject { let magnetHash = fetchMagnetHash(magnetLink: href) - let title = try row.select(source.titleQuery).first() - let titleText = try title?.text() + var title: String? - let size = try row.select(source.sizeQuery).first() + // Some sources may use last-child, but SwiftSoup doesn't support it + if let titleQuery = source.titleQuery { + if titleQuery.contains("last-child") { + let newTitleQuery = titleQuery.replacingOccurrences(of: ":last-child", with: "") + title = try row.select(newTitleQuery).last()?.text() + } else { + title = try row.select(titleQuery).first()?.text() + } + } + + let size = try row.select(source.sizeQuery ?? "").first() let sizeText = try size?.text() let result = SearchResult( - title: titleText ?? "No title provided", - source: source.name, + title: title ?? "No title", + source: source.name ?? "N/A", size: sizeText ?? "?B", magnetLink: href, magnetHash: magnetHash @@ -132,10 +135,12 @@ class ScrapingViewModel: ObservableObject { tempResults.append(result) } - searchResults = tempResults + return tempResults } catch { toastModel?.toastDescription = "Error while scraping: \(error)" print("Error while scraping: \(error)") + + return [] } } diff --git a/Ferrite/ViewModels/SourceManager.swift b/Ferrite/ViewModels/SourceManager.swift new file mode 100644 index 0000000..8a139b8 --- /dev/null +++ b/Ferrite/ViewModels/SourceManager.swift @@ -0,0 +1,76 @@ +// +// SourceViewModel.swift +// Ferrite +// +// Created by Brian Dashore on 7/25/22. +// + +import Foundation + +public class SourceManager: ObservableObject { + var toastModel: ToastViewModel? + + @Published var availableSources: [TorrentSourceJson] = [] + + @MainActor + public func fetchSourcesFromUrl() async { + let sourceUrlRequest = TorrentSourceUrl.fetchRequest() + do { + let sourceUrls = try PersistenceController.shared.backgroundContext.fetch(sourceUrlRequest) + var tempSourceUrls: [TorrentSourceJson] = [] + + for sourceUrl in sourceUrls { + guard let url = URL(string: sourceUrl.urlString) else { + return + } + + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url)) + let sourceResponse = try JSONDecoder().decode(SourceJson.self, from: data) + + tempSourceUrls += sourceResponse.sources + } + + availableSources = tempSourceUrls + } catch { + print(error) + } + } + + public func installSource(sourceJson: TorrentSourceJson) { + let backgroundContext = PersistenceController.shared.backgroundContext + + // If a source exists, don't add the new one + if let name = sourceJson.name { + let existingSourceRequest = TorrentSource.fetchRequest() + existingSourceRequest.predicate = NSPredicate(format: "name == %@", name) + existingSourceRequest.fetchLimit = 1 + + let existingSource = try? backgroundContext.fetch(existingSourceRequest).first + if existingSource != nil { + Task { @MainActor in + toastModel?.toastDescription = "Could not install source \(sourceJson.name ?? "Unknown source") because it is already installed." + } + + return + } + } + + let newTorrentSource = TorrentSource(context: backgroundContext) + newTorrentSource.name = sourceJson.name + newTorrentSource.url = sourceJson.url + newTorrentSource.rowQuery = sourceJson.rowQuery + newTorrentSource.linkQuery = sourceJson.linkQuery + newTorrentSource.titleQuery = sourceJson.titleQuery + newTorrentSource.sizeQuery = sourceJson.sizeQuery + + newTorrentSource.enabled = true + + do { + try backgroundContext.save() + } catch { + Task{ @MainActor in + toastModel?.toastDescription = error.localizedDescription + } + } + } +} diff --git a/Ferrite/Models/ToastViewModel.swift b/Ferrite/ViewModels/ToastViewModel.swift similarity index 100% rename from Ferrite/Models/ToastViewModel.swift rename to Ferrite/ViewModels/ToastViewModel.swift diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index 801976b..4451096 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -13,6 +13,11 @@ struct ContentView: View { @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false + @FetchRequest( + entity: TorrentSource.entity(), + sortDescriptors: [] + ) var sources: FetchedResults + var body: some View { NavView { VStack { @@ -21,17 +26,8 @@ struct ContentView: View { .searchable(text: $scrapingModel.searchText) .onSubmit(of: .search) { Task { - for source in scrapingModel.sources { - guard let html = await scrapingModel.fetchWebsiteHtml(source: source) else { - continue - } - - await scrapingModel.scrapeWebsite(source: source, html: html) - - if realDebridEnabled { - await debridManager.populateDebridHashes(scrapingModel.searchResults) - } - } + await scrapingModel.scanSources(sources: sources) + await debridManager.populateDebridHashes(scrapingModel.searchResults) } } .navigationTitle("Search") diff --git a/Ferrite/Views/MainView.swift b/Ferrite/Views/MainView.swift index 4395214..d4d9747 100644 --- a/Ferrite/Views/MainView.swift +++ b/Ferrite/Views/MainView.swift @@ -9,6 +9,7 @@ import SwiftUI enum Tab { case search + case sources case settings } @@ -24,6 +25,13 @@ struct MainView: View { Label("Search", systemImage: "magnifyingglass") } .tag(Tab.search) + + SourceListView() + .tabItem { + Label("Sources", systemImage: "doc.text") + } + .tag(Tab.sources) + SettingsView() .tabItem { Label("Settings", systemImage: "gear") diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 62524d2..631e75a 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -10,6 +10,8 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var debridManager: DebridManager + let backgroundContext = PersistenceController.shared.backgroundContext + @AppStorage("RealDebrid.Enabled") var realDebridEnabled = false @State private var isProcessing = false @@ -36,6 +38,10 @@ struct SettingsView: View { } } } + + Section("Source management") { + NavigationLink("Source lists", destination: SettingsSourceListView()) + } } .sheet(isPresented: $debridManager.showWebView) { LoginWebView(url: URL(string: debridManager.realDebridAuthUrl)!) diff --git a/Ferrite/Views/SettingsViews/SettingsSourceUrlView.swift b/Ferrite/Views/SettingsViews/SettingsSourceUrlView.swift new file mode 100644 index 0000000..b1619da --- /dev/null +++ b/Ferrite/Views/SettingsViews/SettingsSourceUrlView.swift @@ -0,0 +1,58 @@ +// +// SettingsSourceUrlView.swift +// Ferrite +// +// Created by Brian Dashore on 7/25/22. +// + +import SwiftUI + +struct SettingsSourceListView: View { + let backgroundContext = PersistenceController.shared.backgroundContext + + @FetchRequest( + entity: TorrentSourceUrl.entity(), + sortDescriptors: [] + ) var sourceUrls: FetchedResults + + @State private var presentSourceSheet = false + + var body: some View { + List { + ForEach(sourceUrls, id: \.self) { sourceUrl in + Text(sourceUrl.repoName ?? "Unknown repo") + } + .onDelete { offsets in + for index in offsets { + if let sourceUrl = sourceUrls[safe: index] { + PersistenceController.shared.delete(sourceUrl, context: backgroundContext) + } + } + } + } + .sheet(isPresented: $presentSourceSheet) { + if #available(iOS 16, *) { + SourceListEditorView() + .presentationDetents([.medium]) + } else { + SourceListEditorView() + } + } + .navigationTitle("Source lists") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + presentSourceSheet.toggle() + } label: { + Image(systemName: "plus") + } + } + } + } +} + +struct SettingsSourceListView_Previews: PreviewProvider { + static var previews: some View { + SettingsSourceListView() + } +} diff --git a/Ferrite/Views/SettingsViews/SourceListEditorView.swift b/Ferrite/Views/SettingsViews/SourceListEditorView.swift new file mode 100644 index 0000000..77ddda4 --- /dev/null +++ b/Ferrite/Views/SettingsViews/SourceListEditorView.swift @@ -0,0 +1,97 @@ +// +// SourceListEditorView.swift +// Ferrite +// +// Created by Brian Dashore on 7/25/22. +// + +import SwiftUI + +struct SourceListEditorView: View { + @Environment(\.dismiss) var dismiss + + @EnvironmentObject var sourceManager: SourceManager + + let backgroundContext = PersistenceController.shared.backgroundContext + + @State private var sourceUrl = "" + @State private var urlErrorAlertText = "" + @State private var showUrlErrorAlert = false + + var body: some View { + NavView { + Form { + Section { + TextField("Enter URL", text: $sourceUrl) + .disableAutocorrection(true) + .keyboardType(.URL) + .autocapitalization(.none) + } + } + .alert(isPresented: $showUrlErrorAlert) { + Alert( + title: Text("Error"), + message: Text(urlErrorAlertText), + dismissButton: .default(Text("OK")) + ) + } + .navigationTitle("Editing source list") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + // Placing this function in the SourceManager causes the view to break on error. Place it here for now. + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + Task { + let backgroundContext = PersistenceController.shared.backgroundContext + + if sourceUrl.isEmpty || URL(string: sourceUrl) == nil { + urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly." + showUrlErrorAlert.toggle() + + return + } + + let sourceUrlRequest = TorrentSourceUrl.fetchRequest() + sourceUrlRequest.predicate = NSPredicate(format: "urlString == %@", sourceUrl) + sourceUrlRequest.fetchLimit = 1 + + if let existingSourceUrl = try? backgroundContext.fetch(sourceUrlRequest).first { + print("Existing source URL found") + PersistenceController.shared.delete(existingSourceUrl, context: backgroundContext) + } + + let newSourceUrl = TorrentSourceUrl(context: backgroundContext) + newSourceUrl.urlString = sourceUrl + + do { + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!)) + if let rawResponse = try? JSONDecoder().decode(SourceJson.self, from: data) { + newSourceUrl.repoName = rawResponse.repoName + } + + try backgroundContext.save() + } catch { + urlErrorAlertText = error.localizedDescription + showUrlErrorAlert.toggle() + } + + dismiss() + } + } + } + } + } + } +} + +struct SourceListEditorView_Previews: PreviewProvider { + static var previews: some View { + SourceListEditorView() + } +} diff --git a/Ferrite/Views/SourceListView.swift b/Ferrite/Views/SourceListView.swift new file mode 100644 index 0000000..0a0e0c5 --- /dev/null +++ b/Ferrite/Views/SourceListView.swift @@ -0,0 +1,79 @@ +// +// SourceListView.swift +// Ferrite +// +// Created by Brian Dashore on 7/24/22. +// + +import SwiftUI + +struct SourceListView: View { + @EnvironmentObject var sourceManager: SourceManager + + let backgroundContext = PersistenceController.shared.backgroundContext + + @FetchRequest( + entity: TorrentSource.entity(), + sortDescriptors: [] + ) var sources: FetchedResults + + @State private var availableSourceLength = 0 + + var body: some View { + NavView { + List { + if !sources.isEmpty { + Section("Installed") { + ForEach(sources, id: \.self) { source in + Toggle(isOn: Binding( + get: { source.enabled }, + set: { + source.enabled = $0 + PersistenceController.shared.save(backgroundContext) + })) { + Text(source.name ?? "Unknown Source") + } + } + .onDelete { offsets in + for index in offsets { + if let source = sources[safe: index] { + PersistenceController.shared.delete(source, context: backgroundContext) + } + } + } + } + } + + if sourceManager.availableSources.contains(where: { avail in + !sources.contains(where: { avail.name == $0.name }) + }) { + Section("Catalog") { + ForEach(sourceManager.availableSources, id: \.self) { availableSource in + if !sources.contains(where: { availableSource.name == $0.name }) { + HStack { + Text(availableSource.name ?? "Unnamed source") + + Spacer() + + Button("Install") { + sourceManager.installSource(sourceJson: availableSource) + } + } + } + } + } + } + } + .task { + await sourceManager.fetchSourcesFromUrl() + } + .navigationTitle("Sources") + } + } +} + +struct SourceListView_Previews: PreviewProvider { + static var previews: some View { + SourceListView() + } +}