diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index de3bb7d..3c2c2bd 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -20,7 +20,6 @@ 0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; }; 0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; }; 0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; }; - 0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; }; 0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */; }; 0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; }; 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; }; @@ -54,7 +53,6 @@ 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; }; 0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; }; 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; }; - 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 */; }; @@ -96,6 +94,7 @@ 0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; }; 0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; }; 0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; }; + 0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; }; 0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; }; 0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; }; 0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; }; @@ -129,12 +128,13 @@ 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; }; 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 */; }; 0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.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 */; }; + 0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */; }; + 0CB725342C123E760047FC0B /* CloudTorrentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudTorrentView.swift */; }; 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; }; 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; @@ -154,6 +154,8 @@ 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; }; 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; }; 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; }; + 0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; }; + 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; }; 0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; }; /* End PBXBuildFile section */ @@ -171,7 +173,6 @@ 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = ""; }; 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = ""; }; - 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = ""; }; 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortFilterView.swift; sourceTree = ""; }; 0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = ""; }; @@ -204,7 +205,6 @@ 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = ""; }; 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = ""; }; 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = ""; }; - 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = ""; }; 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = ""; }; 0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = ""; }; 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = ""; }; @@ -242,6 +242,7 @@ 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = ""; }; 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAmountLabelView.swift; sourceTree = ""; }; 0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = ""; }; + 0C8AE2472C0FFB6600701675 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoView.swift; sourceTree = ""; }; 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsBaseUrlView.swift; sourceTree = ""; }; 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsApiView.swift; sourceTree = ""; }; @@ -275,12 +276,13 @@ 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = ""; }; 0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; 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 = ""; }; 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = ""; }; 0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = ""; }; 0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = ""; }; 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = ""; }; + 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDownloadView.swift; sourceTree = ""; }; + 0CB725332C123E760047FC0B /* CloudTorrentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTorrentView.swift; sourceTree = ""; }; 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = ""; }; 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = ""; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = ""; }; @@ -300,6 +302,8 @@ 0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = ""; }; 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = ""; }; + 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debrid.swift; sourceTree = ""; }; + 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridModels.swift; sourceTree = ""; }; 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -390,6 +394,7 @@ 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */, 0C7ED14028D61BBA009E29AD /* BackupModels.swift */, 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */, + 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */, 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */, 0C68135128BC1A7C00FAD890 /* GithubModels.swift */, 0C422E7F293542F300486D65 /* PremiumizeModels.swift */, @@ -406,9 +411,8 @@ 0C2886D52960C4F800D6FC16 /* Cloud */ = { isa = PBXGroup; children = ( - 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */, - 0CAF9318296399190050812A /* PremiumizeCloudView.swift */, - 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */, + 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */, + 0CB725332C123E760047FC0B /* CloudTorrentView.swift */, ); path = Cloud; sourceTree = ""; @@ -452,6 +456,7 @@ 0C44E2A728D4DDDC007711AE /* Application.swift */, 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */, 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */, + 0C8AE2472C0FFB6600701675 /* Store.swift */, ); path = Utils; sourceTree = ""; @@ -492,6 +497,7 @@ isa = PBXGroup; children = ( 0CE1C4172981E8D700418F20 /* Plugin.swift */, + 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */, ); path = Protocols; sourceTree = ""; @@ -847,11 +853,11 @@ 0C5005522992B6750064606A /* PluginTagsView.swift in Sources */, 0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */, 0CF2C0A529D1EBD400E716DD /* UIApplication.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 */, + 0C8AE2482C0FFB6600701675 /* Store.swift in Sources */, 0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, 0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */, @@ -865,10 +871,10 @@ 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */, 0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */, 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */, - 0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */, 0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */, + 0CB725342C123E760047FC0B /* CloudTorrentView.swift in Sources */, 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */, 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */, 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */, @@ -895,7 +901,6 @@ 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */, 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */, 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */, - 0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */, 0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */, 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */, 0CA148E6288903F000DE2211 /* WebView.swift in Sources */, @@ -923,6 +928,7 @@ 0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */, 0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */, 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */, + 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */, 0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */, 0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */, 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */, @@ -942,11 +948,13 @@ 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */, 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */, 0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */, + 0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */, 0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */, 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */, 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */, 0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */, 0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */, + 0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */, 0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */, 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */, 0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */, diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index 2b850ff..eaa9133 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -8,27 +8,60 @@ import Foundation // TODO: Fix errors -public class AllDebrid { - let jsonDecoder = JSONDecoder() +public class AllDebrid: PollingDebridSource, ObservableObject { + public let id = "AllDebrid" + public let abbreviation = "AD" + public let website = "https://alldebrid.com" + public var authTask: Task? + + public var authProcessing: Bool = false + public var isLoggedIn: Bool { + getToken() != nil + } + + public var manualToken: String? { + if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published public var IAValues: [DebridIA] = [] + @Published public var cloudDownloads: [DebridCloudDownload] = [] + @Published public var cloudTorrents: [DebridCloudTorrent] = [] + public var cloudTTL: Double = 0.0 let baseApiUrl = "https://api.alldebrid.com/v4" let appName = "Ferrite" - var authTask: Task? + let jsonDecoder = JSONDecoder() + + // MARK: - Auth // Fetches information for PIN auth - public func getPinInfo() async throws -> PinResponse { + public func getAuthUrl() async throws -> URL { let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get") let request = URLRequest(url: url) do { let (data, _) = try await URLSession.shared.data(for: request) - let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data - return rawResponse + // Validate the URL before doing anything else + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + guard let userUrl = URL(string: rawResponse.userURL) else { + throw DebridError.AuthQuery(description: "The login URL is invalid") + } + + // Spawn the polling task separately + authTask = Task { + try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin) + } + + return userUrl } catch { print("Couldn't get pin information!") - throw ADError.AuthQuery(description: error.localizedDescription) + throw DebridError.AuthQuery(description: error.localizedDescription) } } @@ -48,7 +81,7 @@ public class AllDebrid { while count < 12 { if Task.isCancelled { - throw ADError.AuthQuery(description: "Token request cancelled.") + throw DebridError.AuthQuery(description: "Token request cancelled.") } let (data, _) = try await URLSession.shared.data(for: request) @@ -67,7 +100,7 @@ public class AllDebrid { } } - throw ADError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") + throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") } if case let .failure(error) = await authTask?.result { @@ -76,27 +109,27 @@ public class AllDebrid { } // Adds a manual API key instead of web auth - public func setApiKey(_ key: String) -> Bool { + public func setApiKey(_ key: String) { FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey") UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey") - - return FerriteKeychain.shared.get("AllDebrid.ApiKey") == key } public func getToken() -> String? { - return FerriteKeychain.shared.get("AllDebrid.ApiKey") + FerriteKeychain.shared.get("AllDebrid.ApiKey") } // Clears tokens. No endpoint to deregister a device - public func deleteTokens() { + public func logout() { FerriteKeychain.shared.delete("AllDebrid.ApiKey") UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey") } + // MARK: - Common request + // Wrapper request function which matches the responses and returns data @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { guard let token = getToken() else { - throw ADError.InvalidToken + throw DebridError.InvalidToken } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -104,23 +137,22 @@ public class AllDebrid { let (data, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { - throw ADError.FailedRequest(description: "No HTTP response given") + throw DebridError.FailedRequest(description: "No HTTP response given") } if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { - deleteTokens() - throw ADError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.") + throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.") } else { - throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") } } // Builds a URL for further requests private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL { guard var components = URLComponents(string: urlString) else { - throw ADError.InvalidUrl + throw DebridError.InvalidUrl } components.queryItems = [ @@ -130,14 +162,84 @@ public class AllDebrid { if let url = components.url { return url } else { - throw ADError.InvalidUrl + throw DebridError.InvalidUrl } } + // MARK: - Instant availability + + public func instantAvailability(magnets: [Magnet]) async throws { + let now = Date().timeIntervalSince1970 + + let sendMagnets = magnets.filter { magnet in + if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) { + if now > IAValues[IAIndex].expiryTimeStamp { + IAValues.remove(at: IAIndex) + return true + } else { + return false + } + } else { + return true + } + } + + if sendMagnets.isEmpty { + return + } + + let queryItems = sendMagnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) } + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems)) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil } + let availableHashes = filteredMagnets.map { magnetResp in + // Force unwrap is OK here since the filter caught any nil values + let files = magnetResp.files!.enumerated().map { index, magnetFile in + DebridIAFile(fileId: index, name: magnetFile.name) + } + + return DebridIA( + magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet), + source: self.id, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + } + + IAValues += availableHashes + } + + // MARK: - Downloading + + // Wrapper function to fetch a download link from the API + public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String { + let selectedMagnetId: String + + if let existingMagnet = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "Ready" }) { + selectedMagnetId = existingMagnet.torrentId + } else { + let magnetId = try await addMagnet(magnet: magnet) + selectedMagnetId = String(magnetId) + } + + let lockedLink = try await fetchMagnetStatus( + magnetId: selectedMagnetId, + selectedIndex: iaFile?.fileId ?? 0 + ) + + try await saveLink(link: lockedLink) + let downloadUrl = try await unlockLink(lockedLink: lockedLink) + + return downloadUrl + } + // Adds a magnet link to the user's AD account public func addMagnet(magnet: Magnet) async throws -> Int { guard let magnetLink = magnet.link else { - throw ADError.FailedRequest(description: "The magnet link is invalid") + throw DebridError.FailedRequest(description: "The magnet link is invalid") } var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload")) @@ -157,13 +259,13 @@ public class AllDebrid { if let magnet = rawResponse.magnets[safe: 0] { return magnet.id } else { - throw ADError.InvalidResponse + throw DebridError.InvalidResponse } } - public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String { + public func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> String { let queryItems = [ - URLQueryItem(name: "id", value: String(magnetId)) + URLQueryItem(name: "id", value: magnetId) ] var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems)) @@ -174,32 +276,10 @@ public class AllDebrid { if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] { return linkWrapper.link } else { - throw ADError.EmptyTorrents + throw DebridError.EmptyTorrents } } - public func userMagnets() async throws -> [MagnetStatusData] { - var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status")) - - let data = try await performRequest(request: &request, requestName: #function) - let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data - - if rawResponse.magnets.isEmpty { - throw ADError.EmptyData - } else { - return rawResponse.magnets - } - } - - public func deleteMagnet(magnetId: Int) async throws { - let queryItems = [ - URLQueryItem(name: "id", value: String(magnetId)) - ] - var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems)) - - try await performRequest(request: &request, requestName: #function) - } - public func unlockLink(lockedLink: String) async throws -> String { let queryItems = [ URLQueryItem(name: "link", value: lockedLink) @@ -221,49 +301,74 @@ public class AllDebrid { try await performRequest(request: &request, requestName: #function) } - public func savedLinks() async throws -> [SavedLink] { + // MARK: - Cloud methods + + // Referred to as "User magnets" in AllDebrid's API + public func getUserTorrents() async throws { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status")) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + if rawResponse.magnets.isEmpty { + throw DebridError.EmptyData + } + + cloudTorrents = rawResponse.magnets.map { magnetResponse in + DebridCloudTorrent( + torrentId: String(magnetResponse.id), + source: self.id, + fileName: magnetResponse.filename, + status: magnetResponse.status, + hash: magnetResponse.hash, + links: magnetResponse.links.map(\.link) + ) + } + } + + public func deleteTorrent(torrentId: String?) async throws { + guard let torrentId else { + throw DebridError.FailedRequest(description: "The torrentID \(String(describing: torrentId)) is invalid") + } + + let queryItems = [ + URLQueryItem(name: "id", value: torrentId) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems)) + + try await performRequest(request: &request, requestName: #function) + } + + public func getUserDownloads() async throws { var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links")) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data if rawResponse.links.isEmpty { - throw ADError.EmptyData - } else { - return rawResponse.links + throw DebridError.EmptyData + } + + // The link is also the ID + cloudDownloads = rawResponse.links.map { link in + DebridCloudDownload( + downloadId: link.link, source: self.id, fileName: link.filename, link: link.link + ) } } - public func deleteLink(link: String) async throws { + // Not used + public func checkUserDownloads(link: String) async throws -> String? { + nil + } + + // The downloadId is actually the download link + public func deleteDownload(downloadId: String) async throws { let queryItems = [ - URLQueryItem(name: "link", value: link) + URLQueryItem(name: "link", value: downloadId) ] var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems)) try await performRequest(request: &request, requestName: #function) } - - public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { - let queryItems = magnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) } - var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems)) - - let data = try await performRequest(request: &request, requestName: #function) - let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data - - let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil } - let availableHashes = filteredMagnets.map { magnetResp in - // Force unwrap is OK here since the filter caught any nil values - let files = magnetResp.files!.enumerated().map { index, magnetFile in - IAFile(id: index, fileName: magnetFile.name) - } - - return IA( - magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet), - expiryTimeStamp: Date().timeIntervalSince1970 + 300, - files: files - ) - } - - return availableHashes - } } diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index 3ad235c..4f1803e 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -7,14 +7,37 @@ import Foundation -public class Premiumize { - let jsonDecoder = JSONDecoder() +public class Premiumize: OAuthDebridSource, ObservableObject { + public let id = "Premiumize" + public let abbreviation = "PM" + public let website = "https://premiumize.me" + @Published public var authProcessing: Bool = false + public var isLoggedIn: Bool { + getToken() != nil + } + + public var manualToken: String? { + if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published public var IAValues: [DebridIA] = [] + @Published public var cloudDownloads: [DebridCloudDownload] = [] + @Published public var cloudTorrents: [DebridCloudTorrent] = [] + public var cloudTTL: Double = 0.0 let baseAuthUrl = "https://www.premiumize.me/authorize" let baseApiUrl = "https://www.premiumize.me/api" let clientId = "791565696" - public func buildAuthUrl() throws -> URL { + let jsonDecoder = JSONDecoder() + + // MARK: - Auth + + public func getAuthUrl() throws -> URL { var urlComponents = URLComponents(string: baseAuthUrl)! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: clientId), @@ -25,7 +48,7 @@ public class Premiumize { if let url = urlComponents.url { return url } else { - throw PMError.InvalidUrl + throw DebridError.InvalidUrl } } @@ -33,41 +56,41 @@ public class Premiumize { let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) guard let callbackFragment = callbackComponents?.fragment else { - throw PMError.InvalidResponse + throw DebridError.InvalidResponse } var fragmentComponents = URLComponents() fragmentComponents.query = callbackFragment guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else { - throw PMError.InvalidToken + throw DebridError.InvalidToken } FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken") } // Adds a manual API key instead of web auth - public func setApiKey(_ key: String) -> Bool { + public func setApiKey(_ key: String) { FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken") UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey") - - return FerriteKeychain.shared.get("Premiumize.AccessToken") == key } public func getToken() -> String? { - return FerriteKeychain.shared.get("Premiumize.AccessToken") + FerriteKeychain.shared.get("Premiumize.AccessToken") } // Clears tokens. No endpoint to deregister a device - public func deleteTokens() { + public func logout() { FerriteKeychain.shared.delete("Premiumize.AccessToken") UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey") } + // MARK: - Common request + // Wrapper request function which matches the responses and returns data @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { guard let token = getToken() else { - throw PMError.InvalidToken + throw DebridError.InvalidToken } // Use the API query parameter if a manual API key is present @@ -76,7 +99,7 @@ public class Premiumize { let requestUrl = request.url, var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false) else { - throw PMError.InvalidUrl + throw DebridError.InvalidUrl } let apiTokenItem = URLQueryItem(name: "apikey", value: token) @@ -95,16 +118,110 @@ public class Premiumize { let (data, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { - throw PMError.FailedRequest(description: "No HTTP response given") + throw DebridError.FailedRequest(description: "No HTTP response given") } if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { - deleteTokens() - throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.") + throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.") } else { - throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + } + } + + // MARK: - Instant availability + + public func instantAvailability(magnets: [Magnet]) async throws { + let now = Date().timeIntervalSince1970 + + // Remove magnets that don't have an associated link for PM along with existing TTL logic + let sendMagnets = magnets.filter { magnet in + if magnet.link == nil { + return false + } + + if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) { + if now > IAValues[IAIndex].expiryTimeStamp { + IAValues.remove(at: IAIndex) + return true + } else { + return false + } + } else { + return true + } + } + + if sendMagnets.isEmpty { + return + } + + let availableMagnets = try await divideCacheRequests(magnets: sendMagnets) + + // Split DDL requests into chunks of 10 + for chunk in availableMagnets.chunked(into: 10) { + let tempIA = try await divideDDLRequests(magnetChunk: chunk) + IAValues += tempIA + } + } + + // Function to divide and execute DDL endpoint requests in parallel + // Calls this for 10 requests at a time to not overwhelm API servers + public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] { + let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in + for magnet in magnetChunk { + group.addTask { + try await self.fetchDDL(magnet: magnet) + } + } + + var chunkedIA: [DebridIA] = [] + for try await ia in group { + chunkedIA.append(ia) + } + return chunkedIA + } + + return tempIA + } + + // Grabs DDL links + func fetchDDL(magnet: Magnet) async throws -> DebridIA { + if magnet.hash == nil { + throw DebridError.EmptyData + } + + var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var bodyComponents = URLComponents() + bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)] + + request.httpBody = bodyComponents.query?.data(using: .utf8) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data) + let content = rawResponse.content ?? [] + + if !content.isEmpty { + let files = content.map { file in + DebridIAFile( + fileId: 0, + name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path, + streamUrlString: file.link + ) + } + + return DebridIA( + magnet: magnet, + source: id, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + } else { + throw DebridError.EmptyData } } @@ -134,7 +251,7 @@ public class Premiumize { var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")! urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) } guard let url = urlComponents.url else { - throw PMError.InvalidUrl + throw DebridError.InvalidUrl } var request = URLRequest(url: url) @@ -143,7 +260,7 @@ public class Premiumize { let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data) if rawResponse.response.isEmpty { - throw PMError.EmptyData + throw DebridError.EmptyData } else { let availableMagnets = magnets.enumerated().compactMap { index, magnet in if rawResponse.response[safe: index] == true { @@ -157,65 +274,25 @@ public class Premiumize { } } - // Function to divide and execute DDL endpoint requests in parallel - // Calls this for 10 requests at a time to not overwhelm API servers - public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [IA] { - let tempIA = try await withThrowingTaskGroup(of: Premiumize.IA.self) { group in - for magnet in magnetChunk { - group.addTask { - try await self.fetchDDL(magnet: magnet) - } - } + // MARK: - Downloading - var chunkedIA: [Premiumize.IA] = [] - for try await ia in group { - chunkedIA.append(ia) - } - return chunkedIA - } + // Wrapper function to fetch a DDL link from the API + public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String { + // Store the item in PM cloud for later use + try await createTransfer(magnet: magnet) - return tempIA - } - - // Grabs DDL links - func fetchDDL(magnet: Magnet) async throws -> IA { - if magnet.hash == nil { - throw PMError.EmptyData - } - - var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - var bodyComponents = URLComponents() - bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)] - - request.httpBody = bodyComponents.query?.data(using: .utf8) - - let data = try await performRequest(request: &request, requestName: #function) - let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data) - - if !rawResponse.content.isEmpty { - let files = rawResponse.content.map { file in - IAFile( - name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path, - streamUrlString: file.link - ) - } - - return IA( - magnet: magnet, - expiryTimeStamp: Date().timeIntervalSince1970 + 300, - files: files - ) + if let iaFile, let streamUrlString = iaFile.streamUrlString { + return streamUrlString + } else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0], let streamUrlString = firstFile.streamUrlString { + return streamUrlString } else { - throw PMError.EmptyData + throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API") } } func createTransfer(magnet: Magnet) async throws { guard let magnetLink = magnet.link else { - throw PMError.FailedRequest(description: "The magnet link is invalid") + throw DebridError.FailedRequest(description: "The magnet link is invalid") } var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!) @@ -230,24 +307,29 @@ public class Premiumize { try await performRequest(request: &request, requestName: #function) } - func userItems() async throws -> [UserItem] { + // MARK: - Cloud methods + + public func getUserDownloads() async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data) if rawResponse.files.isEmpty { - throw PMError.EmptyData + throw DebridError.EmptyData } - return rawResponse.files + // The "link" is the ID for Premiumize + cloudDownloads = rawResponse.files.map { file in + DebridCloudDownload(downloadId: file.id, source: self.id, fileName: file.name, link: file.id) + } } func itemDetails(itemID: String) async throws -> ItemDetailsResponse { var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")! urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)] guard let url = urlComponents.url else { - throw PMError.InvalidUrl + throw DebridError.InvalidUrl } var request = URLRequest(url: url) @@ -258,16 +340,26 @@ public class Premiumize { return rawResponse } - func deleteItem(itemID: String) async throws { + public func checkUserDownloads(link: String) async throws -> String? { + // Link is the cloud item ID + try await itemDetails(itemID: link).link + } + + public func deleteDownload(downloadId: String) async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var bodyComponents = URLComponents() - bodyComponents.queryItems = [URLQueryItem(name: "id", value: itemID)] + bodyComponents.queryItems = [URLQueryItem(name: "id", value: downloadId)] request.httpBody = bodyComponents.query?.data(using: .utf8) try await performRequest(request: &request, requestName: #function) } + + // No user torrents for Premiumize + public func getUserTorrents() async throws {} + + public func deleteTorrent(torrentId: String?) async throws {} } diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index 1291b2e..fdeccab 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -7,14 +7,37 @@ import Foundation -public class RealDebrid { - let jsonDecoder = JSONDecoder() +public class RealDebrid: PollingDebridSource, ObservableObject { + public let id = "RealDebrid" + public let abbreviation = "RD" + public let website = "https://real-debrid.com" + public var authTask: Task? + + @Published public var authProcessing: Bool = false + + // Check the manual token since getTokens() is async + public var isLoggedIn: Bool { + FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil + } + + public var manualToken: String? { + if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") { + return FerriteKeychain.shared.get("RealDebrid.AccessToken") + } else { + return nil + } + } + + @Published public var IAValues: [DebridIA] = [] + @Published public var cloudDownloads: [DebridCloudDownload] = [] + @Published public var cloudTorrents: [DebridCloudTorrent] = [] + public var cloudTTL: Double = 0.0 let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" let baseApiUrl = "https://api.real-debrid.com/rest/1.0" let openSourceClientId = "X245A4XAIBGVM" - var authTask: Task? + let jsonDecoder = JSONDecoder() @MainActor func setUserDefaultsValue(_ value: Any, forKey: String) { @@ -26,8 +49,10 @@ public class RealDebrid { UserDefaults.standard.removeObject(forKey: forKey) } + // MARK: - Auth + // Fetches the device code from RD - public func getVerificationInfo() async throws -> DeviceCodeResponse { + public func getAuthUrl() async throws -> URL { var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: openSourceClientId), @@ -35,18 +60,28 @@ public class RealDebrid { ] guard let url = urlComponents.url else { - throw RDError.InvalidUrl + throw DebridError.InvalidUrl } let request = URLRequest(url: url) do { let (data, _) = try await URLSession.shared.data(for: request) + // Validate the URL before doing anything else let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data) - return rawResponse + guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else { + throw DebridError.AuthQuery(description: "The verification URL is invalid") + } + + // Spawn the polling task separately + authTask = Task { + try await getDeviceCredentials(deviceCode: rawResponse.deviceCode) + } + + return directVerificationUrl } catch { print("Couldn't get the new client creds!") - throw RDError.AuthQuery(description: error.localizedDescription) + throw DebridError.AuthQuery(description: error.localizedDescription) } } @@ -59,55 +94,49 @@ public class RealDebrid { ] guard let url = urlComponents.url else { - throw RDError.InvalidUrl + throw DebridError.InvalidUrl } let request = URLRequest(url: url) // Timer to poll RD API for credentials - authTask = Task { - var count = 0 + var count = 0 - while count < 12 { - if Task.isCancelled { - throw RDError.AuthQuery(description: "Token request cancelled.") - } - - let (data, _) = try await URLSession.shared.data(for: request) - - // We don't care if this fails - let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data) - - // If there's a client ID from the response, end the task successfully - if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret { - await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId") - FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret") - - try await getTokens(deviceCode: deviceCode) - - return - } else { - try await Task.sleep(seconds: 5) - count += 1 - } + while count < 12 { + if Task.isCancelled { + throw DebridError.AuthQuery(description: "Token request cancelled.") } - throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") + let (data, _) = try await URLSession.shared.data(for: request) + + // We don't care if this fails + let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data) + + // If there's a client ID from the response, end the task successfully + if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret { + await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId") + FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret") + + try await getApiTokens(deviceCode: deviceCode) + + return + } else { + try await Task.sleep(seconds: 5) + count += 1 + } } - if case let .failure(error) = await authTask?.result { - throw error - } + throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") } // Fetch all tokens for the user and store in FerriteKeychain.shared - public func getTokens(deviceCode: String) async throws { + public func getApiTokens(deviceCode: String) async throws { guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else { - throw RDError.EmptyData + throw DebridError.EmptyData } guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else { - throw RDError.EmptyData + throw DebridError.EmptyData } var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!) @@ -135,13 +164,13 @@ public class RealDebrid { await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") } - public func fetchToken() async -> String? { + public func getToken() async -> String? { let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp") if Date().timeIntervalSince1970 > accessTokenStamp { do { if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") { - try await getTokens(deviceCode: refreshToken) + try await getApiTokens(deviceCode: refreshToken) } } catch { print(error) @@ -154,17 +183,16 @@ public class RealDebrid { // Adds a manual API key instead of web auth // Clear out existing refresh tokens and timestamps - public func setApiKey(_ key: String) -> Bool { + public func setApiKey(_ key: String) { FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken") FerriteKeychain.shared.delete("RealDebrid.RefreshToken") FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp") UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey") - - return FerriteKeychain.shared.get("RealDebrid.AccessToken") == key } - public func deleteTokens() async throws { + // Deletes tokens from device and RD's servers + public func logout() async { FerriteKeychain.shared.delete("RealDebrid.RefreshToken") FerriteKeychain.shared.delete("RealDebrid.ClientSecret") await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") @@ -181,10 +209,12 @@ public class RealDebrid { } } + // MARK: - Common request + // Wrapper request function which matches the responses and returns data @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { - guard let token = await fetchToken() else { - throw RDError.InvalidToken + guard let token = await getToken() else { + throw DebridError.InvalidToken } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -192,24 +222,42 @@ public class RealDebrid { let (data, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { - throw RDError.FailedRequest(description: "No HTTP response given") + throw DebridError.FailedRequest(description: "No HTTP response given") } if response.statusCode >= 200, response.statusCode <= 299 { return data } else if response.statusCode == 401 { - try await deleteTokens() - throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.") + throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.") } else { - throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") } } + // MARK: - Instant availability + // Checks if the magnet is streamable on RD - // Currently does not work for batch links - public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { - var availableHashes: [RealDebrid.IA] = [] - var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.compactMap(\.hash).joined(separator: "/"))")!) + public func instantAvailability(magnets: [Magnet]) async throws { + let now = Date().timeIntervalSince1970 + + let sendMagnets = magnets.filter { magnet in + if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) { + if now > IAValues[IAIndex].expiryTimeStamp { + IAValues.remove(at: IAIndex) + return true + } else { + return false + } + } else { + return true + } + } + + if sendMagnets.isEmpty { + return + } + + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(sendMagnets.compactMap(\.hash).joined(separator: "/"))")!) let data = try await performRequest(request: &request, requestName: #function) @@ -225,7 +273,7 @@ public class RealDebrid { continue } - // Is this a batch + // Is this a batch? if data.rd.count > 1 || data.rd[0].count > 1 { // Batch array let batches = data.rd.map { fileDict in @@ -237,22 +285,18 @@ public class RealDebrid { return RealDebrid.IABatch(files: batchFiles) } - // RD files array - // Possibly sort this in the future, but not sure how at the moment - var files: [RealDebrid.IAFile] = [] + var files: [DebridIAFile] = [] - for index in batches.indices { - let batchFiles = batches[index].files + for batch in batches { + let batchFileIds = batch.files.map(\.id) - for batchFileIndex in batchFiles.indices { - let batchFile = batchFiles[batchFileIndex] - - if !files.contains(where: { $0.name == batchFile.fileName }) { + for batchFile in batch.files { + if !files.contains(where: { $0.fileId == batchFile.id }) { files.append( - RealDebrid.IAFile( + DebridIAFile( + fileId: batchFile.id, name: batchFile.fileName, - batchIndex: index, - batchFileIndex: batchFileIndex + batchIds: batchFileIds ) ) } @@ -260,31 +304,65 @@ public class RealDebrid { } // TTL: 5 minutes - availableHashes.append( - RealDebrid.IA( + IAValues.append( + DebridIA( magnet: Magnet(hash: hash, link: nil), + source: id, expiryTimeStamp: Date().timeIntervalSince1970 + 300, - files: files, - batches: batches + files: files ) ) } else { - availableHashes.append( - RealDebrid.IA( + IAValues.append( + DebridIA( magnet: Magnet(hash: hash, link: nil), - expiryTimeStamp: Date().timeIntervalSince1970 + 300 + source: id, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: [] ) ) } } + } - return availableHashes + // MARK: - Downloading + + // Wrapper function to fetch a download link from the API + public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String { + var selectedMagnetId = "" + + do { + // Don't queue a new job if the torrent already exists + if let existingTorrent = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) { + selectedMagnetId = existingTorrent.torrentId + } else { + selectedMagnetId = try await addMagnet(magnet: magnet) + + try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? []) + } + + // RealDebrid has 1 as the first ID for a file + let torrentLink = try await torrentInfo( + debridID: selectedMagnetId, + selectedFileId: iaFile?.fileId ?? 1 + ) + let downloadLink = try await unrestrictLink(debridDownloadLink: torrentLink) + + return downloadLink + } catch { + if case DebridError.EmptyTorrents = error, !selectedMagnetId.isEmpty { + try? await deleteTorrent(torrentId: selectedMagnetId) + } + + // Re-raise the error to the calling function + throw error + } } // Adds a magnet link to the user's RD account public func addMagnet(magnet: Magnet) async throws -> String { guard let magnetLink = magnet.link else { - throw RDError.FailedRequest(description: "The magnet link is invalid") + throw DebridError.FailedRequest(description: "The magnet link is invalid") } var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!) @@ -323,40 +401,24 @@ public class RealDebrid { } // Gets the info of a torrent from a given ID - public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String { + public func torrentInfo(debridID: String, selectedFileId: Int?) async throws -> String { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data) + let filteredFiles = rawResponse.files.filter { $0.selected == 1 } + let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId }) // Let the user know if a torrent is downloading - if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" { + if let torrentLink = rawResponse.links[safe: linkIndex ?? -1], rawResponse.status == "downloaded" { return torrentLink } else if rawResponse.status == "downloading" || rawResponse.status == "queued" { - throw RDError.EmptyTorrents + throw DebridError.IsCaching } else { - throw RDError.EmptyData + throw DebridError.EmptyTorrents } } - // Gets the user's torrent library - public func userTorrents() async throws -> [UserTorrentsResponse] { - var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!) - - let data = try await performRequest(request: &request, requestName: #function) - let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data) - - return rawResponse - } - - // Deletes a torrent download from RD - public func deleteTorrent(debridID: String) async throws { - var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!) - request.httpMethod = "DELETE" - - try await performRequest(request: &request, requestName: #function) - } - // Downloads link from selectFiles for playback public func unrestrictLink(debridDownloadLink: String) async throws -> String { var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!) @@ -374,18 +436,67 @@ public class RealDebrid { return rawResponse.download } + // MARK: - Cloud methods + + // Gets the user's torrent library + public func getUserTorrents() async throws { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data) + cloudTorrents = rawResponse.map { response in + DebridCloudTorrent( + torrentId: response.id, + source: self.id, + fileName: response.filename, + status: response.status, + hash: response.hash, + links: response.links + ) + } + } + + // Deletes a torrent download from RD + public func deleteTorrent(torrentId: String?) async throws { + let deleteId: String + + if let torrentId { + deleteId = torrentId + } else { + // Refresh the torrent cloud + // The first file is the currently caching one + let _ = try await getUserTorrents() + guard let firstTorrent = cloudTorrents[safe: -1] else { + throw DebridError.EmptyTorrents + } + + deleteId = firstTorrent.torrentId + } + + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!) + request.httpMethod = "DELETE" + + try await performRequest(request: &request, requestName: #function) + } + // Gets the user's downloads - public func userDownloads() async throws -> [UserDownloadsResponse] { + public func getUserDownloads() async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!) let data = try await performRequest(request: &request, requestName: #function) let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data) - - return rawResponse + cloudDownloads = rawResponse.map { response in + DebridCloudDownload(downloadId: response.id, source: self.id, fileName: response.filename, link: response.download) + } } - public func deleteDownload(debridID: String) async throws { - var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!) + // Not used + public func checkUserDownloads(link: String) -> String? { + nil + } + + public func deleteDownload(downloadId: String) async throws { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!) request.httpMethod = "DELETE" try await performRequest(request: &request, requestName: #function) diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift index c12b5c1..3ec1d0e 100644 --- a/Ferrite/Models/AllDebridModels.swift +++ b/Ferrite/Models/AllDebridModels.swift @@ -8,20 +8,6 @@ import Foundation public extension AllDebrid { - // MARK: - Errors - - // TODO: Hybridize debrid errors in one structure - enum ADError: Error { - case InvalidUrl - case InvalidPostBody - case InvalidResponse - case InvalidToken - case EmptyData - case EmptyTorrents - case FailedRequest(description: String) - case AuthQuery(description: String) - } - // MARK: - Generic AllDebrid response // Uses a generic parametr for whatever underlying response is present @@ -166,17 +152,4 @@ public extension AllDebrid { case name = "n" } } - - // MARK: - InstantAvailablity client side structures - - struct IA: Codable, Hashable { - let magnet: Magnet - let expiryTimeStamp: Double - var files: [IAFile] - } - - struct IAFile: Codable, Hashable { - let id: Int - let fileName: String - } } diff --git a/Ferrite/Models/DebridModels.swift b/Ferrite/Models/DebridModels.swift new file mode 100644 index 0000000..31ea356 --- /dev/null +++ b/Ferrite/Models/DebridModels.swift @@ -0,0 +1,57 @@ +// +// DebridModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/2/24. +// + +import Foundation + +public struct DebridIA: Hashable, Sendable { + let magnet: Magnet + let source: String + let expiryTimeStamp: Double + var files: [DebridIAFile] +} + +public struct DebridIAFile: Hashable, Sendable { + let fileId: Int + let name: String + let streamUrlString: String? + let batchIds: [Int] + + init(fileId: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) { + self.fileId = fileId + self.name = name + self.streamUrlString = streamUrlString + self.batchIds = batchIds + } +} + +public struct DebridCloudDownload: Hashable, Sendable { + let downloadId: String + let source: String + let fileName: String + let link: String +} + +public struct DebridCloudTorrent: Hashable, Sendable { + let torrentId: String + let source: String + let fileName: String + let status: String + let hash: String + let links: [String] +} + +public enum DebridError: Error { + case InvalidUrl + case InvalidPostBody + case InvalidResponse + case InvalidToken + case EmptyData + case EmptyTorrents + case IsCaching + case FailedRequest(description: String) + case AuthQuery(description: String) +} diff --git a/Ferrite/Models/PremiumizeModels.swift b/Ferrite/Models/PremiumizeModels.swift index a6f30e7..d160c11 100644 --- a/Ferrite/Models/PremiumizeModels.swift +++ b/Ferrite/Models/PremiumizeModels.swift @@ -8,20 +8,6 @@ import Foundation public extension Premiumize { - // MARK: - Errors - - // TODO: Hybridize debrid errors in one structure - enum PMError: Error { - case InvalidUrl - case InvalidPostBody - case InvalidResponse - case InvalidToken - case EmptyData - case EmptyTorrents - case FailedRequest(description: String) - case AuthQuery(description: String) - } - // MARK: - CacheCheckResponse struct CacheCheckResponse: Codable { @@ -33,7 +19,7 @@ public extension Premiumize { struct DDLResponse: Codable { let status: String - let content: [DDLData] + let content: [DDLData]? let location: String let filename: String let filesize: Int @@ -51,19 +37,6 @@ public extension Premiumize { } } - // MARK: - InstantAvailability client side structures - - struct IA: Codable, Hashable { - let magnet: Magnet - let expiryTimeStamp: Double - let files: [IAFile] - } - - struct IAFile: Codable, Hashable { - let name: String - let streamUrlString: String - } - // MARK: - AllItemsResponse (listall endpoint) struct AllItemsResponse: Codable { diff --git a/Ferrite/Models/RealDebridModels.swift b/Ferrite/Models/RealDebridModels.swift index 393acb6..d72ad40 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -9,20 +9,6 @@ import Foundation public extension RealDebrid { - // MARK: - Errors - - // TODO: Hybridize debrid errors in one structure - enum RDError: Error { - case InvalidUrl - case InvalidPostBody - case InvalidResponse - case InvalidToken - case EmptyData - case EmptyTorrents - case FailedRequest(description: String) - case AuthQuery(description: String) - } - // MARK: - device code endpoint struct DeviceCodeResponse: Codable, Sendable { @@ -90,14 +76,7 @@ public extension RealDebrid { var filesize: Int } - // MARK: - Instant Availability client side structures - - struct IA: Codable, Hashable, Sendable { - let magnet: Magnet - let expiryTimeStamp: Double - var files: [IAFile] = [] - var batches: [IABatch] = [] - } + // MARK: - Instant Availability batch structures (used for client-side conversion) struct IABatch: Codable, Hashable, Sendable { let files: [IABatchFile] @@ -108,12 +87,6 @@ public extension RealDebrid { let fileName: String } - struct IAFile: Codable, Hashable, Sendable { - let name: String - let batchIndex: Int - let batchFileIndex: Int - } - // MARK: - addMagnet endpoint struct AddMagnetResponse: Codable, Sendable { diff --git a/Ferrite/Protocols/Debrid.swift b/Ferrite/Protocols/Debrid.swift new file mode 100644 index 0000000..fb24d7d --- /dev/null +++ b/Ferrite/Protocols/Debrid.swift @@ -0,0 +1,68 @@ +// +// Debrid.swift +// Ferrite +// +// Created by Brian Dashore on 6/1/24. +// + +import Foundation + +public protocol DebridSource: AnyObservableObject { + // ID of the service + // var id: DebridInfo { get } + var id: String { get } + var abbreviation: String { get } + var website: String { get } + + // Auth variables + var authProcessing: Bool { get set } + var isLoggedIn: Bool { get } + + // Manual API key + var manualToken: String? { get } + + // Common authentication functions + func setApiKey(_ key: String) + func logout() async + + // Instant availability variables + var IAValues: [DebridIA] { get set } + + // Instant availability functions + func instantAvailability(magnets: [Magnet]) async throws + + // Fetches a download link from a source + // Include the instant availability information with the args + // Torrents also checked here + func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String + + // Cloud variables + var cloudDownloads: [DebridCloudDownload] { get set } + var cloudTorrents: [DebridCloudTorrent] { get set } + var cloudTTL: Double { get set } + + // User downloads functions + func getUserDownloads() async throws + func checkUserDownloads(link: String) async throws -> String? + func deleteDownload(downloadId: String) async throws + + // User torrent functions + func getUserTorrents() async throws + func deleteTorrent(torrentId: String?) async throws +} + +public protocol PollingDebridSource: DebridSource { + // Task reference for polling + var authTask: Task? { get set } + + // Fetches the Auth URL + func getAuthUrl() async throws -> URL +} + +public protocol OAuthDebridSource: DebridSource { + // Fetches the auth URL + func getAuthUrl() throws -> URL + + // Handles an OAuth callback + func handleAuthCallback(url: URL) throws +} diff --git a/Ferrite/Utils/Store.swift b/Ferrite/Utils/Store.swift new file mode 100644 index 0000000..34cd3b2 --- /dev/null +++ b/Ferrite/Utils/Store.swift @@ -0,0 +1,147 @@ +// +// Store.swift +// Ferrite +// +// +// Originally created by William Baker on 09/06/2022. +// https://github.com/Tiny-Home-Consulting/Dependiject/blob/master/Dependiject/Store.swift +// Copyright (c) 2022 Tiny Home Consulting LLC. All rights reserved. +// +// Combined together by Brian Dashore +// +// TODO: Replace with Observable when minVersion >= iOS 17 +// + +import Combine +import SwiftUI + +class ErasedObservableObject: ObservableObject { + let objectWillChange: AnyPublisher + + init(objectWillChange: AnyPublisher) { + self.objectWillChange = objectWillChange + } + + static func empty() -> ErasedObservableObject { + .init(objectWillChange: Empty().eraseToAnyPublisher()) + } +} + +public protocol AnyObservableObject: AnyObject { + var objectWillChange: ObservableObjectPublisher { get } +} + +// The generic type names were chosen to match the SwiftUI equivalents: +// - ObjectType from StateObject and ObservedObject +// - Subject from ObservedObject.Wrapper.subscript(dynamicMember:) +// - S from Publisher.receive(on:options:) + +/// A property wrapper used to wrap injected observable objects. +/// +/// This is similar to SwiftUI's +/// [`StateObject`](https://developer.apple.com/documentation/swiftui/stateobject), but without +/// compile-time type restrictions. The lack of compile-time restrictions means that `ObjectType` +/// may be a protocol rather than a class. +/// +/// - Important: At runtime, the wrapped value must conform to ``AnyObservableObject``. +/// +/// To pass properties of the observable object down the view hierarchy as bindings, use the +/// projected value: +/// ```swift +/// struct ExampleView: View { +/// @Store var viewModel = Factory.shared.resolve(ViewModelProtocol.self) +/// +/// var body: some View { +/// TextField("username", text: $viewModel.username) +/// } +/// } +/// ``` +/// Not all injected objects need this property wrapper. See the example projects for examples each +/// way. +@propertyWrapper +public struct Store { + /// The underlying object being stored. + public let wrappedValue: ObjectType + + // See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38 + fileprivate var _observableObject: ObservedObject + + @MainActor internal var observableObject: ErasedObservableObject { + _observableObject.wrappedValue + } + + /// A projected value which has the same properties as the wrapped value, but presented as + /// bindings. + /// + /// Use this to pass bindings down the view hierarchy: + /// ```swift + /// struct ExampleView: View { + /// @Store var viewModel = Factory.shared.resolve(ViewModelProtocol.self) + /// + /// var body: some View { + /// TextField("username", text: $viewModel.username) + /// } + /// } + /// ``` + public var projectedValue: Wrapper { + Wrapper(self) + } + + /// Create a stored value on a custom scheduler. + /// + /// Use this init to schedule updates on a specific scheduler other than `DispatchQueue.main`. + public init(wrappedValue: ObjectType, + on scheduler: S, + schedulerOptions: S.SchedulerOptions? = nil) + { + self.wrappedValue = wrappedValue + + if let observable = wrappedValue as? AnyObservableObject { + let objectWillChange = observable.objectWillChange + .receive(on: scheduler, options: schedulerOptions) + .eraseToAnyPublisher() + _observableObject = .init(initialValue: .init(objectWillChange: objectWillChange)) + } else { + assertionFailure( + "Only use the Store property wrapper with objects conforming to AnyObservableObject." + ) + _observableObject = .init(initialValue: .empty()) + } + } + + /// Create a stored value which publishes on the main thread. + /// + /// To control when updates are published, see ``init(wrappedValue:on:schedulerOptions:)``. + public init(wrappedValue: ObjectType) { + self.init(wrappedValue: wrappedValue, on: DispatchQueue.main) + } + + /// An equivalent to SwiftUI's + /// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper) + /// type. + @dynamicMemberLookup + public struct Wrapper { + private var store: Store + + internal init(_ store: Store) { + self.store = store + } + + /// Returns a binding to the resulting value of a given key path. + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> Binding { + Binding { + self.store.wrappedValue[keyPath: keyPath] + } set: { + self.store.wrappedValue[keyPath: keyPath] = $0 + } + } + } +} + +extension Store: DynamicProperty { + public nonisolated mutating func update() { + _observableObject.update() + } +} diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index e7da300..a113407 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -12,26 +12,35 @@ import SwiftUI public class DebridManager: ObservableObject { // Linked classes var logManager: LoggingManager? - let realDebrid: RealDebrid = .init() - let allDebrid: AllDebrid = .init() - let premiumize: Premiumize = .init() + @Published var realDebrid: RealDebrid = .init() + @Published var allDebrid: AllDebrid = .init() + @Published var premiumize: Premiumize = .init() + + lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize] // UI Variables @Published var showWebView: Bool = false @Published var showAuthSession: Bool = false - // Service agnostic variables - @Published var enabledDebrids: Set = [] { + var hasEnabledDebrids: Bool { + debridSources.contains { $0.isLoggedIn } + } + + var enabledDebridCount: Int { + debridSources.filter(\.isLoggedIn).count + } + + @Published var selectedDebridSource: DebridSource? { didSet { - UserDefaults.standard.set(enabledDebrids.rawValue, forKey: "Debrid.EnabledArray") + UserDefaults.standard.set(selectedDebridSource?.id ?? "", forKey: "Debrid.PreferredService") } } - @Published var selectedDebridType: DebridType? { - didSet { - UserDefaults.standard.set(selectedDebridType?.rawValue ?? 0, forKey: "Debrid.PreferredService") - } - } + var selectedDebridItem: DebridIA? + var selectedDebridFile: DebridIAFile? + + // TODO: Figure out a way to remove this var + var selectedOAuthDebridSource: OAuthDebridSource? @Published var filteredIAStatus: Set = [] @@ -39,104 +48,46 @@ public class DebridManager: ObservableObject { var downloadUrl: String = "" var authUrl: URL? - // Is the current debrid type processing an auth request - func authProcessing(_ passedDebridType: DebridType?) -> Bool { - guard let debridType = passedDebridType ?? selectedDebridType else { - return false - } - - switch debridType { - case .realDebrid: - return realDebridAuthProcessing - case .allDebrid: - return allDebridAuthProcessing - case .premiumize: - return premiumizeAuthProcessing - } - } - // RealDebrid auth variables var realDebridAuthProcessing: Bool = false - // RealDebrid fetch variables - @Published var realDebridIAValues: [RealDebrid.IA] = [] - @Published var showDeleteAlert: Bool = false - var selectedRealDebridItem: RealDebrid.IA? - var selectedRealDebridFile: RealDebrid.IAFile? - var selectedRealDebridID: String? - - // TODO: Maybe make these generic? - // RealDebrid cloud variables - @Published var realDebridCloudTorrents: [RealDebrid.UserTorrentsResponse] = [] - @Published var realDebridCloudDownloads: [RealDebrid.UserDownloadsResponse] = [] - var realDebridCloudTTL: Double = 0.0 - // AllDebrid auth variables var allDebridAuthProcessing: Bool = false - // AllDebrid fetch variables - @Published var allDebridIAValues: [AllDebrid.IA] = [] - - var selectedAllDebridItem: AllDebrid.IA? - var selectedAllDebridFile: AllDebrid.IAFile? - - // AllDebrid cloud variables - @Published var allDebridCloudMagnets: [AllDebrid.MagnetStatusData] = [] - @Published var allDebridCloudLinks: [AllDebrid.SavedLink] = [] - var allDebridCloudTTL: Double = 0.0 - // Premiumize auth variables var premiumizeAuthProcessing: Bool = false - // Premiumize fetch variables - @Published var premiumizeIAValues: [Premiumize.IA] = [] - - var selectedPremiumizeItem: Premiumize.IA? - var selectedPremiumizeFile: Premiumize.IAFile? - - // Premiumize cloud variables - @Published var premiumizeCloudItems: [Premiumize.UserItem] = [] - var premiumizeCloudTTL: Double = 0.0 - init() { - if let rawDebridList = UserDefaults.standard.string(forKey: "Debrid.EnabledArray"), - let serializedDebridList = Set(rawValue: rawDebridList) - { - enabledDebrids = serializedDebridList - } + // Set the preferred service. Contains migration logic for earlier versions + if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") { + let debridServiceId: String? - // If a UserDefaults integer isn't set, it's usually 0 - let rawPreferredService = UserDefaults.standard.integer(forKey: "Debrid.PreferredService") - selectedDebridType = DebridType(rawValue: rawPreferredService) + if let preferredServiceInt = Int(rawPreferredService) { + debridServiceId = migratePreferredService(preferredServiceInt) + } else { + debridServiceId = rawPreferredService + } - // If a user has one logged in service, automatically set the preferred service to that one - if enabledDebrids.count == 1 { - selectedDebridType = enabledDebrids.first + // Only set the debrid source if it's logged in + // Otherwise remove the key + let tempDebridSource = debridSources.first { $0.id == debridServiceId } + if tempDebridSource?.isLoggedIn ?? false { + selectedDebridSource = tempDebridSource + } else { + UserDefaults.standard.removeObject(forKey: "Debrid.PreferredService") + } } } - // TODO: Remove this after v0.6.0 - // Login cleanup function that's automatically run to switch to the new login system - public func cleanupOldLogins() async { - let realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled") - if realDebridEnabled { - enabledDebrids.insert(.realDebrid) - UserDefaults.standard.set(false, forKey: "RealDebrid.Enabled") - } + // TODO: Remove after v0.8.0 + // Function to migrate the preferred service to the new string ID format + public func migratePreferredService(_ idInt: Int) -> String? { + // Undo the EnabledDebrids key + UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray") - let allDebridEnabled = UserDefaults.standard.bool(forKey: "AllDebrid.Enabled") - if allDebridEnabled { - enabledDebrids.insert(.allDebrid) - UserDefaults.standard.set(false, forKey: "AllDebrid.Enabled") - } - - let premiumizeEnabled = UserDefaults.standard.bool(forKey: "Premiumize.Enabled") - if premiumizeEnabled { - enabledDebrids.insert(.premiumize) - UserDefaults.standard.set(false, forKey: "Premiumize.Enabled") - } + return DebridType(rawValue: idInt)?.toString() } // Wrapper function to match error descriptions @@ -169,103 +120,29 @@ public class DebridManager: ObservableObject { // Cleans all cached IA values in the event of a full IA refresh public func clearIAValues() { - realDebridIAValues = [] - allDebridIAValues = [] - premiumizeIAValues = [] + for debridSource in debridSources { + debridSource.IAValues = [] + } } // Clears all selected files and items public func clearSelectedDebridItems() { - switch selectedDebridType { - case .realDebrid: - selectedRealDebridFile = nil - selectedRealDebridItem = nil - case .allDebrid: - selectedAllDebridFile = nil - selectedAllDebridItem = nil - case .premiumize: - selectedPremiumizeFile = nil - selectedPremiumizeItem = nil - case .none: - break - } + selectedDebridItem = nil + selectedDebridFile = nil } // Common function to populate hashes for debrid services public func populateDebridIA(_ resultMagnets: [Magnet]) async { - let now = Date() - - // If a hash isn't found in the IA, update it - // If the hash is expired, remove it and update it - let sendMagnets = resultMagnets.filter { magnet in - if let IAIndex = realDebridIAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }), enabledDebrids.contains(.realDebrid) { - if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp { - realDebridIAValues.remove(at: IAIndex) - return true - } else { - return false - } - } else if let IAIndex = allDebridIAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }), enabledDebrids.contains(.allDebrid) { - if now.timeIntervalSince1970 > allDebridIAValues[IAIndex].expiryTimeStamp { - allDebridIAValues.remove(at: IAIndex) - return true - } else { - return false - } - } else if let IAIndex = premiumizeIAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }), enabledDebrids.contains(.premiumize) { - if now.timeIntervalSince1970 > premiumizeIAValues[IAIndex].expiryTimeStamp { - premiumizeIAValues.remove(at: IAIndex) - return true - } else { - return false - } - } else { - return true - } - } - - // Don't exit the function if the API fetch errors - if !sendMagnets.isEmpty { - if enabledDebrids.contains(.realDebrid) { - do { - let fetchedRealDebridIA = try await realDebrid.instantAvailability(magnets: sendMagnets) - realDebridIAValues += fetchedRealDebridIA - } catch { - await sendDebridError(error, prefix: "RealDebrid IA fetch error") - } + for debridSource in debridSources { + if !debridSource.isLoggedIn { + continue } - if enabledDebrids.contains(.allDebrid) { - do { - let fetchedAllDebridIA = try await allDebrid.instantAvailability(magnets: sendMagnets) - allDebridIAValues += fetchedAllDebridIA - } catch { - await sendDebridError(error, prefix: "AllDebrid IA fetch error") - } - } - - if enabledDebrids.contains(.premiumize) { - do { - // Only strip magnets that don't have an associated link for PM - let strippedResultMagnets: [Magnet] = resultMagnets.compactMap { - if let magnetLink = $0.link { - return Magnet(hash: $0.hash, link: magnetLink) - } else { - return nil - } - } - - let availableMagnets = try await premiumize.divideCacheRequests(magnets: strippedResultMagnets) - - // Split DDL requests into chunks of 10 - for chunk in availableMagnets.chunked(into: 10) { - let tempIA = try await premiumize.divideDDLRequests(magnetChunk: chunk) - - premiumizeIAValues += tempIA - } - } catch { - await sendDebridError(error, prefix: "Premiumize IA fetch error") - } + // Don't exit the function if the API fetch errors + do { + try await debridSource.instantAvailability(magnets: resultMagnets) + } catch { + await sendDebridError(error, prefix: "\(debridSource.id) IA fetch error") } } } @@ -276,38 +153,11 @@ public class DebridManager: ObservableObject { return .none } - switch selectedDebridType { - case .realDebrid: - guard let realDebridMatch = realDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) else { - return .none - } - - if realDebridMatch.batches.isEmpty { - return .full - } else { - return .partial - } - case .allDebrid: - guard let allDebridMatch = allDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) else { - return .none - } - - if allDebridMatch.files.count > 1 { - return .partial - } else { - return .full - } - case .premiumize: - guard let premiumizeMatch = premiumizeIAValues.first(where: { magnetHash == $0.magnet.hash }) else { - return .none - } - - if premiumizeMatch.files.count > 1 { - return .partial - } else { - return .full - } - case .none: + if let selectedDebridSource, + let match = selectedDebridSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) + { + return match.files.count > 1 ? .partial : .full + } else { return .none } } @@ -318,32 +168,15 @@ public class DebridManager: ObservableObject { return false } - switch selectedDebridType { - case .realDebrid: - if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) { - selectedRealDebridItem = realDebridItem - return true - } else { - logManager?.error("DebridManager: Could not find the associated RealDebrid entry for magnet hash \(magnetHash)") - return false - } - case .allDebrid: - if let allDebridItem = allDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) { - selectedAllDebridItem = allDebridItem - return true - } else { - logManager?.error("DebridManager: Could not find the associated AllDebrid entry for magnet hash \(magnetHash)") - return false - } - case .premiumize: - if let premiumizeItem = premiumizeIAValues.first(where: { magnetHash == $0.magnet.hash }) { - selectedPremiumizeItem = premiumizeItem - return true - } else { - logManager?.error("DebridManager: Could not find the associated Premiumize entry for magnet hash \(magnetHash)") - return false - } - case .none: + guard let selectedSource = selectedDebridSource else { + return false + } + + if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) { + selectedDebridItem = IAItem + return true + } else { + logManager?.error("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)") return false } } @@ -351,73 +184,62 @@ public class DebridManager: ObservableObject { // MARK: - Authentication UI linked functions // Common function to delegate what debrid service to authenticate with - public func authenticateDebrid(debridType: DebridType, apiKey: String?) async { - switch debridType { - case .realDebrid: - let success = apiKey == nil ? await authenticateRd() : realDebrid.setApiKey(apiKey!) - completeDebridAuth(debridType, success: success) - case .allDebrid: - // Async can't work with nil mapping method - let success = apiKey == nil ? await authenticateAd() : allDebrid.setApiKey(apiKey!) - completeDebridAuth(debridType, success: success) - case .premiumize: - if let apiKey { - let success = premiumize.setApiKey(apiKey) - completeDebridAuth(debridType, success: success) - } else { - await authenticatePm() + public func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async { + defer { + // Don't cancel processing if using OAuth + if !(debridSource is OAuthDebridSource) { + debridSource.authProcessing = false } - } - } - // Callback to finish debrid auth since functions can be split - func completeDebridAuth(_ debridType: DebridType, success: Bool) { - if success { - enabledDebrids.insert(debridType) - if enabledDebrids.count == 1 { - selectedDebridType = enabledDebrids.first + if enabledDebridCount == 1 { + selectedDebridSource = debridSource } } - switch debridType { - case .realDebrid: - realDebridAuthProcessing = false - case .allDebrid: - allDebridAuthProcessing = false - case .premiumize: - premiumizeAuthProcessing = false + // Set an API key if manually provided + if let apiKey { + debridSource.setApiKey(apiKey) + return + } + + // Processing has started + debridSource.authProcessing = true + + if let pollingSource = debridSource as? PollingDebridSource { + do { + let authUrl = try await pollingSource.getAuthUrl() + + if validateAuthUrl(authUrl) { + try await pollingSource.authTask?.value + } else { + throw DebridError.AuthQuery(description: "The authentication URL was invalid") + } + } catch { + await sendDebridError(error, prefix: "\(debridSource.id) authentication error") + + pollingSource.authTask?.cancel() + } + } else if let oauthSource = debridSource as? OAuthDebridSource { + do { + let tempAuthUrl = try oauthSource.getAuthUrl() + selectedOAuthDebridSource = oauthSource + + validateAuthUrl(tempAuthUrl, useAuthSession: true) + } catch { + await sendDebridError(error, prefix: "\(debridSource.id) authentication error") + } + } else { + logManager?.error( + "DebridManager: Auth: Could not figure out the authentication type for \(debridSource.id). Is this configured properly?" + ) + + return } } // Get a truncated manual API key if it's being used - func getManualAuthKey(_ passedDebridType: DebridType?) async -> String? { - guard let debridType = passedDebridType ?? selectedDebridType else { - return nil - } - - let debridToken: String? - switch debridType { - case .realDebrid: - if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") { - debridToken = FerriteKeychain.shared.get("RealDebrid.AccessToken") - } else { - debridToken = nil - } - case .allDebrid: - if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") { - debridToken = FerriteKeychain.shared.get("AllDebrid.ApiKey") - } else { - debridToken = nil - } - case .premiumize: - if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { - debridToken = FerriteKeychain.shared.get("Premiumize.AccessToken") - } else { - debridToken = nil - } - } - - if let debridToken { + func getManualAuthKey(_ debridSource: some DebridSource) async -> String? { + if let debridToken = debridSource.manualToken { let splitString = debridToken.suffix(4) if debridToken.count > 4 { @@ -447,118 +269,43 @@ public class DebridManager: ObservableObject { return true } - private func authenticateRd() async -> Bool { - do { - realDebridAuthProcessing = true - let verificationResponse = try await realDebrid.getVerificationInfo() - - if validateAuthUrl(URL(string: verificationResponse.directVerificationURL)) { - try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode) - return true - } else { - throw RealDebrid.RDError.AuthQuery(description: "The verification URL was invalid") - } - } catch { - await sendDebridError(error, prefix: "RealDebrid authentication error") - - realDebrid.authTask?.cancel() - return false - } - } - - private func authenticateAd() async -> Bool { - do { - allDebridAuthProcessing = true - let pinResponse = try await allDebrid.getPinInfo() - - if validateAuthUrl(URL(string: pinResponse.userURL)) { - try await allDebrid.getApiKey(checkID: pinResponse.check, pin: pinResponse.pin) - return true - } else { - throw AllDebrid.ADError.AuthQuery(description: "The PIN URL was invalid") - } - } catch { - await sendDebridError(error, prefix: "AllDebrid authentication error") - - allDebrid.authTask?.cancel() - return false - } - } - - private func authenticatePm() async { - do { - premiumizeAuthProcessing = true - let tempAuthUrl = try premiumize.buildAuthUrl() - - validateAuthUrl(tempAuthUrl, useAuthSession: true) - } catch { - await sendDebridError(error, prefix: "Premiumize authentication error") - - completeDebridAuth(.premiumize, success: false) - } - } - // Currently handles Premiumize callback - public func handleCallback(url: URL?, error: Error?) async { + public func handleAuthCallback(url: URL?, error: Error?) async { + defer { + if enabledDebridCount == 1 { + selectedDebridSource = selectedOAuthDebridSource + } + + selectedOAuthDebridSource?.authProcessing = false + } + do { + guard let oauthDebridSource = selectedOAuthDebridSource else { + throw DebridError.AuthQuery(description: "OAuth source couldn't be found for callback. Aborting.") + } + if let error { - throw Premiumize.PMError.AuthQuery(description: "OAuth callback Error: \(error)") + throw DebridError.AuthQuery(description: "OAuth callback Error: \(error)") } if let callbackUrl = url { - try premiumize.handleAuthCallback(url: callbackUrl) - completeDebridAuth(.premiumize, success: true) + try oauthDebridSource.handleAuthCallback(url: callbackUrl) } else { - throw Premiumize.PMError.AuthQuery(description: "The callback URL was invalid") + throw DebridError.AuthQuery(description: "The callback URL was invalid") } } catch { await sendDebridError(error, prefix: "Premiumize authentication error (callback)") - - completeDebridAuth(.premiumize, success: false) } } - // MARK: - Logout UI linked functions + // MARK: - Logout UI functions - // Common function to delegate what debrid service to logout of - public func logoutDebrid(debridType: DebridType) async { - switch debridType { - case .realDebrid: - await logoutRd() - case .allDebrid: - logoutAd() - case .premiumize: - logoutPm() + public func logout(_ debridSource: some DebridSource) async { + await debridSource.logout() + + if selectedDebridSource?.id == debridSource.id { + selectedDebridSource = nil } - - // Automatically resets the preferred debrid service if it was set to the logged out service - if selectedDebridType == debridType { - selectedDebridType = nil - } - } - - private func logoutRd() async { - do { - try await realDebrid.deleteTokens() - enabledDebrids.remove(.realDebrid) - } catch { - await sendDebridError(error, prefix: "RealDebrid logout error") - } - } - - private func logoutAd() { - allDebrid.deleteTokens() - enabledDebrids.remove(.allDebrid) - - logManager?.info( - "AllDebrid: Logged out, API key needs to be removed", - description: "Please manually delete the AllDebrid API key" - ) - } - - private func logoutPm() { - premiumize.deleteTokens() - enabledDebrids.remove(.premiumize) } // MARK: - Debrid fetch UI linked functions @@ -576,299 +323,89 @@ public class DebridManager: ObservableObject { self.currentDebridTask = nil }) - switch selectedDebridType { - case .realDebrid: - await fetchRdDownload(magnet: magnet, existingLink: cloudInfo) - case .allDebrid: - await fetchAdDownload(magnet: magnet, existingLockedLink: cloudInfo) - case .premiumize: - await fetchPmDownload(cloudItemId: cloudInfo) - case .none: - break - } - } - - func fetchRdDownload(magnet: Magnet?, existingLink: String?) async { - // If an existing link is passed in args, set it to that. Otherwise, find one from RD cloud. - let torrentLink: String? - if let existingLink { - torrentLink = existingLink - } else { - // Bypass the TTL for up to date information - await fetchRdCloud(bypassTTL: true) - - let existingTorrent = realDebridCloudTorrents.first { $0.hash == selectedRealDebridItem?.magnet.hash && $0.status == "downloaded" } - torrentLink = existingTorrent?.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0] + guard let debridSource = selectedDebridSource else { + return } do { - // If the links match from a user's downloads, no need to re-run a download - if let torrentLink, - let downloadLink = await checkRdUserDownloads(userTorrentLink: torrentLink) - { + if let cloudInfo { + downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? "" + return + } + + if let magnet { + let downloadLink = try await debridSource.getDownloadLink( + magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile + ) + + // Update the UI downloadUrl = downloadLink - } else if let magnet { - // Add a magnet after all the cache checks fail - selectedRealDebridID = try await realDebrid.addMagnet(magnet: magnet) - - var fileIds: [Int] = [] - if let iaFile = selectedRealDebridFile { - guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { - return - } - - fileIds = iaBatchFromFile.files.map(\.id) - } - - if let realDebridId = selectedRealDebridID { - try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds) - - let torrentLink = try await realDebrid.torrentInfo( - debridID: realDebridId, - selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0 - ) - let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink) - - downloadUrl = downloadLink - } else { - logManager?.error( - "RealDebrid: Could not cache torrent with hash \(String(describing: magnet.hash))", - description: "Could not cache this torrent. Aborting." - ) - } } else { - throw RealDebrid.RDError.FailedRequest(description: "Could not fetch your file from RealDebrid's cache or API") + throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API") } // Fetch one more time to add updated data into the RD cloud cache - await fetchRdCloud(bypassTTL: true) + await fetchDebridCloud(bypassTTL: true) } catch { switch error { - case RealDebrid.RDError.EmptyTorrents: + case DebridError.IsCaching: showDeleteAlert.toggle() default: - await sendDebridError(error, prefix: "RealDebrid download error", cancelString: "Download cancelled") - - await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false) + await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled") } logManager?.hideIndeterminateToast() } } + // Wrapper to handle cloud fetching public func fetchDebridCloud(bypassTTL: Bool = false) async { - switch selectedDebridType { - case .realDebrid: - await fetchRdCloud(bypassTTL: bypassTTL) - case .allDebrid: - await fetchAdCloud(bypassTTL: bypassTTL) - case .premiumize: - await fetchPmCloud(bypassTTL: bypassTTL) - case .none: + guard let selectedSource = selectedDebridSource else { return } - } - // Refreshes torrents and downloads from a RD user's account - public func fetchRdCloud(bypassTTL: Bool = false) async { - if bypassTTL || Date().timeIntervalSince1970 > realDebridCloudTTL { + if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL { do { - realDebridCloudTorrents = try await realDebrid.userTorrents() - realDebridCloudDownloads = try await realDebrid.userDownloads() + // Populates the inner downloads and torrent arrays + try await selectedSource.getUserDownloads() + try await selectedSource.getUserTorrents() - // 5 minutes - realDebridCloudTTL = Date().timeIntervalSince1970 + 300 - } catch { - await sendDebridError(error, prefix: "RealDebrid cloud fetch error") - } - } - } - - func deleteRdDownload(downloadID: String) async { - do { - try await realDebrid.deleteDownload(debridID: downloadID) - - // Bypass TTL to get current RD values - await fetchRdCloud(bypassTTL: true) - } catch { - await sendDebridError(error, prefix: "RealDebrid download delete error") - } - } - - func deleteRdTorrent(torrentID: String? = nil, presentError: Bool = true) async { - do { - if let torrentID { - try await realDebrid.deleteTorrent(debridID: torrentID) - } else if let selectedTorrentID = selectedRealDebridID { - try await realDebrid.deleteTorrent(debridID: selectedTorrentID) - } else { - throw RealDebrid.RDError.FailedRequest(description: "No torrent ID was provided") - } - } catch { - await sendDebridError(error, prefix: "RealDebrid torrent delete error", presentError: presentError) - } - } - - func checkRdUserDownloads(userTorrentLink: String) async -> String? { - do { - let existingLinks = realDebridCloudDownloads.first { $0.link == userTorrentLink } - if let existingLink = existingLinks?.download { - return existingLink - } else { - return try await realDebrid.unrestrictLink(debridDownloadLink: userTorrentLink) - } - } catch { - await sendDebridError(error, prefix: "RealDebrid download check error") - - return nil - } - } - - // TODO: Integrate with AD saved links - func fetchAdDownload(magnet: Magnet?, existingLockedLink: String?) async { - // If an existing link is passed in args, set it to that. Otherwise, find one from AD cloud. - let lockedLink: String? - if let existingLockedLink { - lockedLink = existingLockedLink - } else { - // Bypass the TTL for up to date information - await fetchAdCloud(bypassTTL: true) - - let existingMagnet = allDebridCloudMagnets.first { $0.hash == selectedAllDebridItem?.magnet.hash && $0.status == "Ready" } - lockedLink = existingMagnet?.links[safe: selectedAllDebridFile?.id ?? 0]?.link - } - - do { - if let lockedLink, - let unlockedLink = await checkAdUserLinks(lockedLink: lockedLink) - { - downloadUrl = unlockedLink - } else if let magnet { - let magnetID = try await allDebrid.addMagnet(magnet: magnet) - let lockedLink = try await allDebrid.fetchMagnetStatus( - magnetId: magnetID, - selectedIndex: selectedAllDebridFile?.id ?? 0 - ) - - try await allDebrid.saveLink(link: lockedLink) - downloadUrl = try await allDebrid.unlockLink(lockedLink: lockedLink) - } else { - throw AllDebrid.ADError.FailedRequest(description: "Could not fetch your file from AllDebrid's cache or API") - } - - // Fetch one more time to add updated data into the AD cloud cache - await fetchAdCloud(bypassTTL: true) - } catch { - await sendDebridError(error, prefix: "AllDebrid download error", cancelString: "Download cancelled") - } - } - - func checkAdUserLinks(lockedLink: String) async -> String? { - do { - let existingLinks = allDebridCloudLinks.first { $0.link == lockedLink } - if let existingLink = existingLinks?.link { - return existingLink - } else { - try await allDebrid.saveLink(link: lockedLink) - return try await allDebrid.unlockLink(lockedLink: lockedLink) - } - } catch { - await sendDebridError(error, prefix: "AllDebrid download check error") - - return nil - } - } - - // Refreshes torrents and downloads from a RD user's account - public func fetchAdCloud(bypassTTL: Bool = false) async { - if bypassTTL || Date().timeIntervalSince1970 > allDebridCloudTTL { - do { - allDebridCloudMagnets = try await allDebrid.userMagnets() - allDebridCloudLinks = try await allDebrid.savedLinks() - - // 5 minutes - allDebridCloudTTL = Date().timeIntervalSince1970 + 300 - } catch { - await sendDebridError(error, prefix: "AlLDebrid cloud fetch error") - } - } - } - - func deleteAdLink(link: String) async { - do { - try await allDebrid.deleteLink(link: link) - - await fetchAdCloud(bypassTTL: true) - } catch { - await sendDebridError(error, prefix: "AllDebrid link delete error") - } - } - - func deleteAdMagnet(magnetId: Int) async { - do { - try await allDebrid.deleteMagnet(magnetId: magnetId) - - await fetchAdCloud(bypassTTL: true) - } catch { - await sendDebridError(error, prefix: "AllDebrid magnet delete error") - } - } - - func fetchPmDownload(cloudItemId: String? = nil) async { - do { - if let cloudItemId { - downloadUrl = try await premiumize.itemDetails(itemID: cloudItemId).link - } else if let premiumizeFile = selectedPremiumizeFile { - downloadUrl = premiumizeFile.streamUrlString - } else if - let premiumizeItem = selectedPremiumizeItem, - let firstFile = premiumizeItem.files[safe: 0] - { - downloadUrl = firstFile.streamUrlString - } else { - throw Premiumize.PMError.FailedRequest(description: "There were no items or files found!") - } - - // Fetch one more time to add updated data into the PM cloud cache - await fetchPmCloud(bypassTTL: true) - - // Add a PM transfer if the item exists - if let premiumizeItem = selectedPremiumizeItem { - try await premiumize.createTransfer(magnet: premiumizeItem.magnet) - } - } catch { - await sendDebridError(error, prefix: "Premiumize download error", cancelString: "Download or transfer cancelled") - } - } - - // Refreshes items and fetches from a PM user account - public func fetchPmCloud(bypassTTL: Bool = false) async { - if bypassTTL || Date().timeIntervalSince1970 > premiumizeCloudTTL { - do { - let userItems = try await premiumize.userItems() - withAnimation { - premiumizeCloudItems = userItems - } - - // 5 minutes - premiumizeCloudTTL = Date().timeIntervalSince1970 + 300 + // Update the TTL to 5 minutes from now + selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300 } catch { let error = error as NSError if error.code != -999 { - await sendDebridError(error, prefix: "Premiumize cloud fetch error") + await sendDebridError(error, prefix: "\(selectedSource.id) cloud fetch error") } } } } - public func deletePmItem(id: String) async { - do { - try await premiumize.deleteItem(itemID: id) + public func deleteCloudDownload(_ download: DebridCloudDownload) async { + guard let selectedSource = selectedDebridSource else { + return + } - // Bypass TTL to get current RD values - await fetchPmCloud(bypassTTL: true) + do { + try await selectedSource.deleteDownload(downloadId: download.downloadId) + + await fetchDebridCloud(bypassTTL: true) } catch { - await sendDebridError(error, prefix: "Premiumize cloud delete error") + await sendDebridError(error, prefix: "\(selectedSource.id) download delete error") + } + } + + public func deleteCloudTorrent(_ torrent: DebridCloudTorrent) async { + guard let selectedSource = selectedDebridSource else { + return + } + + do { + try await selectedSource.deleteTorrent(torrentId: torrent.torrentId) + + await fetchDebridCloud(bypassTTL: true) + } catch { + await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error") } } } diff --git a/Ferrite/ViewModels/LoggingManager.swift b/Ferrite/ViewModels/LoggingManager.swift index 3e84b14..86c7334 100644 --- a/Ferrite/ViewModels/LoggingManager.swift +++ b/Ferrite/ViewModels/LoggingManager.swift @@ -121,7 +121,7 @@ class LoggingManager: ObservableObject { if let description { toastDescription = description } else if showErrorToasts { - toastDescription = "An error was logged" + toastDescription = "An error was logged. Please look at logs in Settings." } } diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index 46720dd..afdde3d 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -80,7 +80,7 @@ class ScrapingViewModel: ObservableObject { cleanedSearchText = searchText.lowercased() - if await !debridManager.enabledDebrids.isEmpty { + if await !debridManager.hasEnabledDebrids { await debridManager.clearIAValues() } @@ -114,7 +114,7 @@ class ScrapingViewModel: ObservableObject { var failedSourceNames: [String] = [] for await (requestResult, sourceName) in group { if let requestResult { - if await !debridManager.enabledDebrids.isEmpty { + if await debridManager.hasEnabledDebrids { await debridManager.populateDebridIA(requestResult.magnets) } diff --git a/Ferrite/Views/CommonViews/HybridSecureField.swift b/Ferrite/Views/CommonViews/HybridSecureField.swift index d1ac923..665623e 100644 --- a/Ferrite/Views/CommonViews/HybridSecureField.swift +++ b/Ferrite/Views/CommonViews/HybridSecureField.swift @@ -21,7 +21,7 @@ struct HybridSecureField: View { private var isFieldDisabled: Bool = false init(text: Binding, onCommit: (() -> Void)? = nil, showPassword: Bool = false) { - self._text = text + _text = text if let onCommit { self.onCommit = onCommit } @@ -57,6 +57,6 @@ struct HybridSecureField: View { extension HybridSecureField { public func fieldDisabled(_ isFieldDisabled: Bool) -> Self { - modifyViewProp({ $0.isFieldDisabled = isFieldDisabled }) + modifyViewProp { $0.isFieldDisabled = isFieldDisabled } } } diff --git a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift index e3fd2b0..f402ec1 100644 --- a/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift +++ b/Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift @@ -8,38 +8,34 @@ import SwiftUI struct DebridLabelView: View { - @EnvironmentObject var debridManager: DebridManager + @Store var debridSource: DebridSource @State var cloudLinks: [String] = [] + @State var tagColor: Color = .red var magnet: Magnet? var body: some View { - if let selectedDebridType = debridManager.selectedDebridType { - Tag( - name: selectedDebridType.toString(abbreviated: true), - color: getTagColor(), - horizontalPadding: 5, - verticalPadding: 3 - ) - } + Tag( + name: debridSource.abbreviation, + color: getTagColor(), + horizontalPadding: 5, + verticalPadding: 3 + ) } func getTagColor() -> Color { if let magnet, cloudLinks.isEmpty { - switch debridManager.matchMagnetHash(magnet) { - case .full: - return Color.green - case .partial: - return Color.orange - case .none: - return Color.red + guard let match = debridSource.IAValues.first(where: { magnet.hash == $0.magnet.hash }) else { + return .red } + + return match.files.count > 1 ? .orange : .green } else if cloudLinks.count == 1 { - return Color.green + return .green } else if cloudLinks.count > 1 { - return Color.orange + return .orange } else { - return Color.red + return .red } } } diff --git a/Ferrite/Views/ComponentViews/Filters/SelectedDebridFilterView.swift b/Ferrite/Views/ComponentViews/Filters/SelectedDebridFilterView.swift index 9a98c72..bec93ec 100644 --- a/Ferrite/Views/ComponentViews/Filters/SelectedDebridFilterView.swift +++ b/Ferrite/Views/ComponentViews/Filters/SelectedDebridFilterView.swift @@ -15,23 +15,23 @@ struct SelectedDebridFilterView: View { var body: some View { Menu { Button { - debridManager.selectedDebridType = nil + debridManager.selectedDebridSource = nil } label: { Text("None") - if debridManager.selectedDebridType == nil { + if debridManager.selectedDebridSource == nil { Image(systemName: "checkmark") } } - ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in - if debridManager.enabledDebrids.contains(debridType) { + ForEach(debridManager.debridSources, id: \.id) { debridSource in + if debridSource.isLoggedIn { Button { - debridManager.selectedDebridType = debridType + debridManager.selectedDebridSource = debridSource } label: { - Text(debridType.toString()) + Text(debridSource.id) - if debridManager.selectedDebridType == debridType { + if debridManager.selectedDebridSource?.id == debridSource.id { Image(systemName: "checkmark") } } @@ -40,6 +40,5 @@ struct SelectedDebridFilterView: View { } label: { label } - .id(debridManager.selectedDebridType) } } diff --git a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift index 60bd3cf..eb0d632 100644 --- a/Ferrite/Views/ComponentViews/Library/BookmarksView.swift +++ b/Ferrite/Views/ComponentViews/Library/BookmarksView.swift @@ -56,7 +56,7 @@ struct BookmarksView: View { .frame(height: 15) } .task { - if debridManager.enabledDebrids.count > 0 { + if debridManager.hasEnabledDebrids { let magnets = bookmarks.compactMap { if let magnetHash = $0.magnetHash { return Magnet(hash: magnetHash, link: $0.magnetLink) diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift deleted file mode 100644 index f0cf0ce..0000000 --- a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// AllDebridCloudView.swift -// Ferrite -// -// Created by Brian Dashore on 1/5/23. -// - -import SwiftUI - -struct AllDebridCloudView: View { - @EnvironmentObject var debridManager: DebridManager - @EnvironmentObject var navModel: NavigationViewModel - @EnvironmentObject var pluginManager: PluginManager - - @Binding var searchText: String - - var body: some View { - DisclosureGroup("Links") { - ForEach(debridManager.allDebridCloudLinks.filter { - searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) - }, id: \.self) { downloadResponse in - Button(downloadResponse.filename) { - navModel.resultFromCloud = true - navModel.selectedTitle = downloadResponse.filename - debridManager.downloadUrl = downloadResponse.link - - PersistenceController.shared.createHistory( - HistoryEntryJson( - name: downloadResponse.filename, - url: downloadResponse.link, - source: DebridType.allDebrid.toString() - ), - performSave: true - ) - - pluginManager.runDefaultAction( - urlString: debridManager.downloadUrl, - navModel: navModel - ) - } - .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.primary) - } - .onDelete { offsets in - for index in offsets { - if let savedLink = debridManager.allDebridCloudLinks[safe: index] { - Task { - await debridManager.deleteAdLink(link: savedLink.link) - } - } - } - } - } - - DisclosureGroup("Magnets") { - ForEach(debridManager.allDebridCloudMagnets.filter { - searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) - }, id: \.id) { magnet in - Button { - if magnet.status == "Ready", !magnet.links.isEmpty { - navModel.resultFromCloud = true - navModel.selectedTitle = magnet.filename - - var historyInfo = HistoryEntryJson( - name: magnet.filename, - source: DebridType.allDebrid.toString() - ) - - Task { - if magnet.links.count == 1 { - if let lockedLink = magnet.links[safe: 0]?.link { - await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: lockedLink) - - if !debridManager.downloadUrl.isEmpty { - historyInfo.url = debridManager.downloadUrl - PersistenceController.shared.createHistory(historyInfo, performSave: true) - pluginManager.runDefaultAction( - urlString: debridManager.downloadUrl, - navModel: navModel - ) - } - } - } else { - let magnet = Magnet(hash: magnet.hash, link: nil) - - // Do not clear old IA values - await debridManager.populateDebridIA([magnet]) - - if debridManager.selectDebridResult(magnet: magnet) { - navModel.selectedHistoryInfo = historyInfo - navModel.currentChoiceSheet = .batch - } - } - } - } - - } label: { - VStack(alignment: .leading, spacing: 10) { - Text(magnet.filename) - - HStack { - Text(magnet.status) - Spacer() - DebridLabelView(cloudLinks: magnet.links.map(\.link)) - } - .font(.caption) - } - } - .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.9, animation: .easeOut(duration: 0.2)) - .tint(.primary) - } - .onDelete { offsets in - for index in offsets { - if let magnet = debridManager.allDebridCloudMagnets[safe: index] { - Task { - await debridManager.deleteAdMagnet(magnetId: magnet.id) - } - } - } - } - } - } -} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/CloudDownloadView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/CloudDownloadView.swift new file mode 100644 index 0000000..9f2b488 --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/Cloud/CloudDownloadView.swift @@ -0,0 +1,57 @@ +// +// CloudDownloadView.swift +// Ferrite +// +// Created by Brian Dashore on 6/6/24. +// + +import SwiftUI + +struct CloudDownloadView: View { + @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var pluginManager: PluginManager + + @Store var debridSource: DebridSource + + @Binding var searchText: String + + var body: some View { + DisclosureGroup("Downloads") { + ForEach(debridSource.cloudDownloads.filter { + searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased()) + }, id: \.self) { cloudDownload in + Button(cloudDownload.fileName) { + navModel.resultFromCloud = true + navModel.selectedTitle = cloudDownload.fileName + debridManager.downloadUrl = cloudDownload.link + + PersistenceController.shared.createHistory( + HistoryEntryJson( + name: cloudDownload.fileName, + url: cloudDownload.link, + source: debridSource.id + ), + performSave: true + ) + + pluginManager.runDefaultAction( + urlString: debridManager.downloadUrl, + navModel: navModel + ) + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + .tint(.primary) + } + .onDelete { offsets in + for index in offsets { + if let cloudDownload = debridSource.cloudDownloads[safe: index] { + Task { + await debridManager.deleteCloudDownload(cloudDownload) + } + } + } + } + } + } +} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/CloudTorrentView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/CloudTorrentView.swift new file mode 100644 index 0000000..cdc950f --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/Cloud/CloudTorrentView.swift @@ -0,0 +1,89 @@ +// +// CloudTorrentView.swift +// Ferrite +// +// Created by Brian Dashore on 6/6/24. +// + +import SwiftUI + +struct CloudTorrentView: View { + @EnvironmentObject var navModel: NavigationViewModel + @EnvironmentObject var debridManager: DebridManager + @EnvironmentObject var pluginManager: PluginManager + + @Store var debridSource: DebridSource + + @Binding var searchText: String + + var body: some View { + DisclosureGroup("Torrents") { + ForEach(debridSource.cloudTorrents.filter { + searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased()) + }, id: \.self) { cloudTorrent in + Button { + if cloudTorrent.status == "downloaded", !cloudTorrent.links.isEmpty { + navModel.resultFromCloud = true + navModel.selectedTitle = cloudTorrent.fileName + + var historyInfo = HistoryEntryJson( + name: cloudTorrent.fileName, + source: debridSource.id + ) + + Task { + let magnet = Magnet(hash: cloudTorrent.hash, link: nil) + await debridManager.populateDebridIA([magnet]) + if debridManager.selectDebridResult(magnet: magnet) { + // Is this a batch? + + if cloudTorrent.links.count == 1 { + await debridManager.fetchDebridDownload(magnet: magnet) + + if !debridManager.downloadUrl.isEmpty { + historyInfo.url = debridManager.downloadUrl + PersistenceController.shared.createHistory(historyInfo, performSave: true) + + pluginManager.runDefaultAction( + urlString: debridManager.downloadUrl, + navModel: navModel + ) + } + } else { + navModel.selectedMagnet = magnet + navModel.selectedHistoryInfo = historyInfo + navModel.currentChoiceSheet = .batch + } + } + } + } + } label: { + VStack(alignment: .leading, spacing: 10) { + Text(cloudTorrent.fileName) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) + + HStack { + Text(cloudTorrent.status.capitalizingFirstLetter()) + Spacer() + DebridLabelView(debridSource: debridSource, cloudLinks: cloudTorrent.links) + } + .font(.caption) + } + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + .tint(.primary) + } + .onDelete { offsets in + for index in offsets { + if let cloudTorrent = debridSource.cloudTorrents[safe: index] { + Task { + await debridManager.deleteCloudTorrent(cloudTorrent) + } + } + } + } + } + } +} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift deleted file mode 100644 index d309684..0000000 --- a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// PremiumizeCloudView.swift -// Ferrite -// -// Created by Brian Dashore on 1/2/23. -// - -import SwiftUI - -struct PremiumizeCloudView: View { - @EnvironmentObject var debridManager: DebridManager - @EnvironmentObject var navModel: NavigationViewModel - @EnvironmentObject var pluginManager: PluginManager - - @Binding var searchText: String - - var body: some View { - DisclosureGroup("Items") { - ForEach(debridManager.premiumizeCloudItems.filter { - searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased()) - }, id: \.id) { item in - Button(item.name) { - Task { - navModel.resultFromCloud = true - navModel.selectedTitle = item.name - - await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: item.id) - - if !debridManager.downloadUrl.isEmpty { - PersistenceController.shared.createHistory( - HistoryEntryJson( - name: item.name, - url: debridManager.downloadUrl, - source: DebridType.premiumize.toString() - ), - performSave: true - ) - - pluginManager.runDefaultAction( - urlString: debridManager.downloadUrl, - navModel: navModel - ) - } - } - } - .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.primary) - } - .onDelete { offsets in - for index in offsets { - if let item = debridManager.premiumizeCloudItems[safe: index] { - Task { - await debridManager.deletePmItem(id: item.id) - } - } - } - } - } - } -} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift deleted file mode 100644 index b133ef8..0000000 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// RealDebridCloudView.swift -// Ferrite -// -// Created by Brian Dashore on 12/31/22. -// - -import SwiftUI - -struct RealDebridCloudView: View { - @EnvironmentObject var navModel: NavigationViewModel - @EnvironmentObject var debridManager: DebridManager - @EnvironmentObject var pluginManager: PluginManager - - @Binding var searchText: String - - var body: some View { - Group { - DisclosureGroup("Downloads") { - ForEach(debridManager.realDebridCloudDownloads.filter { - searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) - }, id: \.self) { downloadResponse in - Button(downloadResponse.filename) { - navModel.resultFromCloud = true - navModel.selectedTitle = downloadResponse.filename - debridManager.downloadUrl = downloadResponse.download - - PersistenceController.shared.createHistory( - HistoryEntryJson( - name: downloadResponse.filename, - url: downloadResponse.download, - source: DebridType.realDebrid.toString() - ), - performSave: true - ) - - pluginManager.runDefaultAction( - urlString: debridManager.downloadUrl, - navModel: navModel - ) - } - .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.primary) - } - .onDelete { offsets in - for index in offsets { - if let downloadResponse = debridManager.realDebridCloudDownloads[safe: index] { - Task { - await debridManager.deleteRdDownload(downloadID: downloadResponse.id) - } - } - } - } - } - - DisclosureGroup("Torrents") { - ForEach(debridManager.realDebridCloudTorrents.filter { - searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) - }, id: \.self) { torrentResponse in - Button { - if torrentResponse.status == "downloaded", !torrentResponse.links.isEmpty { - navModel.resultFromCloud = true - navModel.selectedTitle = torrentResponse.filename - - var historyInfo = HistoryEntryJson( - name: torrentResponse.filename, - source: DebridType.realDebrid.toString() - ) - - Task { - if torrentResponse.links.count == 1 { - if let torrentLink = torrentResponse.links[safe: 0] { - await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink) - if !debridManager.downloadUrl.isEmpty { - historyInfo.url = debridManager.downloadUrl - PersistenceController.shared.createHistory(historyInfo, performSave: true) - - pluginManager.runDefaultAction( - urlString: debridManager.downloadUrl, - navModel: navModel - ) - } - } - } else { - let magnet = Magnet(hash: torrentResponse.hash, link: nil) - - // Do not clear old IA values - await debridManager.populateDebridIA([magnet]) - - if debridManager.selectDebridResult(magnet: magnet) { - navModel.selectedHistoryInfo = historyInfo - navModel.currentChoiceSheet = .batch - } - } - } - } - } label: { - VStack(alignment: .leading, spacing: 10) { - Text(torrentResponse.filename) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(4) - - HStack { - Text(torrentResponse.status.capitalizingFirstLetter()) - Spacer() - DebridLabelView(cloudLinks: torrentResponse.links) - } - .font(.caption) - } - } - .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.primary) - } - .onDelete { offsets in - for index in offsets { - if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] { - Task { - await debridManager.deleteRdTorrent(torrentID: torrentResponse.id) - } - } - } - } - } - } - } -} diff --git a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift index b9e271f..9a612a8 100644 --- a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -10,19 +10,18 @@ import SwiftUI struct DebridCloudView: View { @EnvironmentObject var debridManager: DebridManager + @Store var debridSource: DebridSource + @Binding var searchText: String var body: some View { List { - switch debridManager.selectedDebridType { - case .realDebrid: - RealDebridCloudView(searchText: $searchText) - case .premiumize: - PremiumizeCloudView(searchText: $searchText) - case .allDebrid: - AllDebridCloudView(searchText: $searchText) - case .none: - EmptyView() + if !debridSource.cloudDownloads.isEmpty { + CloudDownloadView(debridSource: debridSource, searchText: $searchText) + } + + if !debridSource.cloudTorrents.isEmpty { + CloudTorrentView(debridSource: debridSource, searchText: $searchText) } } .listStyle(.plain) @@ -32,7 +31,7 @@ struct DebridCloudView: View { .refreshable { await debridManager.fetchDebridCloud(bypassTTL: true) } - .onChange(of: debridManager.selectedDebridType) { newType in + .onChange(of: debridManager.selectedDebridSource?.id) { newType in if newType != nil { Task { await debridManager.fetchDebridCloud() diff --git a/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift b/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift index 8c97e58..d3c88f6 100644 --- a/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift +++ b/Ferrite/Views/ComponentViews/Library/LibraryPickerView.swift @@ -19,7 +19,7 @@ struct LibraryPickerView: View { Text("Bookmarks").tag(NavigationViewModel.LibraryPickerSegment.bookmarks) Text("History").tag(NavigationViewModel.LibraryPickerSegment.history) - if !debridManager.enabledDebrids.isEmpty { + if debridManager.hasEnabledDebrids { Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud) } } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift index 2602c68..1ba7df0 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift @@ -53,14 +53,14 @@ struct SearchFilterHeaderView: View { SelectedDebridFilterView { FilterLabelView( - name: debridManager.selectedDebridType?.toString(), + name: debridManager.selectedDebridSource?.id, fallbackName: "Debrid" ) } // MARK: - Cache status picker - if !debridManager.enabledDebrids.isEmpty { + if debridManager.hasEnabledDebrids { IAFilterView() } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index efe438b..fa2d24e 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -127,13 +127,14 @@ struct SearchResultButtonView: View { .alert("Caching file", isPresented: $debridManager.showDeleteAlert) { Button("Yes", role: .destructive) { Task { - await debridManager.deleteRdTorrent() + try? await debridManager.selectedDebridSource?.deleteTorrent(torrentId: nil) } } Button("Cancel", role: .cancel) {} } message: { Text( - "RealDebrid is currently caching this file. Would you like to delete it? \n\n" + + "\(String(describing: debridManager.selectedDebridSource?.id)) is currently caching this file. " + + "Would you like to delete it? \n\n" + "Progress can be checked on the RealDebrid website." ) } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift index d42371d..4bb2528 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultInfoView.swift @@ -30,7 +30,9 @@ struct SearchResultInfoView: View { Text(size) } - DebridLabelView(magnet: result.magnet) + if let debridSource = debridManager.selectedDebridSource { + DebridLabelView(debridSource: debridSource, magnet: result.magnet) + } } .font(.caption) } diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift index cebd792..001cb3a 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift @@ -10,7 +10,7 @@ import SwiftUI struct SettingsDebridInfoView: View { @EnvironmentObject var debridManager: DebridManager - let debridType: DebridType + @Store var debridSource: DebridSource @State private var apiKeyTempText: String = "" @@ -18,9 +18,9 @@ struct SettingsDebridInfoView: View { 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.") + Text("\(debridSource.id) 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")!) + Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!) } } @@ -30,21 +30,21 @@ struct SettingsDebridInfoView: View { ) { Button { Task { - if debridManager.enabledDebrids.contains(debridType) { - await debridManager.logoutDebrid(debridType: debridType) - } else if !debridManager.authProcessing(debridType) { - await debridManager.authenticateDebrid(debridType: debridType, apiKey: nil) + if debridSource.isLoggedIn { + await debridManager.logout(debridSource) + } else if !debridSource.authProcessing { + await debridManager.authenticateDebrid(debridSource, apiKey: nil) } - apiKeyTempText = await debridManager.getManualAuthKey(debridType) ?? "" + apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? "" } } label: { Text( - debridManager.enabledDebrids.contains(debridType) + debridSource.isLoggedIn ? "Logout" - : (debridManager.authProcessing(debridType) ? "Processing" : "Login") + : (debridSource.authProcessing ? "Processing" : "Login") ) - .foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue) + .foregroundColor(debridSource.isLoggedIn ? .red : .blue) } } @@ -57,22 +57,22 @@ struct SettingsDebridInfoView: View { onCommit: { Task { if !apiKeyTempText.isEmpty { - await debridManager.authenticateDebrid(debridType: debridType, apiKey: apiKeyTempText) - apiKeyTempText = await debridManager.getManualAuthKey(debridType) ?? "" + await debridManager.authenticateDebrid(debridSource, apiKey: apiKeyTempText) + apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? "" } } } ) - .fieldDisabled(debridManager.enabledDebrids.contains(debridType)) + .fieldDisabled(debridSource.isLoggedIn) } .onAppear { Task { - apiKeyTempText = await debridManager.getManualAuthKey(debridType) ?? "" + apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? "" } } } .listStyle(.insetGrouped) - .navigationTitle(debridType.toString()) + .navigationTitle(debridSource.id) .navigationBarTitleDisplayMode(.inline) } } diff --git a/Ferrite/Views/LibraryView.swift b/Ferrite/Views/LibraryView.swift index fd5a87a..347bc04 100644 --- a/Ferrite/Views/LibraryView.swift +++ b/Ferrite/Views/LibraryView.swift @@ -38,7 +38,13 @@ struct LibraryView: View { case .history: HistoryView(allHistoryEntries: allHistoryEntries, searchText: $searchText) case .debridCloud: - DebridCloudView(searchText: $searchText) + if let selectedDebridSource = debridManager.selectedDebridSource { + DebridCloudView(debridSource: selectedDebridSource, searchText: $searchText) + } else { + // Placeholder view that takes up the entire parent view + Color.clear + .frame(maxWidth: .infinity) + } } } .overlay { @@ -53,7 +59,7 @@ struct LibraryView: View { EmptyInstructionView(title: "No History", message: "Start watching to build history") } case .debridCloud: - if debridManager.selectedDebridType == nil { + if debridManager.selectedDebridSource == nil { EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service") } } @@ -69,7 +75,7 @@ struct LibraryView: View { switch navModel.libraryPickerSelection { case .bookmarks, .debridCloud: SelectedDebridFilterView { - Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid") + Text(debridManager.selectedDebridSource?.abbreviation ?? "Debrid") } .transaction { $0.animation = .none diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 28f18fc..3c8ffcd 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -46,14 +46,14 @@ struct SettingsView: View { NavView { Form { Section(header: InlineHeader("Debrid services")) { - ForEach(DebridType.allCases, id: \.self) { debridType in + ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in NavigationLink { - SettingsDebridInfoView(debridType: debridType) + SettingsDebridInfoView(debridSource: debridSource) } label: { HStack { - Text(debridType.toString()) + Text(debridSource.id) Spacer() - Text(debridManager.enabledDebrids.contains(debridType) ? "Enabled" : "Disabled") + Text(debridSource.isLoggedIn ? "Enabled" : "Disabled") .foregroundColor(.secondary) } } @@ -96,7 +96,7 @@ struct SettingsView: View { if changed { Task { let dataRecords = await WKWebsiteDataStore.default().dataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) - + await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: dataRecords) } } @@ -128,7 +128,7 @@ struct SettingsView: View { } Section(header: InlineHeader("Default actions")) { - if debridManager.enabledDebrids.count > 0 { + if debridManager.hasEnabledDebrids { NavigationLink { DefaultActionPickerView( actionRequirement: .debrid, @@ -227,7 +227,7 @@ struct SettingsView: View { callbackURLScheme: "ferrite" ) { callbackURL, error in Task { - await debridManager.handleCallback(url: callbackURL, error: error) + await debridManager.handleAuthCallback(url: callbackURL, error: error) } } .prefersEphemeralWebBrowserSession(useEphemeralAuth) diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index 771395f..1620fdc 100644 --- a/Ferrite/Views/SheetViews/BatchChoiceView.swift +++ b/Ferrite/Views/SheetViews/BatchChoiceView.swift @@ -23,39 +23,14 @@ struct BatchChoiceView: View { var body: some View { NavView { List { - switch debridManager.selectedDebridType { - case .realDebrid: - ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in - if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty { - Button(file.name) { - debridManager.selectedRealDebridFile = file + ForEach(debridManager.selectedDebridItem?.files ?? [], id: \.self) { file in + if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty { + Button(file.name) { + debridManager.selectedDebridFile = file - queueCommonDownload(fileName: file.name) - } + queueCommonDownload(fileName: file.name) } } - case .allDebrid: - ForEach(debridManager.selectedAllDebridItem?.files ?? [], id: \.self) { file in - if file.fileName.lowercased().contains(searchText.lowercased()) || searchText.isEmpty { - Button(file.fileName) { - debridManager.selectedAllDebridFile = file - - queueCommonDownload(fileName: file.fileName) - } - } - } - case .premiumize: - ForEach(debridManager.selectedPremiumizeItem?.files ?? [], id: \.self) { file in - if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty { - Button(file.name) { - debridManager.selectedPremiumizeFile = file - - queueCommonDownload(fileName: file.name) - } - } - } - case .none: - EmptyView() } } .tint(.primary) @@ -85,7 +60,7 @@ struct BatchChoiceView: View { // Common function to communicate betwen VMs and queue/display a download func queueCommonDownload(fileName: String) { debridManager.currentDebridTask = Task { - await debridManager.fetchDebridDownload(magnet: navModel.resultFromCloud ? nil : navModel.selectedMagnet) + await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet) if !debridManager.downloadUrl.isEmpty { try? await Task.sleep(seconds: 1)