Kodi: Add multi-server support

Multiple servers can be added to Ferrite to playback from any Kodi
server that the user wants. This also adds the ability to have
friendly names which makes it easier to select what server to play on.

Each server shows the user whether it's online or not through Kodi's
JSONRPC ping method.

Signed-off-by: kingbri <bdashore3@proton.me>
This commit is contained in:
kingbri 2023-03-13 15:13:06 -04:00
parent d2d7d7364f
commit 20c55316b0
25 changed files with 611 additions and 198 deletions

View file

@ -13,7 +13,7 @@
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginListView.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; };
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
@ -26,6 +26,9 @@
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; };
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */; };
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; };
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */; };
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */; };
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */; };
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */; };
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */; };
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */; };
@ -118,6 +121,7 @@
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.swift */; };
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */; };
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; };
@ -147,7 +151,7 @@
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListView.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = "<group>"; };
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = "<group>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
@ -159,6 +163,9 @@
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = "<group>"; };
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiEditorView.swift; sourceTree = "<group>"; };
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataClass.swift"; sourceTree = "<group>"; };
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionModels.swift; sourceTree = "<group>"; };
0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsView.swift; sourceTree = "<group>"; };
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginModels.swift; sourceTree = "<group>"; };
@ -247,6 +254,7 @@
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CAF9318296399190050812A /* PremiumizeCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeCloudView.swift; sourceTree = "<group>"; };
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = "<group>"; };
0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = "<group>"; };
0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = "<group>"; };
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = "<group>"; };
@ -321,6 +329,8 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */,
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */,
0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */,
0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */,
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */,
@ -375,12 +385,31 @@
path = Cloud;
sourceTree = "<group>";
};
0C3DD43C29B69664006429DB /* Kodi */ = {
isa = PBXGroup;
children = (
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */,
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */,
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */,
);
path = Kodi;
sourceTree = "<group>";
};
0C3DD43D29B69672006429DB /* PluginList */ = {
isa = PBXGroup;
children = (
0CA05456288EE58200850554 /* SettingsPluginListView.swift */,
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
);
path = PluginList;
sourceTree = "<group>";
};
0C3E00D4296F560800ECECB2 /* Plugin */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C794B65289DAC9F00DD1CC8 /* Source */,
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */,
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */,
0C5005512992B6750064606A /* PluginTagsView.swift */,
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */,
);
@ -448,12 +477,11 @@
0CA0545C288F7CB200850554 /* Settings */ = {
isa = PBXGroup;
children = (
0C3DD43D29B69672006429DB /* PluginList */,
0C3DD43C29B69664006429DB /* Kodi */,
0C44E2AE28D52E8A007711AE /* BackupsView.swift */,
0CA05456288EE58200850554 /* SettingsPluginListView.swift */,
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */,
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */,
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
);
@ -758,10 +786,12 @@
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
0C5708E929B8E61C00BE07F9 /* Logger.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
@ -771,7 +801,7 @@
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */,
@ -847,7 +877,9 @@
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */,
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */,

View file

@ -10,6 +10,91 @@ import Foundation
public class Kodi {
let encoder = JSONEncoder()
// Used to add server to CoreData. Not part of API
public func addServer(
urlString: String,
friendlyName: String?,
username: String?,
password: String?,
existingServer: KodiServer? = nil
) throws {
let backgroundContext = PersistenceController.shared.backgroundContext
if !urlString.starts(with: "http://") && !urlString.starts(with: "https://") {
throw KodiError.ServerAddition(description: "Could not add Kodi server because the URL is invalid.")
}
var name: String = ""
if let friendlyName {
name = friendlyName
} else {
var components = URLComponents(string: urlString)
components?.scheme = nil
components?.path = ""
guard let cleanedName = components?.url?.description.dropFirst(2) else {
throw KodiError.ServerAddition(description: "An invalid friendly name for this Kodi server was generated.")
}
name = String(cleanedName)
}
if existingServer == nil {
let existingServerRequest = KodiServer.fetchRequest()
existingServerRequest.fetchLimit = 1
// If a server with the same name or URL exists, error out
let namePredicate = NSPredicate(format: "name == %@", name)
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
existingServerRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, urlPredicate])
if (try? backgroundContext.fetch(existingServerRequest).first) != nil {
throw KodiError.ServerAddition(description: "An existing kodi server with the same name or URL was found. Please try editing an existing server instead.")
}
}
let newServerObject = existingServer ?? KodiServer(context: backgroundContext)
newServerObject.urlString = urlString
newServerObject.name = name
if let username, let password {
newServerObject.username = username
newServerObject.password = password
}
try backgroundContext.save()
}
public func ping(server: KodiServer) async throws {
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody = RPCPayload(
method: "JSONRPC.Ping",
params: nil
)
if let username = server.username, let password = server.password {
request.setValue("Basic \(Data("\(username):\(password)".utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
}
request.httpBody = try encoder.encode(requestBody)
let (_, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw KodiError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode == 401 {
throw KodiError.FailedRequest(description: "Your Kodi account details for server \(server.name) are invalid. Please check your credentials in Settings > Kodi.")
} else if response.statusCode <= 200, response.statusCode >= 299 {
throw KodiError.FailedRequest(description: "The Kodi request failed with status code \(response.statusCode).")
}
}
public func sendVideoUrl(urlString: String) async throws {
guard let baseUrl = UserDefaults.standard.string(forKey: "ExternalServices.KodiUrl") else {
throw KodiError.InvalidBaseUrl

View file

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

View file

@ -0,0 +1,28 @@
//
// KodiServer+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 3/6/23.
//
//
import Foundation
import CoreData
extension KodiServer {
@nonobjc public class func fetchRequest() -> NSFetchRequest<KodiServer> {
return NSFetchRequest<KodiServer>(entityName: "KodiServer")
}
@NSManaged public var urlString: String
@NSManaged public var name: String
@NSManaged public var username: String?
@NSManaged public var password: String?
}
extension KodiServer : Identifiable {
}

View file

@ -34,6 +34,12 @@
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
</entity>
<entity name="KodiServer" representedClassName="KodiServer" syncable="YES">
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="password" optional="YES" attributeType="String"/>
<attribute name="urlString" attributeType="String" defaultValueString=""/>
<attribute name="username" optional="YES" attributeType="String"/>
</entity>
<entity name="PluginList" representedClassName="PluginList" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>

View file

@ -9,6 +9,7 @@ import Foundation
extension Kodi {
enum KodiError: Error {
case ServerAddition(description: String)
case InvalidBaseUrl
case InvalidPlaybackUrl
case InvalidPostBody

View file

@ -58,11 +58,14 @@ public class NavigationViewModel: ObservableObject {
@Published var selectedTab: ViewTab = .search
// TODO: Maybe move these to their own StateObjects?
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
var selectedSource: Source?
@Published var showSourceListEditor: Bool = false
// Used between service views and editor views in Settings
@Published var selectedPluginList: PluginList?
@Published var selectedKodiServer: KodiServer?
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
@Published var pluginPickerSelection: PluginPickerSegment = .sources

View file

@ -728,7 +728,7 @@ public class PluginManager: ObservableObject {
public func addPluginList(_ url: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext
if url.isEmpty || URL(string: url) == nil {
if url.isEmpty || !url.starts(with: "https://") && !url.starts(with: "http://") {
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
}

View file

@ -23,6 +23,7 @@ struct ListRowLinkView: View {
Image(systemName: "arrow.up.forward.app.fill")
.foregroundColor(.gray)
}
.padding(.trailing, -5)
}
}
@ -50,6 +51,7 @@ struct ListRowButtonView: View {
.foregroundColor(.gray)
}
}
.padding(.trailing, -5)
}
}
@ -66,10 +68,10 @@ struct ListRowTextView: View {
if let rightText {
Text(rightText)
} else {
Image(systemName: rightSymbol!)
.foregroundColor(.gray)
} else if let rightSymbol {
Image(systemName: rightSymbol)
}
}
.padding(.trailing, -5)
}
}

View file

@ -16,6 +16,7 @@ struct BookmarksView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@Binding var searchText: String
@Binding var bookmarksEmpty: Bool
@State private var viewTask: Task<Void, Never>?
@State private var bookmarkPredicate: NSPredicate?
@ -54,6 +55,8 @@ struct BookmarksView: View {
.listStyle(.insetGrouped)
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 15 : -25)
.backport.onAppear {
bookmarksEmpty = bookmarks.isEmpty
if debridManager.enabledDebrids.count > 0 {
viewTask = Task {
let magnets = bookmarks.compactMap {
@ -70,6 +73,9 @@ struct BookmarksView: View {
.onDisappear {
viewTask?.cancel()
}
.onChange(of: bookmarks.count) { newCount in
bookmarksEmpty = newCount == 0
}
}
.backport.onAppear {
applyPredicate()

View file

@ -10,9 +10,15 @@ import SwiftUI
struct HistoryView: View {
@EnvironmentObject var navModel: NavigationViewModel
var history: FetchedResults<History>
@FetchRequest(
entity: History.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \History.date, ascending: false)
]
) var history: FetchedResults<History>
@Binding var searchText: String
@Binding var historyEmpty: Bool
@State private var historyPredicate: NSPredicate?
@ -28,8 +34,12 @@ struct HistoryView: View {
.listStyle(.insetGrouped)
}
.backport.onAppear {
historyEmpty = history.isEmpty
applyPredicate()
}
.onChange(of: history.count) { newCount in
historyEmpty = newCount == 0
}
.onChange(of: searchText) { _ in
applyPredicate()
}

View file

@ -6,7 +6,7 @@
//
import SwiftUI
struct PluginListView<P: Plugin, PJ: PluginJson>: View {
struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
@ -15,6 +15,7 @@ struct PluginListView<P: Plugin, PJ: PluginJson>: View {
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@Binding var searchText: String
@Binding var pluginsEmpty: Bool
@State private var isEditingSearch = false
@State private var isSearching = false
@ -70,9 +71,15 @@ struct PluginListView<P: Plugin, PJ: PluginJson>: View {
.environmentObject(navModel)
}
}
.backport.onAppear {
pluginsEmpty = installedPlugins.isEmpty
}
.onChange(of: searchText) { _ in
sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
}
.onChange(of: installedPlugins.count) { newCount in
pluginsEmpty = newCount == 0
}
}
}
}

View file

@ -104,6 +104,8 @@ struct SourceSettingsBaseUrlView: View {
}
})
.keyboardType(.URL)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.backport.onAppear {
tempBaseUrl = selectedSource.baseUrl ?? ""
}

View file

@ -0,0 +1,111 @@
//
// KodiEditorView.swift
// Ferrite
//
// Created by Brian Dashore on 3/6/23.
//
import SwiftUI
struct KodiEditorView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var logManager: LoggingManager
@State private var loadedSelectedServer = false
@State private var serverUrl: String = ""
@State private var friendlyName: String = ""
@State private var username: String = ""
@State private var password: String = ""
@State private var showErrorAlert = false
@State private var errorAlertText: String = ""
var body: some View {
NavView {
Form {
Group {
Section(
header: InlineHeader("URL"),
footer: Text("Must follow the format http(s)://<ip>:<port>")
) {
TextField("Enter URL", text: $serverUrl)
.keyboardType(.URL)
}
Section(
header: InlineHeader("Friendly name"),
footer: Text("Defaults to the URL if not provided")
) {
TextField("Friendly name", text: $friendlyName)
}
Section(
header: InlineHeader("Credentials"),
footer: Text("Only use for clients with authentication")
) {
TextField("Username", text: $username)
HybridSecureField(text: $password)
}
}
.disableAutocorrection(true)
.autocapitalization(.none)
.id(loadedSelectedServer)
}
.backport.onAppear {
if let selectedKodiServer = navModel.selectedKodiServer {
serverUrl = selectedKodiServer.urlString
friendlyName = selectedKodiServer.name
username = selectedKodiServer.username ?? ""
password = selectedKodiServer.password ?? ""
loadedSelectedServer.toggle()
}
}
.backport.alert(
isPresented: $showErrorAlert,
title: "Error",
message: errorAlertText
)
.navigationTitle("Editing Kodi Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
do {
try pluginManager.kodi.addServer(
urlString: serverUrl,
friendlyName: friendlyName.isEmpty ? nil : friendlyName,
username: username.isEmpty ? nil : username,
password: password.isEmpty ? nil : password,
existingServer: navModel.selectedKodiServer
)
presentationMode.wrappedValue.dismiss()
} catch {
logManager.error("Editing Kodi server: \(error)", showToast: false)
errorAlertText = error.localizedDescription
showErrorAlert.toggle()
}
}
}
}
}
}
}
struct KodiEditorView_Previews: PreviewProvider {
static var previews: some View {
KodiEditorView()
}
}

View file

@ -0,0 +1,52 @@
//
// KodiServerView.swift
// Ferrite
//
// Created by Brian Dashore on 3/11/23.
//
import SwiftUI
struct KodiServerView: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var logManager: LoggingManager
var server: KodiServer
@State private var isActive = false
@State private var pingInProgress = false
@State private var viewTask: Task<Void, Never>?
var body: some View {
HStack {
Text(server.name)
Spacer()
if pingInProgress {
ProgressView()
} else {
Circle()
.foregroundColor(isActive ? .green : .red)
.frame(width: 10, height: 10)
}
}
.backport.onAppear {
viewTask = Task {
pingInProgress = true
do {
try await pluginManager.kodi.ping(server: server)
isActive = true
} catch {
logManager.error("Kodi server \(server.name): \(error)", showToast: false)
isActive = false
}
pingInProgress = false
}
}
.onDisappear {
viewTask?.cancel()
}
}
}

View file

@ -0,0 +1,96 @@
//
// SettingsKodiView.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import SwiftUI
struct SettingsKodiView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
// TODO: Change to var in v0.7
@FetchRequest(
entity: KodiServer.entity(),
sortDescriptors: []
) var kodiServers: FetchedResults<KodiServer>
@State private var presentEditSheet = false
var body: some View {
List {
Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) {
Text("Kodi is an external application that is used to manage a local media library and playback.")
Link("Website", destination: URL(string: "https://kodi.tv")!)
}
}
Section(
header: InlineHeader("Servers"),
footer: Text("Edit a server by holding it and accessing the context menu")
) {
if kodiServers.isEmpty {
Text("Add a server using the + button in the top-right")
} else {
ForEach(kodiServers, id: \.self) { server in
KodiServerView(server: server)
.contextMenu {
Button {
navModel.selectedKodiServer = server
presentEditSheet.toggle()
} label: {
Text("Edit")
Image(systemName: "pencil")
}
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(server, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(server, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
}
}
}
.onDelete { offsets in
for index in offsets {
if let server = kodiServers[safe: index] {
PersistenceController.shared.delete(server, context: backgroundContext)
}
}
}
}
}
}
.listStyle(.insetGrouped)
.sheet(isPresented: $presentEditSheet) {
KodiEditorView()
.environmentObject(navModel)
}
.navigationTitle("Kodi")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
navModel.selectedKodiServer = nil
presentEditSheet.toggle()
} label: {
Image(systemName: "plus")
}
}
}
}
}

View file

@ -12,17 +12,18 @@ struct PluginListEditorView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var logManager: LoggingManager
let backgroundContext = PersistenceController.shared.backgroundContext
@State var selectedPluginList: PluginList?
@State private var sourceUrlSet = false
@State private var showUrlErrorAlert = false
@State private var pluginListUrl: String = ""
@State private var urlErrorAlertText: String = ""
@State private var loadedSelectedList = false
var body: some View {
NavView {
Form {
@ -30,11 +31,13 @@ struct PluginListEditorView: View {
.disableAutocorrection(true)
.keyboardType(.URL)
.autocapitalization(.none)
.conditionalId(sourceUrlSet)
.id(loadedSelectedList)
}
.backport.onAppear {
pluginListUrl = selectedPluginList?.urlString ?? ""
sourceUrlSet = true
if let selectedList = navModel.selectedPluginList {
pluginListUrl = selectedList.urlString
loadedSelectedList.toggle()
}
}
.backport.alert(
isPresented: $showUrlErrorAlert,
@ -54,9 +57,14 @@ struct PluginListEditorView: View {
Button("Save") {
Task {
do {
try await pluginManager.addPluginList(pluginListUrl, existingPluginList: selectedPluginList)
try await pluginManager.addPluginList(
pluginListUrl,
existingPluginList: navModel.selectedPluginList
)
presentationMode.wrappedValue.dismiss()
} catch {
logManager.error("Editing plugin list: \(error)", showToast: false)
urlErrorAlertText = error.localizedDescription
showUrlErrorAlert.toggle()
}
@ -67,9 +75,3 @@ struct PluginListEditorView: View {
}
}
}
struct PluginListEditorView_Previews: PreviewProvider {
static var previews: some View {
PluginListEditorView()
}
}

View file

@ -17,8 +17,7 @@ struct SettingsPluginListView: View {
sortDescriptors: []
) var pluginLists: FetchedResults<PluginList>
@State private var presentSourceSheet = false
@State private var selectedPluginList: PluginList?
@State private var presentEditSheet = false
var body: some View {
ZStack {
@ -29,10 +28,10 @@ struct SettingsPluginListView: View {
ForEach(pluginLists, id: \.self) { pluginList in
VStack(alignment: .leading, spacing: 5) {
Text(pluginList.name)
Group {
Text(pluginList.author)
Text("ID: \(pluginList.id)")
.font(.caption)
}
@ -41,13 +40,13 @@ struct SettingsPluginListView: View {
.padding(.vertical, 2)
.contextMenu {
Button {
selectedPluginList = pluginList
presentSourceSheet.toggle()
navModel.selectedPluginList = pluginList
presentEditSheet.toggle()
} label: {
Text("Edit")
Image(systemName: "pencil")
}
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(pluginList, context: backgroundContext)
@ -77,12 +76,13 @@ struct SettingsPluginListView: View {
.inlinedList(inset: -20)
}
}
.sheet(isPresented: $presentSourceSheet) {
.sheet(isPresented: $presentEditSheet) {
if #available(iOS 16, *) {
PluginListEditorView(selectedPluginList: selectedPluginList)
PluginListEditorView()
.presentationDetents([.medium])
} else {
PluginListEditorView(selectedPluginList: selectedPluginList)
PluginListEditorView()
.environmentObject(navModel)
}
}
.navigationTitle("Plugin Lists")
@ -90,7 +90,8 @@ struct SettingsPluginListView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
presentSourceSheet.toggle()
navModel.selectedPluginList = nil
presentEditSheet.toggle()
} label: {
Image(systemName: "plus")
}

View file

@ -13,40 +13,38 @@ struct SettingsDebridInfoView: View {
let debridType: DebridType
var body: some View {
NavView {
List {
Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) {
Text("\(debridType.toString()) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.")
List {
Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) {
Text("\(debridType.toString()) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.")
Link("Website", destination: URL(string: debridType.website()) ?? URL(string: "https://kingbri.dev/ferrite")!)
}
}
Section(
header: InlineHeader("Login status"),
footer: Text("A WebView will show up to prompt you for credentials")
) {
Button {
Task {
if debridManager.enabledDebrids.contains(debridType) {
await debridManager.logoutDebrid(debridType: debridType)
} else if !debridManager.getAuthProcessingBool(debridType: debridType) {
await debridManager.authenticateDebrid(debridType: debridType)
}
}
} label: {
Text(
debridManager.enabledDebrids.contains(debridType)
? "Logout"
: (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login")
)
.foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue)
}
Link("Website", destination: URL(string: debridType.website()) ?? URL(string: "https://kingbri.dev/ferrite")!)
}
}
Section(
header: InlineHeader("Login status"),
footer: Text("A WebView will show up to prompt you for credentials")
) {
Button {
Task {
if debridManager.enabledDebrids.contains(debridType) {
await debridManager.logoutDebrid(debridType: debridType)
} else if !debridManager.getAuthProcessingBool(debridType: debridType) {
await debridManager.authenticateDebrid(debridType: debridType)
}
}
} label: {
Text(
debridManager.enabledDebrids.contains(debridType)
? "Logout"
: (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login")
)
.foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue)
}
}
.navigationTitle(debridType.toString())
.navigationBarTitleDisplayMode(.inline)
}
.navigationTitle(debridType.toString())
.navigationBarTitleDisplayMode(.inline)
}
}

View file

@ -1,61 +0,0 @@
//
// SettingsKodiView.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import SwiftUI
struct SettingsKodiView: View {
@AppStorage("ExternalServices.KodiUrl") var kodiUrl: String = ""
@AppStorage("ExternalServices.KodiUsername") var kodiUsername: String = ""
@AppStorage("ExternalServices.KodiPassword") var kodiPassword: String = ""
@State private var showPassword = false
var body: some View {
NavView {
List {
Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) {
Text("Kodi is an external application that is used to manage a local media library and playback.")
Link("Website", destination: URL(string: "https://kodi.tv")!)
}
}
Section(
header: InlineHeader("Base URL"),
footer: Text("Enter your Kodi server's http URL here including the port.")
) {
TextField("http://...", text: $kodiUrl, onEditingChanged: { isFocused in
if !isFocused, kodiUrl.last == "/" {
kodiUrl = String(kodiUrl.dropLast())
}
})
.keyboardType(.URL)
.autocorrectionDisabled(true)
.autocapitalization(.none)
}
Section(
header: InlineHeader("Credentials"),
footer: Text("Enter your kodi username and password here (if applicable)")
) {
TextField("Username", text: $kodiUsername)
HybridSecureField(text: $kodiPassword)
}
}
.navigationTitle("Kodi")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct SettingsKodiView_Previews: PreviewProvider {
static var previews: some View {
SettingsKodiView()
}
}

View file

@ -11,51 +11,49 @@ struct SettingsLogView: View {
@EnvironmentObject var logManager: LoggingManager
var body: some View {
NavView {
List {
ForEach($logManager.messageArray, id: \.self) { $log in
Text(log.toMessage())
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(log.isExpanded ? nil : 5)
.onTapGesture {
log.isExpanded.toggle()
}
}
}
.listStyle(.plain)
.backport.alert(
isPresented: $logManager.showLogExportedAlert,
title: "Success",
message: "Log successfully exported in Ferrite's logs folder"
)
.navigationTitle("Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
logManager.exportLogs()
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
if #available(iOS 15, *) {
Button(role: .destructive) {
logManager.messageArray = []
} label: {
Label("Clear session logs", systemImage: "trash")
}
} else {
Button {
logManager.messageArray = []
} label: {
Label("Clear session logs", systemImage: "trash")
}
}
} label: {
Image(systemName: "ellipsis")
List {
ForEach($logManager.messageArray, id: \.self) { $log in
Text(log.toMessage())
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(log.isExpanded ? nil : 5)
.onTapGesture {
log.isExpanded.toggle()
}
}
}
.listStyle(.plain)
.backport.alert(
isPresented: $logManager.showLogExportedAlert,
title: "Success",
message: "Log successfully exported in Ferrite's logs folder"
)
.navigationTitle("Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
logManager.exportLogs()
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
if #available(iOS 15, *) {
Button(role: .destructive) {
logManager.messageArray = []
} label: {
Label("Clear session logs", systemImage: "trash")
}
} else {
Button {
logManager.messageArray = []
} label: {
Label("Clear session logs", systemImage: "trash")
}
}
} label: {
Image(systemName: "ellipsis")
}
}
}

View file

@ -12,17 +12,8 @@ struct LibraryView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Bookmark.entity(),
sortDescriptors: []
) var bookmarks: FetchedResults<Bookmark>
@FetchRequest(
entity: History.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \History.date, ascending: false)
]
) var history: FetchedResults<History>
@State private var bookmarksEmpty = false
@State private var historyEmpty = false
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@ -37,9 +28,9 @@ struct LibraryView: View {
ZStack {
switch navModel.libraryPickerSelection {
case .bookmarks:
BookmarksView(searchText: $searchText)
BookmarksView(searchText: $searchText, bookmarksEmpty: $bookmarksEmpty)
case .history:
HistoryView(history: history, searchText: $searchText)
HistoryView(searchText: $searchText, historyEmpty: $historyEmpty)
case .debridCloud:
DebridCloudView(searchText: $searchText)
}
@ -47,11 +38,11 @@ struct LibraryView: View {
.overlay {
switch navModel.libraryPickerSelection {
case .bookmarks:
if bookmarks.isEmpty {
if bookmarksEmpty {
EmptyInstructionView(title: "No Bookmarks", message: "Add a bookmark from search results")
}
case .history:
if history.isEmpty {
if historyEmpty {
EmptyInstructionView(title: "No History", message: "Start watching to build history")
}
case .debridCloud:

View file

@ -12,18 +12,24 @@ struct PluginsView: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
/*
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
*/
/*
@FetchRequest(
entity: Action.entity(),
sortDescriptors: []
) var actions: FetchedResults<Action>
*/
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var installedSourcesEmpty = false
@State private var installedActionsEmpty = false
@State private var checkedForPlugins = false
@State private var isEditingSearch = false
@ -38,9 +44,15 @@ struct PluginsView: View {
if checkedForPlugins {
switch navModel.pluginPickerSelection {
case .sources:
PluginListView<Source, SourceJson>(searchText: $searchText)
PluginAggregateView<Source, SourceJson>(
searchText: $searchText,
pluginsEmpty: $installedSourcesEmpty
)
case .actions:
PluginListView<Action, ActionJson>(searchText: $searchText)
PluginAggregateView<Action, ActionJson>(
searchText: $searchText,
pluginsEmpty: $installedActionsEmpty
)
}
}
}
@ -48,11 +60,11 @@ struct PluginsView: View {
if checkedForPlugins {
switch navModel.pluginPickerSelection {
case .sources:
if sources.isEmpty, pluginManager.availableSources.isEmpty {
if installedSourcesEmpty, pluginManager.availableSources.isEmpty {
EmptyInstructionView(title: "No Sources", message: "Add a plugin list in Settings")
}
case .actions:
if actions.isEmpty, pluginManager.availableActions.isEmpty {
if installedActionsEmpty, pluginManager.availableActions.isEmpty {
EmptyInstructionView(title: "No Actions", message: "Add a plugin list in Settings")
}
}

View file

@ -15,6 +15,11 @@ struct SettingsView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@FetchRequest(
entity: KodiServer.entity(),
sortDescriptors: []
) var kodiServers: FetchedResults<KodiServer>
@AppStorage("ExternalServices.KodiUrl") var kodiUrl: String = ""
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@ -55,7 +60,7 @@ struct SettingsView: View {
HStack {
Text("Kodi")
Spacer()
Text(kodiUrl.isEmpty ? "Disabled" : "Enabled")
Text(kodiServers.isEmpty ? "Disabled" : "Enabled")
.foregroundColor(.secondary)
}
})

View file

@ -21,7 +21,10 @@ struct ActionChoiceView: View {
sortDescriptors: []
) var actions: FetchedResults<Action>
@AppStorage("ExternalServices.KodiUrl") var kodiUrl: String = ""
@FetchRequest(
entity: KodiServer.entity(),
sortDescriptors: []
) var kodiServers: FetchedResults<KodiServer>
@State private var showLinkCopyAlert = false
@State private var showMagnetCopyAlert = false
@ -53,12 +56,20 @@ struct ActionChoiceView: View {
}
}
if !kodiUrl.isEmpty {
ListRowButtonView("Open in Kodi", systemImage: "arrow.up.forward.app.fill") {
Task {
await pluginManager.sendToKodi(urlString: debridManager.downloadUrl)
if !kodiServers.isEmpty {
DisclosureGroup("Open in Kodi") {
ForEach(kodiServers, id: \.self) { server in
Button {
Task {
await pluginManager.sendToKodi(urlString: debridManager.downloadUrl)
}
} label: {
KodiServerView(server: server)
}
.backport.tint(.primary)
}
}
.backport.tint(.secondary)
}
ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") {