Ferrite: Add Kodi support

Adds support for playing links on a preset Kodi server. This is
less featured than the Ferrite companion, but should still work
without a problem.

Signed-off-by: kingbri <bdashore3@proton.me>
This commit is contained in:
kingbri 2023-03-05 01:05:18 -05:00
parent 438e48be66
commit b8799be896
15 changed files with 290 additions and 26 deletions

View file

@ -55,6 +55,10 @@
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; };
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */; };
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */; };
0C6771FC29B3E0DB005D38D2 /* HybridSecureField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */; };
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; };
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; };
@ -178,6 +182,10 @@
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = "<group>"; };
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; };
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; };
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; };
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HybridSecureField.swift; sourceTree = "<group>"; };
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = "<group>"; };
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = "<group>"; };
@ -346,6 +354,7 @@
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
);
path = Models;
sourceTree = "<group>";
@ -437,6 +446,7 @@
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */,
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -475,6 +485,7 @@
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
0C2D9652299316CC00A504B6 /* Tag.swift */,
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */,
);
path = CommonViews;
sourceTree = "<group>";
@ -554,6 +565,7 @@
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */,
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
);
path = API;
sourceTree = "<group>";
@ -715,6 +727,7 @@
files = (
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */,
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
@ -777,6 +790,7 @@
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
0CA148E3288903F000DE2211 /* Task.swift in Sources */,
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */,
0C6771FC29B3E0DB005D38D2 /* HybridSecureField.swift in Sources */,
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */,
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
@ -803,6 +817,7 @@
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
@ -815,6 +830,7 @@
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,

View file

@ -0,0 +1,51 @@
//
// KodiWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import Foundation
public class Kodi {
let encoder = JSONEncoder()
public func sendVideoUrl(urlString: String) async throws {
guard let baseUrl = UserDefaults.standard.string(forKey: "ExternalServices.KodiUrl") else {
throw KodiError.InvalidBaseUrl
}
if URL(string: urlString) == nil {
throw KodiError.InvalidPlaybackUrl
}
let username = UserDefaults.standard.string(forKey: "ExternalServices.KodiUsername")
let password = UserDefaults.standard.string(forKey: "ExternalServices.KodiPassword")
let requestBody = RPCPayload(
method: "Player.Open",
params: Params(item: Item(file: urlString))
)
var request = URLRequest(url: URL(string: "\(baseUrl)/jsonrpc")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let username, let 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 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).")
}
}
}

View file

@ -0,0 +1,35 @@
//
// KodiModels.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import Foundation
extension Kodi {
enum KodiError: Error {
case InvalidBaseUrl
case InvalidPlaybackUrl
case InvalidPostBody
case FailedRequest(description: String)
}
// MARK: - RPC payload
struct RPCPayload: Encodable {
let jsonrpc: String = "2.0"
let id: String = "1"
let method: String
let params: Params?
}
// MARK: - RPC Params
struct Params: Codable {
let item: Item
}
// MARK: - RPC Item
struct Item: Codable {
let file: String
}
}

View file

@ -10,11 +10,16 @@ import SwiftUI
public class PluginManager: ObservableObject {
var toastModel: ToastViewModel?
let kodi: Kodi = .init()
@Published var availableSources: [SourceJson] = []
@Published var availableActions: [ActionJson] = []
@Published var showBrokenDefaultActionAlert = false
@Published var showActionErrorAlert = false
@Published var actionErrorAlertMessage: String = ""
@Published var showActionSuccessAlert = false
@Published var actionSuccessAlertMessage: String = ""
@MainActor
public func fetchPluginsFromUrl() async {
@ -173,7 +178,7 @@ public class PluginManager: ObservableObject {
}
@MainActor
public func runDebridAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) {
public func runDebridAction(urlString: String?, navModel: NavigationViewModel) {
let context = PersistenceController.shared.backgroundContext
if
@ -187,19 +192,22 @@ public class PluginManager: ObservableObject {
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
runDeeplinkAction(fetchedAction, urlString: urlString)
} else {
currentChoiceSheet = .action
navModel.currentChoiceSheet = .action
UserDefaults.standard.set(nil, forKey: "Actions.DefaultDebridName")
UserDefaults.standard.set(nil, forKey: "Action.DefaultDebridList")
showBrokenDefaultActionAlert.toggle()
actionErrorAlertMessage =
"The default action could not be run. The action choice sheet has been opened. \n\n" +
"Please check your default actions in Settings"
showActionErrorAlert.toggle()
}
} else {
currentChoiceSheet = .action
navModel.currentChoiceSheet = .action
}
}
@MainActor
public func runMagnetAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) {
public func runMagnetAction(urlString: String?, navModel: NavigationViewModel) {
let context = PersistenceController.shared.backgroundContext
if
@ -213,14 +221,17 @@ public class PluginManager: ObservableObject {
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
runDeeplinkAction(fetchedAction, urlString: urlString)
} else {
currentChoiceSheet = .action
navModel.currentChoiceSheet = .action
UserDefaults.standard.set(nil, forKey: "Actions.DefaultMagnetName")
UserDefaults.standard.set(nil, forKey: "Actions.DefaultMagnetList")
showBrokenDefaultActionAlert.toggle()
actionErrorAlertMessage =
"The default action could not be run. The action choice sheet has been opened. \n\n" +
"Please check your default actions in Settings"
showActionErrorAlert.toggle()
}
} else {
currentChoiceSheet = .action
navModel.currentChoiceSheet = .action
}
}
@ -228,7 +239,9 @@ public class PluginManager: ObservableObject {
@MainActor
public func runDeeplinkAction(_ action: Action, urlString: String?) {
guard let deeplink = action.deeplink, let urlString else {
toastModel?.updateToastDescription("Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!")
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
showActionErrorAlert.toggle()
print("Could not run action: \(action.name) since there is no deeplink to execute.")
return
@ -239,11 +252,37 @@ public class PluginManager: ObservableObject {
if let playbackUrl {
UIApplication.shared.open(playbackUrl)
} else {
toastModel?.updateToastDescription("Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!")
actionErrorAlertMessage = "Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!"
showActionErrorAlert.toggle()
print("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
}
}
@MainActor
public func sendToKodi(urlString: String?) async {
guard let urlString else {
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
showActionErrorAlert.toggle()
print("Could not send URL to Kodi since there is no playback URL to send")
return
}
do {
try await kodi.sendVideoUrl(urlString: urlString)
actionSuccessAlertMessage = "Your URL should be playing on Kodi"
showActionSuccessAlert.toggle()
} catch {
actionErrorAlertMessage = "Kodi Error: \(error)"
showActionErrorAlert.toggle()
print("Kodi action error: \(error)")
}
}
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else {
await toastModel?.updateToastDescription("Action addition error: No action present. Contact the app dev!")

View file

@ -0,0 +1,34 @@
//
// HybridSecureField.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import SwiftUI
struct HybridSecureField: View {
@Binding var text: String
@State private var showPassword = false
var body: some View {
HStack {
Group {
if showPassword {
TextField("Password", text: $text)
} else {
SecureField("Password", text: $text)
}
}
.autocorrectionDisabled(true)
.autocapitalization(.none)
Button {
showPassword.toggle()
} label: {
Image(systemName: self.showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
}
}
}
}

View file

@ -41,7 +41,7 @@ struct AllDebridCloudView: View {
PersistenceController.shared.createHistory(historyInfo, performSave: true)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}
}

View file

@ -41,7 +41,7 @@ struct PremiumizeCloudView: View {
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}
}

View file

@ -38,7 +38,7 @@ struct RealDebridCloudView: View {
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}
.backport.tint(.primary)
@ -78,7 +78,7 @@ struct RealDebridCloudView: View {
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}
}

View file

@ -26,7 +26,7 @@ struct HistoryButtonView: View {
debridManager.downloadUrl = url
pluginManager.runDebridAction(
urlString: url,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
if navModel.currentChoiceSheet != .action {
@ -36,7 +36,7 @@ struct HistoryButtonView: View {
} else {
pluginManager.runMagnetAction(
urlString: url,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}
} else {

View file

@ -133,6 +133,7 @@ struct SourceSettingsApiView: View {
clientId.timeStamp = Date()
}
})
.autocorrectionDisabled(true)
.autocapitalization(.none)
.backport.onAppear {
tempClientId = clientId.value ?? ""
@ -146,6 +147,7 @@ struct SourceSettingsApiView: View {
clientSecret.timeStamp = Date()
}
})
.autocorrectionDisabled(true)
.autocapitalization(.none)
.backport.onAppear {
tempClientSecret = clientSecret.value ?? ""

View file

@ -45,7 +45,7 @@ struct SearchResultButtonView: View {
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
if navModel.currentChoiceSheet != .action {
@ -74,7 +74,7 @@ struct SearchResultButtonView: View {
pluginManager.runMagnetAction(
urlString: result.magnet.link,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}
}

View file

@ -0,0 +1,61 @@
//
// 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

@ -15,6 +15,8 @@ struct SettingsView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("ExternalServices.KodiUrl") var kodiUrl: String = ""
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false
@ -29,7 +31,7 @@ struct SettingsView: View {
var body: some View {
NavView {
Form {
Section(header: InlineHeader("Debrid Services")) {
Section(header: InlineHeader("Debrid services")) {
HStack {
Text("RealDebrid")
Spacer()
@ -82,6 +84,17 @@ struct SettingsView: View {
}
}
Section(header: InlineHeader("Playback services")) {
NavigationLink(destination: SettingsKodiView(), label: {
HStack {
Text("Kodi")
Spacer()
Text(kodiUrl.isEmpty ? "Disabled" : "Enabled")
.foregroundColor(.secondary)
}
})
}
Section(header: InlineHeader("Behavior")) {
Toggle(isOn: $autocorrectSearch) {
Text("Autocorrect search")

View file

@ -21,6 +21,8 @@ struct ActionChoiceView: View {
sortDescriptors: []
) var actions: FetchedResults<Action>
@AppStorage("ExternalServices.KodiUrl") var kodiUrl: String = ""
@State private var showLinkCopyAlert = false
@State private var showMagnetCopyAlert = false
@ -51,6 +53,14 @@ struct ActionChoiceView: View {
}
}
if !kodiUrl.isEmpty {
ListRowButtonView("Open in Kodi", systemImage: "arrow.up.forward.app.fill") {
Task {
await pluginManager.sendToKodi(urlString: debridManager.downloadUrl)
}
}
}
ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = debridManager.downloadUrl
showLinkCopyAlert.toggle()
@ -113,11 +123,14 @@ struct ActionChoiceView: View {
}
}
.backport.alert(
isPresented: $pluginManager.showBrokenDefaultActionAlert,
title: "Action not found",
message:
"The default action could not be run. The action choice sheet has been opened. \n\n" +
"Please check your default actions in Settings"
isPresented: $pluginManager.showActionSuccessAlert,
title: "Action successful",
message: pluginManager.actionSuccessAlertMessage
)
.backport.alert(
isPresented: $pluginManager.showActionErrorAlert,
title: "Action error",
message: pluginManager.actionErrorAlertMessage
)
.onDisappear {
debridManager.downloadUrl = ""

View file

@ -86,7 +86,7 @@ struct BatchChoiceView: View {
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
navModel: navModel
)
}