Sources: Allow for dynamic properties and basic API usage

Some sources are self-hosted and require unique keys and sever
addresses.

Signed-off-by: kingbri <bdashore3@gmail.com>
This commit is contained in:
kingbri 2022-08-10 21:38:50 -04:00
parent a141ca5819
commit cab6eb3bb6
8 changed files with 188 additions and 15 deletions

View file

@ -119,6 +119,7 @@
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>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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>"; };
@ -193,6 +194,7 @@
0CA148BA288903F000DE2211 /* Ferrite */ = {
isa = PBXGroup;
children = (
0CC6E4D428A45BA000AF2BCC /* Info.plist */,
0CBC7703288DE7E90054BE44 /* DataManagement */,
0CA148F12889066000DE2211 /* API */,
0C0D50E3288DFE6E0035ECC8 /* Models */,
@ -578,11 +580,11 @@
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = NO;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@ -610,11 +612,11 @@
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = NO;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";

View file

@ -15,7 +15,8 @@ public extension Source {
}
@NSManaged var id: UUID
@NSManaged var baseUrl: String
@NSManaged var baseUrl: String?
@NSManaged var dynamicBaseUrl: Bool
@NSManaged var enabled: Bool
@NSManaged var name: String
@NSManaged var author: String
@ -24,6 +25,7 @@ public extension Source {
@NSManaged var version: Int16
@NSManaged var htmlParser: SourceHtmlParser?
@NSManaged var rssParser: SourceRssParser?
@NSManaged var api: SourceApi?
}
extension Source: Identifiable {}

View file

@ -1,16 +1,24 @@
<?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="21277" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="baseUrl" attributeType="String" defaultValueString=""/>
<attribute name="baseUrl" optional="YES" attributeType="String"/>
<attribute name="dynamicBaseUrl" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
<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"/>
<relationship name="rssParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceApi" representedClassName="SourceApi" syncable="YES" codeGenerationType="class">
<attribute name="clientId" optional="YES" attributeType="String"/>
<attribute name="clientSecret" optional="YES" attributeType="String"/>
<attribute name="dynamicClientId" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="api" inverseEntity="Source"/>
</entity>
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString="text"/>

13
Ferrite/Info.plist Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<false/>
</dict>
</plist>

View file

@ -16,9 +16,11 @@ public struct SourceListJson: Codable {
public struct SourceJson: Codable, Hashable {
let name: String
let version: Int16
let baseUrl: String
let baseUrl: String?
var dynamicBaseUrl: Bool?
var author: String?
var listId: UUID?
let api: SourceApiJson?
let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson?
}
@ -30,6 +32,12 @@ public enum SourcePreferredParser: Int16, CaseIterable {
case siteApi = 3
}
public struct SourceApiJson: Codable, Hashable {
let clientId: String?
var dynamicClientId: Bool?
let usesSecret: Bool
}
public struct SourceRssParserJson: Codable, Hashable {
let rssUrl: String?
let searchUrl: String

View file

@ -52,6 +52,15 @@ class ScrapingViewModel: ObservableObject {
if source.enabled {
currentSourceName = source.name
guard let baseUrl = source.baseUrl else {
Task { @MainActor in
toastModel?.toastDescription = "The base URL could not be found for source \(source.name)"
}
print("The base URL could not be found for source \(source.name)")
continue
}
// Default to HTML scraping
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
@ -65,13 +74,13 @@ class ScrapingViewModel: ObservableObject {
continue
}
let urlString = source.baseUrl + htmlParser.searchUrl.replacingOccurrences(of: "{query}", with: encodedQuery)
let urlString = 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)
let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html)
tempResults += sourceResults
}
case .rss:
@ -83,14 +92,16 @@ class ScrapingViewModel: ObservableObject {
continue
}
let replacedSearchUrl = rssParser.searchUrl.replacingOccurrences(of: "{query}", with: encodedQuery)
let replacedSearchUrl = rssParser.searchUrl
.replacingOccurrences(of: "{apiKey}", with: source.api?.clientSecret ?? "")
.replacingOccurrences(of: "{query}", with: encodedQuery)
// 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
urlString = baseUrl + replacedSearchUrl
}
guard let rss = await fetchWebsiteData(urlString: urlString) else {
@ -261,7 +272,9 @@ class ScrapingViewModel: ObservableObject {
leechers: leechers
)
tempResults.append(result)
if !tempResults.contains(result) {
tempResults.append(result)
}
}
return tempResults
@ -269,7 +282,7 @@ class ScrapingViewModel: ObservableObject {
// HTML scraper
@MainActor
public func scrapeHtml(source: Source, html: String) async -> [SearchResult] {
public func scrapeHtml(source: Source, baseUrl: String, html: String) async -> [SearchResult] {
guard let htmlParser = source.htmlParser else {
return []
}
@ -304,7 +317,7 @@ class ScrapingViewModel: ObservableObject {
continue
}
guard let magnetHtml = await fetchWebsiteData(urlString: source.baseUrl + externalMagnetLink) else {
guard let magnetHtml = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink) else {
continue
}
@ -411,7 +424,9 @@ class ScrapingViewModel: ObservableObject {
leechers: leechers
)
tempResults.append(result)
if !tempResults.contains(result) {
tempResults.append(result)
}
} catch {
toastModel?.toastDescription = "Scraping error: \(error)"
print("Scraping error: \(error)")

View file

@ -48,6 +48,18 @@ public class SourceManager: ObservableObject {
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) {
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
if (!dynamicBaseUrl && sourceJson.baseUrl == nil) || (dynamicBaseUrl && sourceJson.baseUrl != nil) {
Task { @MainActor in
toastModel?.toastDescription = "Not adding this source because base URL parameters are malformed. Please contact the source dev."
}
print("Not adding this source because base URL parameters are malformed")
return
}
// If a source exists, don't add the new one unless upserting
let existingSourceRequest = Source.fetchRequest()
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
@ -68,10 +80,15 @@ public class SourceManager: ObservableObject {
newSource.id = UUID()
newSource.name = sourceJson.name
newSource.version = sourceJson.version
newSource.dynamicBaseUrl = dynamicBaseUrl
newSource.baseUrl = sourceJson.baseUrl
newSource.author = sourceJson.author ?? "Unknown"
newSource.listId = sourceJson.listId
if let sourceApiJson = sourceJson.api {
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
}
// Adds an RSS parser if present
if let rssParserJson = sourceJson.rssParser {
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
@ -100,6 +117,25 @@ public class SourceManager: ObservableObject {
}
}
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext)
newSourceApi.clientId = apiJson.clientId
if let clientId = apiJson.clientId {
newSourceApi.clientId = clientId
}
newSourceApi.dynamicClientId = apiJson.dynamicClientId ?? false
if apiJson.usesSecret {
newSourceApi.clientSecret = ""
}
newSource.api = newSourceApi
}
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext

View file

@ -12,6 +12,8 @@ struct SourceSettingsView: View {
@EnvironmentObject var navModel: NavigationViewModel
@State private var tempBaseUrl: String = ""
var body: some View {
NavView {
Form {
@ -42,6 +44,31 @@ struct SourceSettingsView: View {
}
}
if selectedSource.dynamicBaseUrl {
Section(
header: Text("Base URL"),
footer: Text("Enter the base URL of your server.")
) {
TextField("https://...", text: $tempBaseUrl)
.onAppear {
tempBaseUrl = selectedSource.baseUrl ?? ""
}
.onSubmit {
if tempBaseUrl.last == "/" {
selectedSource.baseUrl = String(tempBaseUrl.dropLast())
} else {
selectedSource.baseUrl = tempBaseUrl
}
PersistenceController.shared.save()
}
}
}
if let sourceApi = selectedSource.api {
SourceSettingsApiView(selectedSourceApi: sourceApi)
}
SourceSettingsMethodView(selectedSource: selectedSource)
}
}
@ -57,6 +84,68 @@ struct SourceSettingsView: View {
}
}
struct SourceSettingsApiView: View {
@ObservedObject var selectedSourceApi: SourceApi
@State private var tempClientId: String = ""
@State private var tempClientSecret: String = ""
@State private var showPassword = false
@FocusState var inFocus: Field?
enum Field {
case secure, plain
}
var body: some View {
Section(
header: Text("API credentials"),
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.")
) {
if selectedSourceApi.dynamicClientId {
TextField("Client ID", text: $tempClientId)
.onAppear {
tempClientId = selectedSourceApi.clientId ?? ""
}
.onSubmit {
selectedSourceApi.clientId = tempClientId
PersistenceController.shared.save()
}
}
if selectedSourceApi.clientSecret != nil {
HStack {
Group {
if showPassword {
TextField("Token", text: $tempClientSecret)
.focused($inFocus, equals: .plain)
} else {
SecureField("Token", text: $tempClientSecret)
.focused($inFocus, equals: .secure)
}
}
.onAppear {
tempClientSecret = selectedSourceApi.clientSecret ?? ""
}
.onSubmit {
selectedSourceApi.clientSecret = tempClientSecret
PersistenceController.shared.save()
}
Spacer()
Button {
showPassword.toggle()
inFocus = showPassword ? .plain : .secure
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
}
}
}
}
}
}
struct SourceSettingsMethodView: View {
@ObservedObject var selectedSource: Source