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 <bdashore3@gmail.com>
This commit is contained in:
kingbri 2022-07-25 21:43:20 -04:00
parent ac42936afb
commit 1eef8202ca
22 changed files with 686 additions and 52 deletions

View file

@ -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 = "<group>"; };
0C0D50E0288DF7700035ECC8 /* TorrentSource+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSource+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* SourceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListView.swift; sourceTree = "<group>"; };
0CA05456288EE58200850554 /* SettingsSourceUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSourceUrlView.swift; sourceTree = "<group>"; };
0CA05458288EE9E600850554 /* SourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManager.swift; sourceTree = "<group>"; };
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListEditorView.swift; sourceTree = "<group>"; };
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; };
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagnetChoiceView.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
0CBC7701288DE4400054BE44 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = "<group>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CE37AB8288E4AE900428C2D /* TorrentSourceUrl+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSourceUrl+CoreDataClass.swift"; sourceTree = "<group>"; };
0CE37AB9288E4AE900428C2D /* TorrentSourceUrl+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TorrentSourceUrl+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
0C0D50E3288DFE6E0035ECC8 /* Models */ = {
isa = PBXGroup;
children = (
0CA148C4288903F000DE2211 /* RealDebridModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
0CA0545C288F7CB200850554 /* SettingsViews */ = {
isa = PBXGroup;
children = (
0CA05456288EE58200850554 /* SettingsSourceUrlView.swift */,
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */,
);
path = SettingsViews;
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
0CA148F02889062700DE2211 /* RepresentableViews */ = {
@ -159,7 +215,6 @@
isa = PBXGroup;
children = (
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0CA148C4288903F000DE2211 /* RealDebridModels.swift */,
);
path = API;
sourceTree = "<group>";
@ -180,6 +235,16 @@
name = Products;
sourceTree = "<group>";
};
0CBC7703288DE7E90054BE44 /* DataManagement */ = {
isa = PBXGroup;
children = (
0C0D50DE288DF72D0035ECC8 /* Classes */,
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */,
0CBC7700288DE4400054BE44 /* FerriteDB.xcdatamodeld */,
);
path = DataManagement;
sourceTree = "<group>";
};
/* 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 = "<group>";
versionGroupType = wrapper.xcdatamodel;
};
/* End XCVersionGroup section */
};
rootObject = 0CAF1C60286F5C0D00296F86 /* Project object */;
}

View file

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

View file

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

View file

@ -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<TorrentSource> {
return NSFetchRequest<TorrentSource>(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 {
}

View file

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

View file

@ -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<TorrentSourceUrl> {
return NSFetchRequest<TorrentSourceUrl>(entityName: "TorrentSourceUrl")
}
@NSManaged public var urlString: String
@NSManaged public var repoName: String?
@NSManaged public var repoAuthor: String?
}
extension TorrentSourceUrl : Identifiable {
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21256.2" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="TorrentSource" representedClassName="TorrentSource" syncable="YES">
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="linkQuery" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="rowQuery" attributeType="String"/>
<attribute name="sizeQuery" optional="YES" attributeType="String"/>
<attribute name="titleQuery" optional="YES" attributeType="String"/>
<attribute name="url" attributeType="String"/>
</entity>
<entity name="TorrentSourceUrl" representedClassName="TorrentSourceUrl" syncable="YES">
<attribute name="repoAuthor" optional="YES" attributeType="String"/>
<attribute name="repoName" optional="YES" attributeType="String"/>
<attribute name="urlString" attributeType="String"/>
</entity>
</model>

View file

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

View file

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

View file

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

View file

@ -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<TorrentSource>) 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 []
}
}

View file

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

View file

@ -13,6 +13,11 @@ struct ContentView: View {
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@FetchRequest(
entity: TorrentSource.entity(),
sortDescriptors: []
) var sources: FetchedResults<TorrentSource>
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")

View file

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

View file

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

View file

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

View file

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

View file

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