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:
parent
a141ca5819
commit
cab6eb3bb6
8 changed files with 188 additions and 15 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
13
Ferrite/Info.plist
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue