Sources: Add RSS, descriptions, and settings

RSS parsing has been added as a method to parse source since they're
easier on the website's end to parse.

Source settings have been added. The only current setting is the fetch
mode which selects which parser/scraper to use. By default, if an RSS
parser is found, it's selected.

A source now has info shown regarding versioning and authorship. A source
list's repository name and author string are now required.

Signed-off-by: kingbri <bdashore3@gmail.com>
This commit is contained in:
kingbri 2022-08-04 21:23:47 -04:00
parent 9ea7ab7b11
commit efecfa3236
19 changed files with 787 additions and 154 deletions

View file

@ -18,6 +18,9 @@
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */; }; 0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; }; 0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; }; 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; };
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; };
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; }; 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; };
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; }; 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; };
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */; }; 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */; };
@ -55,6 +58,8 @@
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; }; 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; };
0CF501F2289AE06A0099C785 /* SourceTracker+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF501F0289AE06A0099C785 /* SourceTracker+CoreDataClass.swift */; };
0CF501F3289AE06A0099C785 /* SourceTracker+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF501F1289AE06A0099C785 /* SourceTracker+CoreDataProperties.swift */; };
0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; }; 0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -67,6 +72,9 @@
0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = "<group>"; }; 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = "<group>"; };
0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = "<group>"; }; 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = "<group>"; };
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataClass.swift"; sourceTree = "<group>"; }; 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataClass.swift"; sourceTree = "<group>"; };
0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = "<group>"; }; 0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = "<group>"; };
@ -103,6 +111,8 @@
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; }; 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>"; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CF501F0289AE06A0099C785 /* SourceTracker+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceTracker+CoreDataClass.swift"; sourceTree = "<group>"; };
0CF501F1289AE06A0099C785 /* SourceTracker+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceTracker+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = "<group>"; }; 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -125,6 +135,10 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = { 0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */,
0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */,
0CF501F0289AE06A0099C785 /* SourceTracker+CoreDataClass.swift */,
0CF501F1289AE06A0099C785 /* SourceTracker+CoreDataProperties.swift */,
0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */, 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */,
0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */, 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */,
0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */, 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */,
@ -218,6 +232,7 @@
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */, 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */, 0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
0C0D50E6288DFF850035ECC8 /* SourceListView.swift */, 0C0D50E6288DFF850035ECC8 /* SourceListView.swift */,
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */, 0C32FB522890D19D002BD219 /* AboutView.swift */,
); );
path = Views; path = Views;
@ -365,15 +380,18 @@
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */, 0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */, 0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
0CA148DB288903F000DE2211 /* NavView.swift in Sources */, 0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */, 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */, 0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */,
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */, 0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */, 0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0CF501F2289AE06A0099C785 /* SourceTracker+CoreDataClass.swift in Sources */,
0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */, 0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* SourceListView.swift in Sources */, 0C0D50E7288DFF850035ECC8 /* SourceListView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */, 0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */,
@ -388,7 +406,9 @@
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */, 0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */,
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */, 0CA05459288EE9E600850554 /* SourceManager.swift in Sources */,
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */, 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsSourceUrlView.swift in Sources */, 0CA05457288EE58200850554 /* SettingsSourceUrlView.swift in Sources */,
0CF501F3289AE06A0099C785 /* SourceTracker+CoreDataProperties.swift in Sources */,
0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */, 0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */, 0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,

View file

@ -2,23 +2,31 @@
// Source+CoreDataProperties.swift // Source+CoreDataProperties.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/30/22. // Created by Brian Dashore on 8/3/22.
// //
// //
import CoreData
import Foundation import Foundation
import CoreData
public extension Source {
@nonobjc class func fetchRequest() -> NSFetchRequest<Source> { extension Source {
NSFetchRequest<Source>(entityName: "Source")
@nonobjc public class func fetchRequest() -> NSFetchRequest<Source> {
return NSFetchRequest<Source>(entityName: "Source")
} }
@NSManaged var name: String @NSManaged public var baseUrl: String
@NSManaged var enabled: Bool @NSManaged public var enabled: Bool
@NSManaged var version: String @NSManaged public var name: String
@NSManaged var baseUrl: String @NSManaged public var author: String?
@NSManaged var htmlParser: SourceHtmlParser? @NSManaged public var preferredParser: Int16
@NSManaged public var version: String
@NSManaged public var htmlParser: SourceHtmlParser?
@NSManaged public var rssParser: SourceRssParser?
} }
extension Source: Identifiable {} extension Source : Identifiable {
}

View file

@ -15,6 +15,7 @@ public extension SourceComplexQuery {
} }
@NSManaged var attribute: String @NSManaged var attribute: String
@NSManaged var lookupAttribute: String?
@NSManaged var query: String @NSManaged var query: String
@NSManaged var regex: String? @NSManaged var regex: String?
} }

View file

@ -2,25 +2,49 @@
// SourceHtmlParser+CoreDataProperties.swift // SourceHtmlParser+CoreDataProperties.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 8/2/22. // Created by Brian Dashore on 8/3/22.
// //
// //
import CoreData
import Foundation import Foundation
import CoreData
public extension SourceHtmlParser {
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceHtmlParser> { extension SourceHtmlParser {
NSFetchRequest<SourceHtmlParser>(entityName: "SourceHtmlParser")
@nonobjc public class func fetchRequest() -> NSFetchRequest<SourceHtmlParser> {
return NSFetchRequest<SourceHtmlParser>(entityName: "SourceHtmlParser")
} }
@NSManaged var rows: String @NSManaged public var rows: String
@NSManaged var searchUrl: String @NSManaged public var searchUrl: String
@NSManaged var magnet: SourceMagnet? @NSManaged public var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source? @NSManaged public var parentSource: Source?
@NSManaged var size: SourceSize? @NSManaged public var seedLeech: SourceSeedLeech?
@NSManaged var title: SourceTitle? @NSManaged public var size: SourceSize?
@NSManaged var seedLeech: SourceSeedLeech? @NSManaged public var title: SourceTitle?
@NSManaged public var magnetHash: SourceMagnetHash?
@NSManaged public var trackers: NSSet?
} }
extension SourceHtmlParser: Identifiable {} // MARK: Generated accessors for trackers
extension SourceHtmlParser {
@objc(addTrackersObject:)
@NSManaged public func addToTrackers(_ value: SourceTracker)
@objc(removeTrackersObject:)
@NSManaged public func removeFromTrackers(_ value: SourceTracker)
@objc(addTrackers:)
@NSManaged public func addToTrackers(_ values: NSSet)
@objc(removeTrackers:)
@NSManaged public func removeFromTrackers(_ values: NSSet)
}
extension SourceHtmlParser : Identifiable {
}

View file

@ -14,8 +14,8 @@ public extension SourceList {
NSFetchRequest<SourceList>(entityName: "SourceList") NSFetchRequest<SourceList>(entityName: "SourceList")
} }
@NSManaged var repoAuthor: String? @NSManaged var author: String
@NSManaged var repoName: String? @NSManaged var name: String
@NSManaged var urlString: String @NSManaged var urlString: String
} }

View file

@ -0,0 +1,15 @@
//
// SourceRssParser+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
//
//
import Foundation
import CoreData
@objc(SourceRssParser)
public class SourceRssParser: NSManagedObject {
}

View file

@ -0,0 +1,56 @@
//
// SourceRssParser+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
//
//
import Foundation
import CoreData
extension SourceRssParser {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SourceRssParser> {
return NSFetchRequest<SourceRssParser>(entityName: "SourceRssParser")
}
@NSManaged public var items: String
@NSManaged public var searchUrl: String
@NSManaged public var rssUrl: String?
@NSManaged public var parentSource: Source?
@NSManaged public var trackers: NSSet?
@NSManaged public var magnetLink: SourceMagnetLink?
@NSManaged public var size: SourceSize?
@NSManaged public var title: SourceTitle?
@NSManaged public var seedLeech: SourceSeedLeech?
@NSManaged public var magnetHash: SourceMagnetHash?
var trackerArray: [SourceTracker] {
let trackerSet = trackers as? Set<SourceTracker> ?? []
return trackerSet.map { $0 }
}
}
// MARK: Generated accessors for trackers
extension SourceRssParser {
@objc(addTrackersObject:)
@NSManaged public func addToTrackers(_ value: SourceTracker)
@objc(removeTrackersObject:)
@NSManaged public func removeFromTrackers(_ value: SourceTracker)
@objc(addTrackers:)
@NSManaged public func addToTrackers(_ values: NSSet)
@objc(removeTrackers:)
@NSManaged public func removeFromTrackers(_ values: NSSet)
}
extension SourceRssParser : Identifiable {
}

View file

@ -20,6 +20,7 @@ public extension SourceSeedLeech {
@NSManaged var seederRegex: String? @NSManaged var seederRegex: String?
@NSManaged var seeders: String? @NSManaged var seeders: String?
@NSManaged var attribute: String @NSManaged var attribute: String
@NSManaged var lookupAttribute: String?
@NSManaged var parentParser: SourceHtmlParser? @NSManaged var parentParser: SourceHtmlParser?
} }

View file

@ -0,0 +1,15 @@
//
// SourceTracker+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
//
//
import Foundation
import CoreData
@objc(SourceTracker)
public class SourceTracker: NSManagedObject {
}

View file

@ -0,0 +1,27 @@
//
// SourceTracker+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
//
//
import Foundation
import CoreData
extension SourceTracker {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SourceTracker> {
return NSFetchRequest<SourceTracker>(entityName: "SourceTracker")
}
@NSManaged public var urlString: String
@NSManaged public var parentRssParser: SourceRssParser?
@NSManaged public var parentHtmlParser: SourceHtmlParser?
}
extension SourceTracker : Identifiable {
}

View file

@ -1,48 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21271" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21271" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Source" representedClassName="Source" syncable="YES"> <entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="baseUrl" attributeType="String" defaultValueString=""/> <attribute name="baseUrl" attributeType="String" defaultValueString=""/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="version" attributeType="String" defaultValueString=""/> <attribute name="version" attributeType="String" defaultValueString=""/>
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/> <relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
<relationship name="rssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
</entity> </entity>
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES"> <entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString=""/> <attribute name="attribute" attributeType="String" defaultValueString="text"/>
<attribute name="lookupAttribute" optional="YES" attributeType="String"/>
<attribute name="query" attributeType="String" defaultValueString=""/> <attribute name="query" attributeType="String" defaultValueString=""/>
<attribute name="regex" optional="YES" attributeType="String"/> <attribute name="regex" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES"> <entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
<attribute name="rows" attributeType="String" defaultValueString=""/> <attribute name="rows" attributeType="String" defaultValueString=""/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/> <attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<relationship name="magnet" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnet" inverseName="parentParser" inverseEntity="SourceMagnet"/> <relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/> <relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSeedLeech" inverseName="parentParser" inverseEntity="SourceSeedLeech"/> <relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentParser" inverseEntity="SourceSize"/> <relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentParser" inverseEntity="SourceTitle"/> <relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentHtmlParser" inverseEntity="SourceTitle"/>
<relationship name="trackers" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="SourceTracker" inverseName="parentHtmlParser" inverseEntity="SourceTracker"/>
</entity> </entity>
<entity name="SourceList" representedClassName="SourceList" syncable="YES"> <entity name="SourceList" representedClassName="SourceList" syncable="YES">
<attribute name="repoAuthor" optional="YES" attributeType="String"/> <attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="repoName" optional="YES" attributeType="String"/> <attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="urlString" attributeType="String" defaultValueString=""/> <attribute name="urlString" attributeType="String" defaultValueString=""/>
</entity> </entity>
<entity name="SourceMagnet" representedClassName="SourceMagnet" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class"> <entity name="SourceMagnetHash" representedClassName="SourceMagnetHash" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetHash" inverseEntity="SourceHtmlParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetHash" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceMagnetLink" representedClassName="SourceMagnetLink" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<attribute name="externalLinkQuery" optional="YES" attributeType="String"/> <attribute name="externalLinkQuery" optional="YES" attributeType="String"/>
<relationship name="parentParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnet" inverseEntity="SourceHtmlParser"/> <relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetLink" inverseEntity="SourceHtmlParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentRssParser" inverseEntity="SourceTitle"/>
<relationship name="trackers" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="SourceTracker" inverseName="parentRssParser" inverseEntity="SourceTracker"/>
</entity> </entity>
<entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES"> <entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString=""/> <attribute name="attribute" attributeType="String" defaultValueString=""/>
<attribute name="combined" optional="YES" attributeType="String"/> <attribute name="combined" optional="YES" attributeType="String"/>
<attribute name="leecherRegex" optional="YES" attributeType="String"/> <attribute name="leecherRegex" optional="YES" attributeType="String"/>
<attribute name="leechers" optional="YES" attributeType="String"/> <attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="lookupAttribute" optional="YES" attributeType="String"/>
<attribute name="seederRegex" optional="YES" attributeType="String"/> <attribute name="seederRegex" optional="YES" attributeType="String"/>
<attribute name="seeders" optional="YES" attributeType="String"/> <attribute name="seeders" optional="YES" attributeType="String"/>
<relationship name="parentParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/> <relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="seedLeech" inverseEntity="SourceRssParser"/>
</entity> </entity>
<entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class"> <entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/> <relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="size" inverseEntity="SourceRssParser"/>
</entity> </entity>
<entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class"> <entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/> <relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="title" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceTracker" representedClassName="SourceTracker" syncable="YES">
<attribute name="urlString" attributeType="String" defaultValueString=""/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="trackers" inverseEntity="SourceHtmlParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="trackers" inverseEntity="SourceRssParser"/>
</entity> </entity>
</model> </model>

View file

@ -8,36 +8,52 @@
import Foundation import Foundation
public struct SourceListJson: Codable { public struct SourceListJson: Codable {
let repoName: String? let name: String
let repoAuthor: String? let author: String
let sources: [SourceJson] var sources: [SourceJson]
enum CodingKeys: String, CodingKey {
case repoName = "name"
case repoAuthor = "author"
case sources
}
} }
public struct SourceJson: Codable, Hashable { public struct SourceJson: Codable, Hashable {
let name: String let name: String
let version: String let version: String
let baseUrl: String let baseUrl: String
var author: String?
let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson? let htmlParser: SourceHtmlParserJson?
} }
public enum SourcePreferredParser: Int16, CaseIterable {
case none = 0
case scraping = 1
case rss = 2
case siteApi = 3
}
public struct SourceRssParserJson: Codable, Hashable {
let rssUrl: String?
let searchUrl: String
let items: String
let magnetHash: SouceComplexQueryJson?
let magnetLink: SouceComplexQueryJson?
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let sl: SourceSLJson?
let trackers: [String]?
}
public struct SourceHtmlParserJson: Codable, Hashable { public struct SourceHtmlParserJson: Codable, Hashable {
let searchUrl: String let searchUrl: String
let rows: String let rows: String
let magnet: SourceMagnetJson let magnet: SourceMagnetJson
let title: SouceComplexQuery? let title: SouceComplexQueryJson?
let size: SouceComplexQuery? let size: SouceComplexQueryJson?
let sl: SourceSLJson? let sl: SourceSLJson?
} }
public struct SouceComplexQuery: Codable, Hashable { public struct SouceComplexQueryJson: Codable, Hashable {
let query: String let query: String
let attribute: String let lookupAttribute: String?
let attribute: String?
let regex: String? let regex: String?
} }
@ -52,7 +68,8 @@ public struct SourceSLJson: Codable, Hashable {
let seeders: String? let seeders: String?
let leechers: String? let leechers: String?
let combined: String? let combined: String?
let attribute: String let attribute: String?
let lookupAttribute: String?
let seederRegex: String? let seederRegex: String?
let leecherRegex: String? let leecherRegex: String?
} }

View file

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
class NavigationViewModel: ObservableObject { class NavigationViewModel: ObservableObject {
// Used between SearchResultsView and MagnetChoiceView
enum ChoiceSheetType: Identifiable { enum ChoiceSheetType: Identifiable {
var id: Int { var id: Int {
hashValue hashValue
@ -18,4 +19,8 @@ class NavigationViewModel: ObservableObject {
} }
@Published var currentChoiceSheet: ChoiceSheetType? @Published var currentChoiceSheet: ChoiceSheetType?
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
@Published var selectedSource: Source?
} }

View file

@ -25,6 +25,7 @@ class ScrapingViewModel: ObservableObject {
// Link the toast view model for single-directional communication // Link the toast view model for single-directional communication
var toastModel: ToastViewModel? var toastModel: ToastViewModel?
let byteCountFormatter: ByteCountFormatter = .init()
@Published var searchResults: [SearchResult] = [] @Published var searchResults: [SearchResult] = []
@Published var searchText: String = "" @Published var searchText: String = ""
@ -42,22 +43,56 @@ class ScrapingViewModel: ObservableObject {
for source in sources { for source in sources {
if source.enabled { if source.enabled {
if let htmlParser = source.htmlParser { // Default to HTML scraping
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
toastModel?.toastDescription = "Could not process search query, invalid characters present."
print("Could not process search query, invalid characters present")
continue switch preferredParser {
case .scraping:
if let htmlParser = source.htmlParser {
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
toastModel?.toastDescription = "Could not process search query, invalid characters present."
print("Could not process search query, invalid characters present")
continue
}
let urlString = source.baseUrl + htmlParser.searchUrl.replacingOccurrences(of: "{query}", with: encodedQuery)
guard let html = await fetchWebsiteData(urlString: urlString) else {
continue
}
let sourceResults = await scrapeHtml(source: source, html: html)
tempResults += sourceResults
} }
case .rss:
if let rssParser = source.rssParser {
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
toastModel?.toastDescription = "Could not process search query, invalid characters present."
print("Could not process search query, invalid characters present")
let urlString = source.baseUrl + htmlParser.searchUrl.replacingOccurrences(of: "{query}", with: encodedQuery) continue
}
guard let html = await fetchWebsiteHtml(urlString: urlString) else { let replacedSearchUrl = rssParser.searchUrl.replacingOccurrences(of: "{query}", with: encodedQuery)
continue
// If there is an RSS base URL, use that instead
var urlString: String
if let rssUrl = rssParser.rssUrl {
urlString = rssUrl + replacedSearchUrl
} else {
urlString = source.baseUrl + replacedSearchUrl
}
guard let rss = await fetchWebsiteData(urlString: urlString) else {
continue
}
let sourceResults = scrapeRss(source: source, rss: rss)
tempResults += sourceResults
} }
case .siteApi, .none:
let sourceResults = await scrapeWebsite(source: source, html: html) continue
tempResults += sourceResults
} }
} }
} }
@ -65,9 +100,9 @@ class ScrapingViewModel: ObservableObject {
searchResults = tempResults searchResults = tempResults
} }
// Fetches the HTML for a URL // Fetches the data for a URL
@MainActor @MainActor
public func fetchWebsiteHtml(urlString: String) async -> String? { public func fetchWebsiteData(urlString: String) async -> String? {
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
toastModel?.toastDescription = "Source doesn't contain a valid URL, contact the source dev!" toastModel?.toastDescription = "Source doesn't contain a valid URL, contact the source dev!"
print("Source doesn't contain a valid URL, contact the source dev!") print("Source doesn't contain a valid URL, contact the source dev!")
@ -80,17 +115,139 @@ class ScrapingViewModel: ObservableObject {
let html = String(data: data, encoding: .ascii) let html = String(data: data, encoding: .ascii)
return html return html
} catch { } catch {
toastModel?.toastDescription = "Error in fetching HTML \(error)" toastModel?.toastDescription = "Error in fetching data \(error)"
print("Error in fetching HTML \(error)") print("Error in fetching data \(error)")
return nil return nil
} }
} }
// Returns results to UI // RSS feed scraper
// Results must have a link and title, but other parameters aren't required
@MainActor @MainActor
public func scrapeWebsite(source: Source, html: String) async -> [SearchResult] { public func scrapeRss(source: Source, rss: String) -> [SearchResult] {
guard let rssParser = source.rssParser else {
return []
}
var tempResults: [SearchResult] = []
var items = Elements()
do {
let document = try SwiftSoup.parse(rss, "", Parser.xmlParser())
items = try document.getElementsByTag("item")
} catch {
toastModel?.toastDescription = "RSS scraping error, couldn't fetch items: \(error)"
print("RSS scraping error, couldn't fetch items: \(error)")
return []
}
for item in items {
// Parse magnet link or translate hash
var magnetHash: String?
if let magnetHashParser = rssParser.magnetHash {
magnetHash = try? runRssComplexQuery(
item: item,
query: magnetHashParser.query,
attribute: magnetHashParser.attribute,
lookupAttribute: magnetHashParser.lookupAttribute,
regexString: magnetHashParser.regex
)
}
var title: String?
if let titleParser = rssParser.title {
title = try? runRssComplexQuery(
item: item,
query: titleParser.query,
attribute: titleParser.attribute,
lookupAttribute: titleParser.lookupAttribute,
regexString: titleParser.regex
)
}
var link: String?
if let magnetLinkParser = rssParser.magnetLink {
link = try? runRssComplexQuery(
item: item,
query: magnetLinkParser.query,
attribute: magnetLinkParser.attribute,
lookupAttribute: magnetLinkParser.lookupAttribute,
regexString: magnetLinkParser.regex
)
} else if let magnetHash = magnetHash {
link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: rssParser.trackerArray)
} else {
continue
}
guard let href = link, href.starts(with: "magnet:") else {
continue
}
if magnetHash == nil {
magnetHash = fetchMagnetHash(magnetLink: href)
}
var size: String?
if let sizeParser = rssParser.size {
size = try? runRssComplexQuery(
item: item,
query: sizeParser.query,
attribute: sizeParser.attribute,
lookupAttribute: sizeParser.lookupAttribute,
regexString: sizeParser.regex
)
}
if let sizeString = size, let sizeInt = Int64(sizeString) {
size = byteCountFormatter.string(fromByteCount: sizeInt)
}
var seeders: String?
var leechers: String?
if let seederLeecher = rssParser.seedLeech {
if let seederQuery = seederLeecher.seeders {
seeders = try? runRssComplexQuery(
item: item,
query: seederQuery,
attribute: seederLeecher.attribute,
lookupAttribute: seederLeecher.lookupAttribute,
regexString: seederLeecher.seederRegex
)
}
if let leecherQuery = seederLeecher.leechers {
leechers = try? runRssComplexQuery(
item: item,
query: leecherQuery,
attribute: seederLeecher.attribute,
lookupAttribute: seederLeecher.lookupAttribute,
regexString: seederLeecher.leecherRegex
)
}
}
let result = SearchResult(
title: title ?? "No title",
source: source.name,
size: size ?? "",
magnetLink: href,
magnetHash: magnetHash,
seeders: seeders,
leechers: leechers
)
tempResults.append(result)
}
return tempResults
}
// HTML scraper
@MainActor
public func scrapeHtml(source: Source, html: String) async -> [SearchResult] {
guard let htmlParser = source.htmlParser else { guard let htmlParser = source.htmlParser else {
return [] return []
} }
@ -115,7 +272,7 @@ class ScrapingViewModel: ObservableObject {
// Fetches the magnet link // Fetches the magnet link
// If the magnet is located on an external page, fetch the external page and grab the magnet link // If the magnet is located on an external page, fetch the external page and grab the magnet link
// External page fetching affects source performance // External page fetching affects source performance
guard let magnetParser = htmlParser.magnet else { guard let magnetParser = htmlParser.magnetLink else {
continue continue
} }
@ -125,7 +282,7 @@ class ScrapingViewModel: ObservableObject {
continue continue
} }
guard let magnetHtml = await fetchWebsiteHtml(urlString: source.baseUrl + externalMagnetLink) else { guard let magnetHtml = await fetchWebsiteData(urlString: source.baseUrl + externalMagnetLink) else {
continue continue
} }
@ -140,7 +297,7 @@ class ScrapingViewModel: ObservableObject {
href = try linkResult.attr(magnetParser.attribute) href = try linkResult.attr(magnetParser.attribute)
} }
} else { } else {
guard let link = try runComplexQuery( guard let link = try runHtmlComplexQuery(
row: row, row: row,
query: magnetParser.query, query: magnetParser.query,
attribute: magnetParser.attribute, attribute: magnetParser.attribute,
@ -162,7 +319,7 @@ class ScrapingViewModel: ObservableObject {
// Fetches the episode/movie title // Fetches the episode/movie title
var title: String? var title: String?
if let titleParser = htmlParser.title { if let titleParser = htmlParser.title {
title = try? runComplexQuery( title = try? runHtmlComplexQuery(
row: row, row: row,
query: titleParser.query, query: titleParser.query,
attribute: titleParser.attribute, attribute: titleParser.attribute,
@ -171,9 +328,10 @@ class ScrapingViewModel: ObservableObject {
} }
// Fetches the torrent's size // Fetches the torrent's size
// TODO: Add int translation
var size: String? var size: String?
if let sizeParser = htmlParser.size { if let sizeParser = htmlParser.size {
size = try? runComplexQuery( size = try? runHtmlComplexQuery(
row: row, row: row,
query: sizeParser.query, query: sizeParser.query,
attribute: sizeParser.attribute, attribute: sizeParser.attribute,
@ -186,7 +344,7 @@ class ScrapingViewModel: ObservableObject {
var leechers: String? var leechers: String?
if let seederLeecher = htmlParser.seedLeech { if let seederLeecher = htmlParser.seedLeech {
if let combinedQuery = seederLeecher.combined { if let combinedQuery = seederLeecher.combined {
if let combinedString = try? runComplexQuery( if let combinedString = try? runHtmlComplexQuery(
row: row, row: row,
query: combinedQuery, query: combinedQuery,
attribute: seederLeecher.attribute, attribute: seederLeecher.attribute,
@ -202,7 +360,7 @@ class ScrapingViewModel: ObservableObject {
} }
} else { } else {
if let seederQuery = seederLeecher.seeders { if let seederQuery = seederLeecher.seeders {
seeders = try? runComplexQuery( seeders = try? runHtmlComplexQuery(
row: row, row: row,
query: seederQuery, query: seederQuery,
attribute: seederLeecher.attribute, attribute: seederLeecher.attribute,
@ -211,7 +369,7 @@ class ScrapingViewModel: ObservableObject {
} }
if let leecherQuery = seederLeecher.seeders { if let leecherQuery = seederLeecher.seeders {
leechers = try? runComplexQuery( leechers = try? runHtmlComplexQuery(
row: row, row: row,
query: leecherQuery, query: leecherQuery,
attribute: seederLeecher.attribute, attribute: seederLeecher.attribute,
@ -243,7 +401,8 @@ class ScrapingViewModel: ObservableObject {
return tempResults return tempResults
} }
func runComplexQuery(row: Element, query: String, attribute: String, regexString: String?) throws -> String? { // Complex query parsing for HTML scraping
func runHtmlComplexQuery(row: Element, query: String, attribute: String, regexString: String?) throws -> String? {
var parsedValue: String? var parsedValue: String?
let result = try row.select(query).first() let result = try row.select(query).first()
@ -258,7 +417,36 @@ class ScrapingViewModel: ObservableObject {
// A capture group must be used in the provided regex // A capture group must be used in the provided regex
if let regexString = regexString, if let regexString = regexString,
let parsedValue = parsedValue, let parsedValue = parsedValue,
let regexValue = try Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
{
return regexValue
} else {
return parsedValue
}
}
// Complex query parsing for RSS scraping
func runRssComplexQuery(item: Element, query: String, attribute: String, lookupAttribute: String?, regexString: String?) throws -> String? {
var parsedValue: String?
switch attribute {
case "text":
parsedValue = try item.getElementsByTag(query).first()?.text()
default:
// If there's a key/value to lookup the attribute with, query it. Othewise assume the value is in the same attribute
if let lookupAttribute = lookupAttribute {
let containerElement = try item.getElementsByAttributeValue(lookupAttribute, query).first()
parsedValue = try containerElement?.attr(attribute)
} else {
let containerElement = try item.getElementsByAttribute(attribute).first()
parsedValue = try containerElement?.attr(attribute)
}
}
// A capture group must be used in the provided regex
if let regexString = regexString,
let parsedValue = parsedValue,
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
{ {
return regexValue return regexValue
} else { } else {
@ -284,4 +472,46 @@ class ScrapingViewModel: ObservableObject {
return String(magnetHash).lowercased() return String(magnetHash).lowercased()
} }
} }
func parseSizeString(sizeString: String) -> String? {
// Test if the string can be a full integer
guard let size = Int(sizeString) else {
return nil
}
let length = sizeString.count
if length > 9 {
// This is a GB
return String("\(Double(size) / 1e9) GB")
} else if length > 6 {
// This is a MB
return String("\(Double(size) / 1e6) MB")
} else if length > 3 {
// This is a KB
return String("\(Double(size) / 1e3) KB")
} else {
return nil
}
}
public func generateMagnetLink(magnetHash: String, title: String?, trackers: [SourceTracker]) -> String {
var magnetLinkArray: [String] = ["magnet:?xt=urn:btih:"]
magnetLinkArray.append(magnetHash)
if let title = title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
magnetLinkArray.append("&dn=\(encodedTitle)")
}
for tracker in trackers {
if URL(string: tracker.urlString) != nil,
let encodedUrlString = tracker.urlString.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
{
magnetLinkArray.append("&tr=\(encodedUrlString)")
}
}
return magnetLinkArray.joined()
}
} }

View file

@ -18,18 +18,22 @@ public class SourceManager: ObservableObject {
@MainActor @MainActor
public func fetchSourcesFromUrl() async { public func fetchSourcesFromUrl() async {
let sourceUrlRequest = SourceList.fetchRequest() let sourceListRequest = SourceList.fetchRequest()
do { do {
let sourceUrls = try PersistenceController.shared.backgroundContext.fetch(sourceUrlRequest) let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest)
var tempSourceUrls: [SourceJson] = [] var tempSourceUrls: [SourceJson] = []
for sourceUrl in sourceUrls { for sourceList in sourceLists {
guard let url = URL(string: sourceUrl.urlString) else { guard let url = URL(string: sourceList.urlString) else {
return return
} }
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url)) let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
let sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data) var sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
for index in sourceResponse.sources.indices {
sourceResponse.sources[index].author = sourceList.author
}
tempSourceUrls += sourceResponse.sources tempSourceUrls += sourceResponse.sources
} }
@ -61,55 +65,23 @@ public class SourceManager: ObservableObject {
newSource.name = sourceJson.name newSource.name = sourceJson.name
newSource.version = sourceJson.version newSource.version = sourceJson.version
newSource.baseUrl = sourceJson.baseUrl newSource.baseUrl = sourceJson.baseUrl
newSource.author = sourceJson.author
// Adds an RSS parser if present
if let rssParserJson = sourceJson.rssParser {
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
}
// Adds an HTML parser if present // Adds an HTML parser if present
if let htmlParserJson = sourceJson.htmlParser { if let htmlParserJson = sourceJson.htmlParser {
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext) addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl }
newSourceHtmlParser.rows = htmlParserJson.rows
// Adds a title complex query if present // Add an API condition as well
if let titleJson = htmlParserJson.title { if newSource.rssParser != nil {
let newSourceTitle = SourceTitle(context: backgroundContext) newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
newSourceTitle.query = titleJson.query } else {
newSourceTitle.attribute = titleJson.attribute newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
newSourceTitle.regex = titleJson.regex
newSourceHtmlParser.title = newSourceTitle
}
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute
newSourceSize.regex = sizeJson.regex
newSourceHtmlParser.size = newSourceSize
}
if let seedLeechJson = htmlParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceHtmlParser.seedLeech = newSourceSeedLeech
}
// Adds a magnet complex query and its unique properties
let newSourceMagnet = SourceMagnet(context: backgroundContext)
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
newSourceMagnet.query = htmlParserJson.magnet.query
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
newSourceMagnet.regex = htmlParserJson.magnet.regex
newSourceHtmlParser.magnet = newSourceMagnet
newSource.htmlParser = newSourceHtmlParser
} }
newSource.enabled = true newSource.enabled = true
@ -123,6 +95,125 @@ public class SourceManager: ObservableObject {
} }
} }
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext)
newSourceRssParser.rssUrl = rssParserJson.rssUrl
newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items
if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.lookupAttribute = magnetLinkJson.lookupAttribute
newSourceRssParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = rssParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.lookupAttribute = magnetHashJson.lookupAttribute
newSourceRssParser.magnetHash = newSourceMagnetHash
}
if let titleJson = rssParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.lookupAttribute = newSourceTitle.lookupAttribute
newSourceRssParser.title = newSourceTitle
}
if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.lookupAttribute = sizeJson.lookupAttribute
newSourceRssParser.size = newSourceSize
}
if let seedLeechJson = rssParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.lookupAttribute = seedLeechJson.lookupAttribute
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceRssParser.seedLeech = newSourceSeedLeech
}
if let trackerJson = rssParserJson.trackers {
for urlString in trackerJson {
let newSourceTracker = SourceTracker(context: backgroundContext)
newSourceTracker.urlString = urlString
newSourceTracker.parentRssParser = newSourceRssParser
}
}
newSource.rssParser = newSourceRssParser
}
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
newSourceHtmlParser.rows = htmlParserJson.rows
// Adds a title complex query if present
if let titleJson = htmlParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.regex = titleJson.regex
newSourceHtmlParser.title = newSourceTitle
}
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.regex = sizeJson.regex
newSourceHtmlParser.size = newSourceSize
}
if let seedLeechJson = htmlParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceHtmlParser.seedLeech = newSourceSeedLeech
}
// Adds a magnet complex query and its unique properties
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
newSourceMagnet.query = htmlParserJson.magnet.query
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
newSourceMagnet.regex = htmlParserJson.magnet.regex
newSourceHtmlParser.magnetLink = newSourceMagnet
newSource.htmlParser = newSourceHtmlParser
}
@MainActor @MainActor
public func addSourceList(sourceUrl: String) async -> Bool { public func addSourceList(sourceUrl: String) async -> Bool {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
@ -134,24 +225,25 @@ public class SourceManager: ObservableObject {
return false return false
} }
let sourceUrlRequest = SourceList.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 = SourceList(context: backgroundContext)
newSourceUrl.urlString = sourceUrl
do { do {
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!)) let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!))
if let rawResponse = try? JSONDecoder().decode(SourceListJson.self, from: data) { let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
newSourceUrl.repoName = rawResponse.repoName
let sourceListRequest = SourceList.fetchRequest()
sourceListRequest.predicate = NSPredicate(format: "urlString == %@ OR author == %@", sourceUrl, rawResponse.author)
sourceListRequest.fetchLimit = 1
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
urlErrorAlertText = "A source with the same URL or author exists. Please remove it and try again."
showUrlErrorAlert.toggle()
return false
} }
let newSourceUrl = SourceList(context: backgroundContext)
newSourceUrl.urlString = sourceUrl
newSourceUrl.name = rawResponse.name
newSourceUrl.author = rawResponse.author
try backgroundContext.save() try backgroundContext.save()
return true return true

View file

@ -73,7 +73,10 @@ struct ContentView: View {
.onSubmit(of: .search) { .onSubmit(of: .search) {
Task { Task {
await scrapingModel.scanSources(sources: sources.compactMap { $0 }) await scrapingModel.scanSources(sources: sources.compactMap { $0 })
await debridManager.populateDebridHashes(scrapingModel.searchResults)
if realDebridEnabled {
await debridManager.populateDebridHashes(scrapingModel.searchResults)
}
} }
} }
.navigationTitle("Search") .navigationTitle("Search")

View file

@ -20,7 +20,7 @@ struct SettingsSourceListView: View {
var body: some View { var body: some View {
List { List {
ForEach(sourceUrls, id: \.self) { sourceUrl in ForEach(sourceUrls, id: \.self) { sourceUrl in
Text(sourceUrl.repoName ?? "Unknown repo") Text(sourceUrl.name)
} }
.onDelete { offsets in .onDelete { offsets in
for index in offsets { for index in offsets {

View file

@ -9,6 +9,7 @@ import SwiftUI
struct SourceListView: View { struct SourceListView: View {
@EnvironmentObject var sourceManager: SourceManager @EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
@ -32,15 +33,36 @@ struct SourceListView: View {
PersistenceController.shared.save() PersistenceController.shared.save()
} }
)) { )) {
Text(source.name) VStack(alignment: .leading, spacing: 5) {
} HStack {
} Text(source.name)
.onDelete { offsets in Text("v\(source.version)")
for index in offsets { .foregroundColor(.secondary)
if let source = sources[safe: index] { }
PersistenceController.shared.delete(source, context: backgroundContext)
Text("by \(source.author ?? "Unknown")")
.foregroundColor(.secondary)
} }
} }
.contextMenu {
Button {
navModel.selectedSource = source
navModel.showSourceSettings.toggle()
} label: {
Text("Settings")
Image(systemName: "gear")
}
Button {
PersistenceController.shared.delete(source, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
}
}
.sheet(isPresented: $navModel.showSourceSettings) {
SourceSettingsView()
} }
} }
} }
@ -52,7 +74,16 @@ struct SourceListView: View {
ForEach(sourceManager.availableSources, id: \.self) { availableSource in ForEach(sourceManager.availableSources, id: \.self) { availableSource in
if !sources.contains(where: { availableSource.name == $0.name }) { if !sources.contains(where: { availableSource.name == $0.name }) {
HStack { HStack {
Text(availableSource.name) VStack(alignment: .leading, spacing: 5) {
HStack {
Text(availableSource.name)
Text("v\(availableSource.version)")
.foregroundColor(.secondary)
}
Text("by \(availableSource.author ?? "Unknown")")
.foregroundColor(.secondary)
}
Spacer() Spacer()

View file

@ -0,0 +1,56 @@
//
// SourceSettingsView.swift
// Ferrite
//
// Created by Brian Dashore on 8/4/22.
//
import SwiftUI
struct SourceSettingsView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavView {
Form {
SourceSettingsMethodView()
}
.navigationTitle("Source settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
struct SourceSettingsMethodView: View {
@EnvironmentObject var navModel: NavigationViewModel
@State private var selectedTempParser: SourcePreferredParser = .none
var body: some View {
Picker("Fetch method", selection: $selectedTempParser) {
if navModel.selectedSource?.htmlParser != nil {
Text("Web scraping")
.tag(SourcePreferredParser.scraping)
}
if navModel.selectedSource?.rssParser != nil {
Text("RSS")
.tag(SourcePreferredParser.rss)
}
}
.pickerStyle(.inline)
.onAppear {
selectedTempParser = SourcePreferredParser(rawValue: navModel.selectedSource?.preferredParser ?? 0) ?? .none
}
.onChange(of: selectedTempParser) { newMethod in
navModel.selectedSource?.preferredParser = newMethod.rawValue
PersistenceController.shared.save()
}
}
}