From 95ea2be72294bb9e2467ee47691c19b0a7ab34e1 Mon Sep 17 00:00:00 2001 From: kingbri Date: Wed, 10 Aug 2022 21:38:50 -0400 Subject: [PATCH] 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 --- Ferrite.xcodeproj/project.pbxproj | 6 +- .../Classes/Source+CoreDataProperties.swift | 4 +- .../FerriteDB.xcdatamodel/contents | 14 ++- Ferrite/Info.plist | 13 +++ Ferrite/Models/SourceModels.swift | 10 ++- Ferrite/ViewModels/ScrapingViewModel.swift | 31 +++++-- Ferrite/ViewModels/SourceManager.swift | 36 ++++++++ .../SourceViews/SourceSettingsView.swift | 89 +++++++++++++++++++ 8 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 Ferrite/Info.plist diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index ed8ac08..aa8efc3 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = ""; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0CF501F0289AE06A0099C785 /* SourceTracker+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceTracker+CoreDataClass.swift"; sourceTree = ""; }; 0CF501F1289AE06A0099C785 /* SourceTracker+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceTracker+CoreDataProperties.swift"; sourceTree = ""; }; 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = ""; }; @@ -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"; diff --git a/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift index 2be1c40..dcaf8fc 100644 --- a/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/Source+CoreDataProperties.swift @@ -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 {} diff --git a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents index db4687e..e3a8b92 100644 --- a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents +++ b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB.xcdatamodel/contents @@ -1,16 +1,24 @@ - + - + + + - + + + + + + + diff --git a/Ferrite/Info.plist b/Ferrite/Info.plist new file mode 100644 index 0000000..3eba462 --- /dev/null +++ b/Ferrite/Info.plist @@ -0,0 +1,13 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UILaunchScreen + + + diff --git a/Ferrite/Models/SourceModels.swift b/Ferrite/Models/SourceModels.swift index ce54637..1d153e9 100644 --- a/Ferrite/Models/SourceModels.swift +++ b/Ferrite/Models/SourceModels.swift @@ -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 diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index f15a877..e803c6f 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -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)") diff --git a/Ferrite/ViewModels/SourceManager.swift b/Ferrite/ViewModels/SourceManager.swift index 5f18c6f..ace32a8 100644 --- a/Ferrite/ViewModels/SourceManager.swift +++ b/Ferrite/ViewModels/SourceManager.swift @@ -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 diff --git a/Ferrite/Views/SourceViews/SourceSettingsView.swift b/Ferrite/Views/SourceViews/SourceSettingsView.swift index 8617b96..33b81f4 100644 --- a/Ferrite/Views/SourceViews/SourceSettingsView.swift +++ b/Ferrite/Views/SourceViews/SourceSettingsView.swift @@ -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