// // SourceManager.swift // Ferrite // // Created by Brian Dashore on 7/25/22. // import CoreData import Foundation import SwiftUI public class SourceManager: ObservableObject { var toastModel: ToastViewModel? @Published var availableSources: [SourceJson] = [] var urlErrorAlertText = "" @Published var showUrlErrorAlert = false @MainActor public func fetchSourcesFromUrl() async { let sourceListRequest = SourceList.fetchRequest() do { let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest) var tempAvailableSources: [SourceJson] = [] for sourceList in sourceLists { guard let url = URL(string: sourceList.urlString) else { return } // Always get the up-to-date source list let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) let (data, _) = try await URLSession.shared.data(for: request) let sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data) for var source in sourceResponse.sources { // If there is a minVersion, check and see if the source is valid if checkAppVersion(minVersion: source.minVersion) { source.author = sourceList.author source.listId = sourceList.id tempAvailableSources.append(source) } } } availableSources = tempAvailableSources } catch { print(error) } } func fetchUpdatedSources(installedSources: FetchedResults) -> [SourceJson] { var updatedSources: [SourceJson] = [] for source in installedSources { if let availableSource = availableSources.first(where: { source.listId == $0.listId && source.name == $0.name && source.author == $0.author }), availableSource.version > source.version { updatedSources.append(availableSource) } } return updatedSources } // Checks if the current app version is supported by the source func checkAppVersion(minVersion: String?) -> Bool { // If there's no min version, assume that every version is supported guard let minVersion else { return true } return Application.shared.appVersion >= minVersion } // Fetches sources using the background context public func fetchInstalledSources() -> [Source] { let backgroundContext = PersistenceController.shared.backgroundContext if let sources = try? backgroundContext.fetch(Source.fetchRequest()) { return sources.compactMap { $0 } } else { return [] } } public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async { 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 { await toastModel?.updateToastDescription("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) existingSourceRequest.fetchLimit = 1 if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first { if doUpsert { PersistenceController.shared.delete(existingSource, context: backgroundContext) } else { await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.") return } } let newSource = Source(context: backgroundContext) newSource.id = UUID() newSource.name = sourceJson.name newSource.version = sourceJson.version newSource.dynamicBaseUrl = dynamicBaseUrl newSource.baseUrl = sourceJson.baseUrl newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls newSource.author = sourceJson.author ?? "Unknown" newSource.listId = sourceJson.listId newSource.trackers = sourceJson.trackers if let sourceApiJson = sourceJson.api { addSourceApi(newSource: newSource, apiJson: sourceApiJson) } if let jsonParserJson = sourceJson.jsonParser { addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson) } // Adds an RSS parser if present if let rssParserJson = sourceJson.rssParser { addRssParser(newSource: newSource, rssParserJson: rssParserJson) } // Adds an HTML parser if present if let htmlParserJson = sourceJson.htmlParser { addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson) } // Add an API condition as well if newSource.jsonParser != nil { newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue) } else if newSource.rssParser != nil { newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue) } else { newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue) } newSource.enabled = true do { try backgroundContext.save() } catch { await toastModel?.updateToastDescription(error.localizedDescription) } } func addSourceApi(newSource: Source, apiJson: SourceApiJson) { let backgroundContext = PersistenceController.shared.backgroundContext let newSourceApi = SourceApi(context: backgroundContext) newSourceApi.apiUrl = apiJson.apiUrl if let clientIdJson = apiJson.clientId { let newClientId = SourceApiClientId(context: backgroundContext) newClientId.query = clientIdJson.query newClientId.urlString = clientIdJson.url newClientId.dynamic = clientIdJson.dynamic ?? false newClientId.value = clientIdJson.value newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue newClientId.expiryLength = clientIdJson.expiryLength ?? 0 newClientId.timeStamp = Date() newSourceApi.clientId = newClientId } if let clientSecretJson = apiJson.clientSecret { let newClientSecret = SourceApiClientSecret(context: backgroundContext) newClientSecret.query = clientSecretJson.query newClientSecret.urlString = clientSecretJson.url newClientSecret.dynamic = clientSecretJson.dynamic ?? false newClientSecret.value = clientSecretJson.value newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0 newClientSecret.timeStamp = Date() newSourceApi.clientSecret = newClientSecret } newSource.api = newSourceApi } func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) { let backgroundContext = PersistenceController.shared.backgroundContext let newSourceJsonParser = SourceJsonParser(context: backgroundContext) newSourceJsonParser.searchUrl = jsonParserJson.searchUrl newSourceJsonParser.results = jsonParserJson.results newSourceJsonParser.subResults = jsonParserJson.subResults // Tune these complex queries to the final JSON parser format if let magnetLinkJson = jsonParserJson.magnetLink { let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) newSourceMagnetLink.query = magnetLinkJson.query newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text" newSourceMagnetLink.discriminator = magnetLinkJson.discriminator newSourceJsonParser.magnetLink = newSourceMagnetLink } if let magnetHashJson = jsonParserJson.magnetHash { let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext) newSourceMagnetHash.query = magnetHashJson.query newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text" newSourceMagnetHash.discriminator = magnetHashJson.discriminator newSourceJsonParser.magnetHash = newSourceMagnetHash } if let titleJson = jsonParserJson.title { let newSourceTitle = SourceTitle(context: backgroundContext) newSourceTitle.query = titleJson.query newSourceTitle.attribute = titleJson.attribute ?? "text" newSourceTitle.discriminator = titleJson.discriminator newSourceJsonParser.title = newSourceTitle } if let sizeJson = jsonParserJson.size { let newSourceSize = SourceSize(context: backgroundContext) newSourceSize.query = sizeJson.query newSourceSize.attribute = sizeJson.attribute ?? "text" newSourceSize.discriminator = sizeJson.discriminator newSourceJsonParser.size = newSourceSize } if let seedLeechJson = jsonParserJson.sl { let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext) newSourceSeedLeech.seeders = seedLeechJson.seeders newSourceSeedLeech.leechers = seedLeechJson.leechers newSourceSeedLeech.combined = seedLeechJson.combined newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text" newSourceSeedLeech.discriminator = seedLeechJson.discriminator newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex newSourceJsonParser.seedLeech = newSourceSeedLeech } newSource.jsonParser = newSourceJsonParser } 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.discriminator = magnetLinkJson.discriminator newSourceRssParser.magnetLink = newSourceMagnetLink } if let magnetHashJson = rssParserJson.magnetHash { let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext) newSourceMagnetHash.query = magnetHashJson.query newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text" newSourceMagnetHash.discriminator = magnetHashJson.discriminator newSourceRssParser.magnetHash = newSourceMagnetHash } if let titleJson = rssParserJson.title { let newSourceTitle = SourceTitle(context: backgroundContext) newSourceTitle.query = titleJson.query newSourceTitle.attribute = titleJson.attribute ?? "text" newSourceTitle.discriminator = titleJson.discriminator newSourceRssParser.title = newSourceTitle } if let sizeJson = rssParserJson.size { let newSourceSize = SourceSize(context: backgroundContext) newSourceSize.query = sizeJson.query newSourceSize.attribute = sizeJson.attribute ?? "text" newSourceSize.discriminator = sizeJson.discriminator 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.discriminator = seedLeechJson.discriminator newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex newSourceRssParser.seedLeech = newSourceSeedLeech } 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 public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool { let backgroundContext = PersistenceController.shared.backgroundContext if sourceUrl.isEmpty || URL(string: sourceUrl) == nil { urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly." showUrlErrorAlert.toggle() return false } do { let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!)) let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data) if let existingSourceList { existingSourceList.urlString = sourceUrl existingSourceList.name = rawResponse.name existingSourceList.author = rawResponse.author try PersistenceController.shared.container.viewContext.save() } else { let sourceListRequest = SourceList.fetchRequest() let urlPredicate = NSPredicate(format: "urlString == %@", sourceUrl) let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name) sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate]) sourceListRequest.fetchLimit = 1 if (try? backgroundContext.fetch(sourceListRequest).first) != nil { urlErrorAlertText = "An existing source with this information was found. Please try editing the source list instead." showUrlErrorAlert.toggle() return false } let newSourceUrl = SourceList(context: backgroundContext) newSourceUrl.id = UUID() newSourceUrl.urlString = sourceUrl newSourceUrl.name = rawResponse.name newSourceUrl.author = rawResponse.author try backgroundContext.save() } return true } catch { print(error) urlErrorAlertText = error.localizedDescription showUrlErrorAlert.toggle() return false } } }