diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b429522..4a2650d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -6,13 +6,13 @@ on: jobs: build: - runs-on: macos-12 + runs-on: macos-14 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest + xcode-version: latest-stable - name: Get commit SHA run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - name: Build @@ -25,7 +25,7 @@ jobs: cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bc654d..3705910 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,13 +7,13 @@ on: jobs: build: - runs-on: macos-12 + runs-on: macos-14 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest + xcode-version: latest-stable - name: Build run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO env: @@ -30,7 +30,7 @@ jobs: run: | zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa - name: Upload release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index d314abc..3e9d788 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; }; 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; }; 0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; }; + 0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; }; + 0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; }; + 0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; }; 0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; }; 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; }; 0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; }; @@ -20,7 +23,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 +56,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 +97,9 @@ 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 */; }; + 0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */; }; + 0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E4A2C188FA7003B17B5 /* TorBoxModels.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 +133,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 /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.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 */; }; @@ -142,6 +147,7 @@ 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */; }; 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; }; 0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; }; + 0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */; }; 0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; }; 0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */; }; 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; }; @@ -153,6 +159,10 @@ 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 */; }; + 0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */; }; + 0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.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 */ @@ -162,6 +172,9 @@ 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = ""; }; 0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = ""; }; 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = ""; }; + 0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = ""; }; + 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = ""; }; + 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = ""; }; 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = ""; }; 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = ""; }; 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = ""; }; @@ -170,7 +183,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 = ""; }; @@ -203,7 +215,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 = ""; }; @@ -241,6 +252,9 @@ 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 = ""; }; + 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxWrapper.swift; sourceTree = ""; }; + 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxModels.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 = ""; }; @@ -274,12 +288,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 /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.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 = ""; }; @@ -288,6 +303,7 @@ 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = ""; }; 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = ""; }; 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerriteKeychain.swift; sourceTree = ""; }; 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoMetaView.swift; sourceTree = ""; }; 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoAboutView.swift; sourceTree = ""; }; 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = ""; }; @@ -298,6 +314,10 @@ 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 = ""; }; + 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataClass.swift"; sourceTree = ""; }; + 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataProperties.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 */ @@ -377,6 +397,8 @@ 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */, 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */, 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */, + 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */, + 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */, ); path = Classes; sourceTree = ""; @@ -388,6 +410,7 @@ 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */, 0C7ED14028D61BBA009E29AD /* BackupModels.swift */, 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */, + 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */, 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */, 0C68135128BC1A7C00FAD890 /* GithubModels.swift */, 0C422E7F293542F300486D65 /* PremiumizeModels.swift */, @@ -397,6 +420,8 @@ 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */, 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */, 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */, + 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */, + 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */, ); path = Models; sourceTree = ""; @@ -404,9 +429,8 @@ 0C2886D52960C4F800D6FC16 /* Cloud */ = { isa = PBXGroup; children = ( - 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */, - 0CAF9318296399190050812A /* PremiumizeCloudView.swift */, - 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */, + 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */, + 0CB725332C123E760047FC0B /* CloudMagnetView.swift */, ); path = Cloud; sourceTree = ""; @@ -449,6 +473,9 @@ children = ( 0C44E2A728D4DDDC007711AE /* Application.swift */, 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */, + 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */, + 0C8AE2472C0FFB6600701675 /* Store.swift */, + 0C07C6032C1A859B00808A46 /* FormDataBody.swift */, ); path = Utils; sourceTree = ""; @@ -489,6 +516,7 @@ isa = PBXGroup; children = ( 0CE1C4172981E8D700418F20 /* Plugin.swift */, + 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */, ); path = Protocols; sourceTree = ""; @@ -646,6 +674,8 @@ 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */, 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */, + 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */, + 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */, ); path = API; sourceTree = ""; @@ -738,7 +768,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1400; - LastUpgradeCheck = 1400; + LastUpgradeCheck = 1600; TargetAttributes = { 0CAF1C67286F5C0E00296F86 = { CreatedOnToolsVersion = 14.0; @@ -814,6 +844,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */, 0C7ED14328D65518009E29AD /* FileManager.swift in Sources */, 0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */, 0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */, @@ -844,14 +875,15 @@ 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 */, + 0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */, 0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */, 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */, 0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */, @@ -861,10 +893,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 /* CloudMagnetView.swift in Sources */, 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */, 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */, 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */, @@ -891,7 +923,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 */, @@ -912,16 +943,22 @@ 0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */, 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */, 0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */, + 0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */, 0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */, 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, 0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */, 0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */, 0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */, + 0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */, + 0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */, 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */, + 0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */, + 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */, 0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */, 0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */, 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */, + 0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */, 0C7075E429D374C50093DB2D /* Color.swift in Sources */, 0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */, 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */, @@ -938,11 +975,14 @@ 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */, 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */, 0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */, + 0C890E492C188808003B17B5 /* TorBoxWrapper.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 */, @@ -960,6 +1000,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -992,6 +1033,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -1013,6 +1055,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = minimal; }; name = Debug; }; @@ -1020,6 +1063,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -1052,6 +1096,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -1066,6 +1111,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = minimal; VALIDATE_PRODUCT = YES; }; name = Release; @@ -1076,10 +1122,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_TEAM = 8A74DBQ6S3; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Ferrite/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Ferrite; @@ -1095,7 +1142,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.0; + MARKETING_VERSION = 0.7.1; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1111,10 +1158,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_TEAM = 8A74DBQ6S3; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Ferrite/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Ferrite; @@ -1130,7 +1178,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.7.0; + MARKETING_VERSION = 0.7.1; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Ferrite.xcodeproj/xcshareddata/xcschemes/Ferrite.xcscheme b/Ferrite.xcodeproj/xcshareddata/xcschemes/Ferrite.xcscheme index 454bc7f..161d6ea 100644 --- a/Ferrite.xcodeproj/xcshareddata/xcschemes/Ferrite.xcscheme +++ b/Ferrite.xcodeproj/xcshareddata/xcschemes/Ferrite.xcscheme @@ -1,6 +1,6 @@ ? + @Published var authProcessing: Bool = false + var isLoggedIn: Bool { + getToken() != nil + } + + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published var IAValues: [DebridIA] = [] + @Published var cloudDownloads: [DebridCloudDownload] = [] + @Published var cloudMagnets: [DebridCloudMagnet] = [] + var cloudTTL: Double = 0.0 + + private let baseApiUrl = "https://api.alldebrid.com/v4" + private let appName = "Ferrite" + + private let jsonDecoder = JSONDecoder() + + init() { + // Populate user downloads and magnets + Task { + try? await getUserDownloads() + try? await getUserMagnets() + } + } + + // MARK: - Auth + // Fetches information for PIN auth - public func getPinInfo() async throws -> PinResponse { + 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) } } // Fetches API keys - public func getApiKey(checkID: String, pin: String) async throws { + func getApiKey(checkID: String, pin: String) async throws { let queryItems = [ URLQueryItem(name: "agent", value: appName), URLQueryItem(name: "check", value: checkID), @@ -50,7 +89,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) @@ -60,7 +99,7 @@ public class AllDebrid { // If there's an API key from the response, end the task successfully if let apiKeyResponse = rawResponse { - keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey") + FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey") return } else { @@ -69,7 +108,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 { @@ -77,15 +116,28 @@ public class AllDebrid { } } - // Clears tokens. No endpoint to deregister a device - public func deleteTokens() { - keychain.delete("AllDebrid.ApiKey") + // Adds a manual API key instead of web auth + func setApiKey(_ key: String) { + FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey") + UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey") } + func getToken() -> String? { + FerriteKeychain.shared.get("AllDebrid.ApiKey") + } + + // Clears tokens. No endpoint to deregister a device + 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 = keychain.get("AllDebrid.ApiKey") else { - throw ADError.InvalidToken + guard let token = getToken() else { + throw DebridError.InvalidToken } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -93,23 +145,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 { + func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL { guard var components = URLComponents(string: urlString) else { - throw ADError.InvalidUrl + throw DebridError.InvalidUrl } components.queryItems = [ @@ -119,14 +170,80 @@ public class AllDebrid { if let url = components.url { return url } else { - throw ADError.InvalidUrl + throw DebridError.InvalidUrl } } + // MARK: - Instant availability + + 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(id: index, name: magnetFile.name) + } + + return DebridIA( + magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet), + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + } + + IAValues += availableHashes + } + + // MARK: - Downloading + + // Wrapper function to fetch a download link from the API + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + let selectedMagnetId: String + + if let existingMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && cachedStatus.contains($0.status) }) { + selectedMagnetId = existingMagnet.id + } else { + let magnetId = try await addMagnet(magnet: magnet) + selectedMagnetId = String(magnetId) + } + + let lockedLink = try await fetchMagnetStatus( + magnetId: selectedMagnetId, + selectedIndex: iaFile?.id ?? 0 + ) + + return (lockedLink, nil) + } + // Adds a magnet link to the user's AD account - public func addMagnet(magnet: Magnet) async throws -> Int { + 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")) @@ -146,13 +263,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 { + func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> DebridIAFile { 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)) @@ -160,68 +277,93 @@ public class AllDebrid { let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data // Better to fetch no link at all than the wrong link - if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] { - return linkWrapper.link + if let cloudMagnetFile = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] { + return DebridIAFile(id: 0, name: cloudMagnetFile.filename, streamUrlString: cloudMagnetFile.link) } else { - throw ADError.EmptyTorrents + throw DebridError.EmptyUserMagnets } } - public func userMagnets() async throws -> [MagnetStatusData] { + // Known as unlockLink in AD's API + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { + let queryItems = [ + URLQueryItem(name: "link", value: restrictedFile.streamUrlString) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems)) + + let data = try await performRequest(request: &request, requestName: "unlockLink") + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + return rawResponse.link + } + + func saveLink(link: String) async throws { + let queryItems = [ + URLQueryItem(name: "links[]", value: link) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems)) + + try await performRequest(request: &request, requestName: #function) + } + + // MARK: - Cloud methods + + func getUserMagnets() 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 ADError.EmptyData - } else { - return rawResponse.magnets + cloudMagnets = rawResponse.magnets.map { magnetResponse in + DebridCloudMagnet( + id: String(magnetResponse.id), + fileName: magnetResponse.filename, + status: magnetResponse.status, + hash: magnetResponse.hash, + links: magnetResponse.links.map(\.link) + ) } } - public func deleteMagnet(magnetId: Int) async throws { + func deleteUserMagnet(cloudMagnetId: String?) async throws { + guard let cloudMagnetId else { + throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid") + } + let queryItems = [ - URLQueryItem(name: "id", value: String(magnetId)) + URLQueryItem(name: "id", value: cloudMagnetId) ] 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) - ] - var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems)) + 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 + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data - return rawResponse.link - } - - 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 + // The link is also the ID + cloudDownloads = rawResponse.links.map { link in + DebridCloudDownload( + id: link.link, fileName: link.filename, link: link.link ) } + } - return availableHashes + // Not used + func checkUserDownloads(link: String) -> String? { + link + } + + // The downloadId is actually the download link + func deleteUserDownload(downloadId: String) async throws { + let queryItems = [ + 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) } } diff --git a/Ferrite/API/GithubWrapper.swift b/Ferrite/API/GithubWrapper.swift index 01c0dc5..ef97dbe 100644 --- a/Ferrite/API/GithubWrapper.swift +++ b/Ferrite/API/GithubWrapper.swift @@ -7,8 +7,8 @@ import Foundation -public class Github { - public func fetchLatestRelease() async throws -> Release? { +class Github { + func fetchLatestRelease() async throws -> Release? { let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")! let (data, _) = try await URLSession.shared.data(from: url) @@ -17,7 +17,7 @@ public class Github { return rawResponse } - public func fetchReleases() async throws -> [Release]? { + func fetchReleases() async throws -> [Release]? { let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")! let (data, _) = try await URLSession.shared.data(from: url) diff --git a/Ferrite/API/KodiWrapper.swift b/Ferrite/API/KodiWrapper.swift index 95bc23f..ba5ccae 100644 --- a/Ferrite/API/KodiWrapper.swift +++ b/Ferrite/API/KodiWrapper.swift @@ -7,15 +7,15 @@ import Foundation -public class Kodi { - let encoder = JSONEncoder() +class Kodi { + private let encoder = JSONEncoder() // Used to add server to CoreData. Not part of API - public func addServer(urlString: String, - friendlyName: String?, - username: String?, - password: String?, - existingServer: KodiServer? = nil) throws + func addServer(urlString: String, + friendlyName: String?, + username: String?, + password: String?, + existingServer: KodiServer? = nil) throws { let backgroundContext = PersistenceController.shared.backgroundContext @@ -65,7 +65,7 @@ public class Kodi { try backgroundContext.save() } - public func ping(server: KodiServer) async throws { + func ping(server: KodiServer) async throws { var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -94,7 +94,7 @@ public class Kodi { } } - public func sendVideoUrl(urlString: String, server: KodiServer) async throws { + func sendVideoUrl(urlString: String, server: KodiServer) async throws { if URL(string: urlString) == nil { throw KodiError.InvalidPlaybackUrl } diff --git a/Ferrite/API/OffCloudWrapper.swift b/Ferrite/API/OffCloudWrapper.swift new file mode 100644 index 0000000..9311a47 --- /dev/null +++ b/Ferrite/API/OffCloudWrapper.swift @@ -0,0 +1,277 @@ +// +// OffCloudWrapper.swift +// Ferrite +// +// Created by Brian Dashore on 6/12/24. +// + +import Foundation + +class OffCloud: DebridSource, ObservableObject { + let id = "OffCloud" + let abbreviation = "OC" + let website = "https://offcloud.com" + let description: String? = "OffCloud is a debrid service that is used for downloads and media playback. " + + "You must pay to access this service. \n\n" + + "This service does not inform if a magnet link is a batch before downloading." + let cachedStatus: [String] = ["downloaded"] + + @Published var authProcessing: Bool = false + var isLoggedIn: Bool { + getToken() != nil + } + + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "OffCloud.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published var IAValues: [DebridIA] = [] + @Published var cloudDownloads: [DebridCloudDownload] = [] + @Published var cloudMagnets: [DebridCloudMagnet] = [] + var cloudTTL: Double = 0.0 + + private let baseApiUrl = "https://offcloud.com/api" + private let jsonDecoder = JSONDecoder() + private let jsonEncoder = JSONEncoder() + + init() { + // Populate user downloads and magnets + Task { + try? await getUserMagnets() + } + } + + func setApiKey(_ key: String) { + FerriteKeychain.shared.set(key, forKey: "OffCloud.ApiKey") + UserDefaults.standard.set(true, forKey: "OffCloud.UseManualKey") + } + + func logout() async { + FerriteKeychain.shared.delete("OffCloud.ApiKey") + UserDefaults.standard.removeObject(forKey: "OffCloud.UseManualKey") + } + + private func getToken() -> String? { + FerriteKeychain.shared.get("OffCloud.ApiKey") + } + + // Wrapper request function which matches the responses and returns data + @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw DebridError.FailedRequest(description: "No HTTP response given") + } + + if response.statusCode >= 200, response.statusCode <= 299 { + return data + } else if response.statusCode == 401 { + throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.") + } else { + print(response) + 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 DebridError.InvalidUrl + } + + guard let token = getToken() else { + throw DebridError.InvalidToken + } + + components.queryItems = [ + URLQueryItem(name: "key", value: token) + ] + queryItems + + if let url = components.url { + return url + } else { + throw DebridError.InvalidUrl + } + } + + 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: try buildRequestURL(urlString: "\(baseApiUrl)/cache")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = InstantAvailabilityRequest(hashes: sendMagnets.compactMap(\.hash)) + request.httpBody = try jsonEncoder.encode(body) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(InstantAvailabilityResponse.self, from: data) + + let availableHashes = rawResponse.cachedItems.map { + DebridIA( + magnet: Magnet(hash: $0, link: nil), + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: [] + ) + } + + IAValues += availableHashes + } + + // Cloud in OffCloud's API + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + let selectedCloudMagnet: DebridCloudMagnet + + // Don't queue a new job if the magnet already exists in the user's account + if let existingCloudMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && cachedStatus.contains($0.status) }) { + selectedCloudMagnet = existingCloudMagnet + } else { + let cloudDownloadResponse = try await offcloudDownload(magnet: magnet) + + guard cachedStatus.contains(cloudDownloadResponse.status) else { + throw DebridError.IsCaching + } + + selectedCloudMagnet = DebridCloudMagnet( + id: cloudDownloadResponse.requestId, + fileName: cloudDownloadResponse.fileName, + status: cloudDownloadResponse.status, + hash: "", + links: [cloudDownloadResponse.url] + ) + } + + let cloudExploreResponse = try await cloudExplore(requestId: selectedCloudMagnet.id) + + // Request will error if the file isn't a batch + if case let .links(cloudExploreLinks) = cloudExploreResponse { + var copiedIA = ia + + copiedIA?.files = cloudExploreLinks.enumerated().compactMap { index, exploreLink in + guard let exploreURL = URL(string: exploreLink) else { + return nil + } + + return DebridIAFile( + id: index, + name: exploreURL.lastPathComponent, + streamUrlString: exploreLink.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + ) + } + + return (nil, copiedIA) + } else if case let .error(cloudExploreError) = cloudExploreResponse, + cloudExploreError.error.lowercased() == "bad archive" + { + guard let selectedCloudLink = selectedCloudMagnet.links[safe: 0] else { + throw DebridError.EmptyUserMagnets + } + + let restrictedFile = DebridIAFile( + id: 0, + name: selectedCloudMagnet.fileName, + streamUrlString: "\(selectedCloudLink)/\(selectedCloudMagnet.fileName)" + ) + + return (restrictedFile, nil) + } else { + return (nil, nil) + } + } + + // Called as "cloud" in offcloud's API + private func offcloudDownload(magnet: Magnet) async throws -> CloudDownloadResponse { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + guard let magnetLink = magnet.link else { + throw DebridError.EmptyData + } + + let body = CloudDownloadRequest(url: magnetLink) + request.httpBody = try jsonEncoder.encode(body) + + let data = try await performRequest(request: &request, requestName: "cloud") + let rawResponse = try jsonDecoder.decode(CloudDownloadResponse.self, from: data) + + return rawResponse + } + + private func cloudExplore(requestId: String) async throws -> CloudExploreResponse { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud/explore/\(requestId)")) + + let data = try await performRequest(request: &request, requestName: "cloudExplore") + let rawResponse = try jsonDecoder.decode(CloudExploreResponse.self, from: data) + + return rawResponse + } + + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { + guard let streamUrlString = restrictedFile.streamUrlString else { + throw DebridError.FailedRequest(description: "Could not get a streaming URL from the OffCloud API") + } + + return streamUrlString + } + + func getUserDownloads() {} + + func checkUserDownloads(link: String) -> String? { + link + } + + func deleteUserDownload(downloadId: String) {} + + func getUserMagnets() async throws { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/cloud/history")) + + let data = try await performRequest(request: &request, requestName: "cloudHistory") + let rawResponse = try jsonDecoder.decode([CloudHistoryResponse].self, from: data) + + cloudMagnets = rawResponse.compactMap { cloudHistory in + guard let magnetHash = Magnet(hash: nil, link: cloudHistory.originalLink).hash else { + return nil + } + + return DebridCloudMagnet( + id: cloudHistory.requestId, + fileName: cloudHistory.fileName, + status: cloudHistory.status, + hash: magnetHash, + links: [cloudHistory.originalLink] + ) + } + } + + // Uses the base website because this isn't present in the API path but still works like the API? + func deleteUserMagnet(cloudMagnetId: String?) async throws { + guard let cloudMagnetId else { + throw DebridError.InvalidPostBody + } + + var request = URLRequest(url: try buildRequestURL(urlString: "\(website)/cloud/remove/\(cloudMagnetId)")) + try await performRequest(request: &request, requestName: "cloudRemove") + } +} diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index ccd4207..c83ee0c 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -6,17 +6,48 @@ // import Foundation -import KeychainSwift -public class Premiumize { - let jsonDecoder = JSONDecoder() - let keychain = KeychainSwift() +class Premiumize: OAuthDebridSource, ObservableObject { + let id = "Premiumize" + let abbreviation = "PM" + let website = "https://premiumize.me" + let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " + + "You must pay to access the service." - let baseAuthUrl = "https://www.premiumize.me/authorize" - let baseApiUrl = "https://www.premiumize.me/api" - let clientId = "791565696" + @Published var authProcessing: Bool = false + var isLoggedIn: Bool { + getToken() != nil + } - public func buildAuthUrl() throws -> URL { + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published var IAValues: [DebridIA] = [] + @Published var cloudDownloads: [DebridCloudDownload] = [] + @Published var cloudMagnets: [DebridCloudMagnet] = [] + var cloudTTL: Double = 0.0 + + private let baseAuthUrl = "https://www.premiumize.me/authorize" + private let baseApiUrl = "https://www.premiumize.me/api" + private let clientId = "791565696" + + private let jsonDecoder = JSONDecoder() + + init() { + // Populate user downloads and magnets + Task { + try? await getUserDownloads() + } + } + + // MARK: - Auth + + func getAuthUrl() throws -> URL { var urlComponents = URLComponents(string: baseAuthUrl)! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: clientId), @@ -27,59 +58,185 @@ public class Premiumize { if let url = urlComponents.url { return url } else { - throw PMError.InvalidUrl + throw DebridError.InvalidUrl } } - public func handleAuthCallback(url: URL) throws { + func handleAuthCallback(url: URL) throws { 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 } - keychain.set(accessToken, forKey: "Premiumize.AccessToken") + FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken") + } + + // Adds a manual API key instead of web auth + func setApiKey(_ key: String) { + FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken") + UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey") + } + + func getToken() -> String? { + FerriteKeychain.shared.get("Premiumize.AccessToken") } // Clears tokens. No endpoint to deregister a device - public func deleteTokens() { - keychain.delete("Premiumize.AccessToken") + 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 = keychain.get("Premiumize.AccessToken") else { - throw PMError.InvalidToken + guard let token = getToken() else { + throw DebridError.InvalidToken } - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + // Use the API query parameter if a manual API key is present + if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { + guard + let requestUrl = request.url, + var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false) + else { + throw DebridError.InvalidUrl + } + + let apiTokenItem = URLQueryItem(name: "apikey", value: token) + + if components.queryItems == nil { + components.queryItems = [apiTokenItem] + } else { + components.queryItems?.append(apiTokenItem) + } + + request.url = components.url + } else { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } 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 + + 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 + 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 + private 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( + id: 0, + name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path, + streamUrlString: file.link + ) + } + + return DebridIA( + magnet: magnet, + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + } else { + throw DebridError.EmptyData } } // Function to divide and execute cache endpoint requests in parallel // Calls this for 100 hashes at a time due to API limits - public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] { + func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] { let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in for chunk in magnets.chunked(into: 100) { group.addTask { @@ -99,11 +256,11 @@ public class Premiumize { } // Parent function for initial checking of the cache - func checkCache(magnets: [Magnet]) async throws -> [Magnet] { + private func checkCache(magnets: [Magnet]) async throws -> [Magnet] { 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) @@ -112,7 +269,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 { @@ -126,65 +283,32 @@ 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 - } + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + // 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 { + return (iaFile, nil) + } else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] { + return (firstFile, nil) } else { - throw PMError.EmptyData + throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API") } } - func createTransfer(magnet: Magnet) async throws { + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { + guard let streamUrlString = restrictedFile.streamUrlString else { + throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API") + } + + return streamUrlString + } + + private 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")!) @@ -199,24 +323,29 @@ public class Premiumize { try await performRequest(request: &request, requestName: #function) } - func userItems() async throws -> [UserItem] { + // MARK: - Cloud methods + + 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(id: file.id, fileName: file.name, link: file.id) + } } - func itemDetails(itemID: String) async throws -> ItemDetailsResponse { + private 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) @@ -227,16 +356,26 @@ public class Premiumize { return rawResponse } - func deleteItem(itemID: String) async throws { + func checkUserDownloads(link: String) async throws -> String? { + // Link is the cloud item ID + try await itemDetails(itemID: link).link + } + + func deleteUserDownload(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 magnets for Premiumize + func getUserMagnets() {} + + func deleteUserMagnet(cloudMagnetId: String?) {} } diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index a81f69c..b1445f4 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -6,30 +6,62 @@ // import Foundation -import KeychainSwift - -public class RealDebrid { - let jsonDecoder = JSONDecoder() - let keychain = KeychainSwift() - - let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" - let baseApiUrl = "https://api.real-debrid.com/rest/1.0" - let openSourceClientId = "X245A4XAIBGVM" +class RealDebrid: PollingDebridSource, ObservableObject { + let id = "RealDebrid" + let abbreviation = "RD" + let website = "https://real-debrid.com" + let cachedStatus: [String] = ["downloaded"] var authTask: Task? + @Published var authProcessing: Bool = false + + // Check the manual token since getTokens() is async + var isLoggedIn: Bool { + FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil + } + + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") { + return FerriteKeychain.shared.get("RealDebrid.AccessToken") + } else { + return nil + } + } + + @Published var IAValues: [DebridIA] = [] + @Published var cloudDownloads: [DebridCloudDownload] = [] + @Published var cloudMagnets: [DebridCloudMagnet] = [] + var cloudTTL: Double = 0.0 + + private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" + private let baseApiUrl = "https://api.real-debrid.com/rest/1.0" + private let openSourceClientId = "X245A4XAIBGVM" + + private let jsonDecoder = JSONDecoder() + @MainActor - func setUserDefaultsValue(_ value: Any, forKey: String) { + private func setUserDefaultsValue(_ value: Any, forKey: String) { UserDefaults.standard.set(value, forKey: forKey) } @MainActor - func removeUserDefaultsValue(forKey: String) { + private func removeUserDefaultsValue(forKey: String) { UserDefaults.standard.removeObject(forKey: forKey) } + init() { + // Populate user downloads and magnets + Task { + try? await getUserDownloads() + try? await getUserMagnets() + } + } + + // MARK: - Auth + // Fetches the device code from RD - public func getVerificationInfo() async throws -> DeviceCodeResponse { + func getAuthUrl() async throws -> URL { var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: openSourceClientId), @@ -37,23 +69,33 @@ 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) } } // Fetches the user's client ID and secret - public func getDeviceCredentials(deviceCode: String) async throws { + func getDeviceCredentials(deviceCode: String) async throws { var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")! urlComponents.queryItems = [ URLQueryItem(name: "client_id", value: openSourceClientId), @@ -61,55 +103,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") - keychain.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 keychain - public func getTokens(deviceCode: String) async throws { + // Fetch all tokens for the user and store in FerriteKeychain.shared + 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 = keychain.get("RealDebrid.ClientSecret") else { - throw RDError.EmptyData + guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else { + throw DebridError.EmptyData } var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!) @@ -130,20 +166,20 @@ public class RealDebrid { let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data) - keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken") - keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") + FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken") + FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn) await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") } - public func fetchToken() async -> String? { + func getToken() async -> String? { let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp") if Date().timeIntervalSince1970 > accessTokenStamp { do { - if let refreshToken = keychain.get("RealDebrid.RefreshToken") { - try await getTokens(deviceCode: refreshToken) + if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") { + try await getApiTokens(deviceCode: refreshToken) } } catch { print(error) @@ -151,29 +187,43 @@ public class RealDebrid { } } - return keychain.get("RealDebrid.AccessToken") + return FerriteKeychain.shared.get("RealDebrid.AccessToken") } - public func deleteTokens() async throws { - keychain.delete("RealDebrid.RefreshToken") - keychain.delete("RealDebrid.ClientSecret") + // Adds a manual API key instead of web auth + // Clear out existing refresh tokens and timestamps + 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") + } + + // Deletes tokens from device and RD's servers + func logout() async { + FerriteKeychain.shared.delete("RealDebrid.RefreshToken") + FerriteKeychain.shared.delete("RealDebrid.ClientSecret") await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp") // Run the request, doesn't matter if it fails - if let token = keychain.get("RealDebrid.AccessToken") { + if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") { var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") _ = try? await URLSession.shared.data(for: request) - keychain.delete("RealDebrid.AccessToken") + FerriteKeychain.shared.delete("RealDebrid.AccessToken") + await removeUserDefaultsValue(forKey: "RealDebrid.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 = await fetchToken() else { - throw RDError.InvalidToken + guard let token = await getToken() else { + throw DebridError.InvalidToken } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -181,28 +231,45 @@ 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: "/"))")!) + 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) - // Does not account for torrent packs at the moment let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data) for (hash, response) in rawResponseDict { @@ -214,66 +281,82 @@ public class RealDebrid { continue } - // Is this a batch - if data.rd.count > 1 || data.rd[0].count > 1 { - // Batch array - let batches = data.rd.map { fileDict in - let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in - // Force unwrapped ID. Is safe because ID is guaranteed on a successful response - RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename) - }.sorted(by: { $0.id < $1.id }) + // Handle files array + let batches = data.rd.map { fileDict in + let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in + // Force unwrapped ID. Is safe because ID is guaranteed on a successful response + RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename) + }.sorted(by: { $0.id < $1.id }) - return RealDebrid.IABatch(files: batchFiles) - } + 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 }) { - files.append( - RealDebrid.IAFile( - name: batchFile.fileName, - batchIndex: index, - batchFileIndex: batchFileIndex - ) + for batchFile in batch.files { + if !files.contains(where: { $0.id == batchFile.id }) { + files.append( + DebridIAFile( + id: batchFile.id, + name: batchFile.fileName, + batchIds: batchFileIds ) - } + ) } } - - // TTL: 5 minutes - availableHashes.append( - RealDebrid.IA( - magnet: Magnet(hash: hash, link: nil), - expiryTimeStamp: Date().timeIntervalSince1970 + 300, - files: files, - batches: batches - ) - ) - } else { - availableHashes.append( - RealDebrid.IA( - magnet: Magnet(hash: hash, link: nil), - expiryTimeStamp: Date().timeIntervalSince1970 + 300 - ) - ) } - } - return availableHashes + // TTL: 5 minutes + IAValues.append( + DebridIA( + magnet: Magnet(hash: hash, link: nil), + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: files + ) + ) + } + } + + // MARK: - Downloading + + // Wrapper function to fetch a download link from the API + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + var selectedMagnetId = "" + + do { + // Don't queue a new job if the magnet already exists in the user's library + if let existingCloudMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) { + selectedMagnetId = existingCloudMagnet.id + } 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 restrictedFile = try await torrentInfo( + debridID: selectedMagnetId, + selectedFileId: iaFile?.id ?? 1 + ) + + return (restrictedFile, nil) + } catch { + if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty { + try? await deleteUserMagnet(cloudMagnetId: 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 { + 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")!) @@ -292,7 +375,7 @@ public class RealDebrid { } // Queues the magnet link for downloading - public func selectFiles(debridID: String, fileIds: [Int]) async throws { + func selectFiles(debridID: String, fileIds: [Int]) async throws { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") @@ -312,48 +395,36 @@ public class RealDebrid { } // Gets the info of a torrent from a given ID - public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String { + func torrentInfo(debridID: String, selectedFileId: Int?) async throws -> DebridIAFile { 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" { - return torrentLink + // Let the user know if a magnet is downloading + if let cloudMagnetLink = rawResponse.links[safe: linkIndex ?? -1], rawResponse.status == "downloaded" { + return DebridIAFile( + id: 0, + name: rawResponse.filename, + streamUrlString: cloudMagnetLink + ) } else if rawResponse.status == "downloading" || rawResponse.status == "queued" { - throw RDError.EmptyTorrents + throw DebridError.IsCaching } else { - throw RDError.EmptyData + throw DebridError.EmptyUserMagnets } } - // 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 { + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var bodyComponents = URLComponents() - bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)] + bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)] request.httpBody = bodyComponents.query?.data(using: .utf8) @@ -363,18 +434,66 @@ public class RealDebrid { return rawResponse.download } + // MARK: - Cloud methods + + // Gets the user's cloud magnet library + func getUserMagnets() 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) + cloudMagnets = rawResponse.map { response in + DebridCloudMagnet( + id: response.id, + fileName: response.filename, + status: response.status, + hash: response.hash, + links: response.links + ) + } + } + + // Deletes a magnet download from RD + func deleteUserMagnet(cloudMagnetId: String?) async throws { + let deleteId: String + + if let cloudMagnetId { + deleteId = cloudMagnetId + } else { + // Refresh the user magnet list + // The first file is the currently caching one + let _ = try await getUserMagnets() + guard let firstCloudMagnet = cloudMagnets[safe: -1] else { + throw DebridError.EmptyUserMagnets + } + + deleteId = firstCloudMagnet.id + } + + 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] { + 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(id: response.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 + func checkUserDownloads(link: String) -> String? { + link + } + + func deleteUserDownload(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/API/TorBoxWrapper.swift b/Ferrite/API/TorBoxWrapper.swift new file mode 100644 index 0000000..3894969 --- /dev/null +++ b/Ferrite/API/TorBoxWrapper.swift @@ -0,0 +1,270 @@ +// +// TorBoxWrapper.swift +// Ferrite +// +// Created by Brian Dashore on 6/11/24. +// + +import Foundation + +class TorBox: DebridSource, ObservableObject { + let id = "TorBox" + let abbreviation = "TB" + let website = "https://torbox.app" + let description: String? = "TorBox is a debrid service that is used for downloads and media playback with seeding. " + + "Both free and paid plans are available." + let cachedStatus: [String] = ["cached", "completed"] + + @Published var authProcessing: Bool = false + var isLoggedIn: Bool { + getToken() != nil + } + + var manualToken: String? { + if UserDefaults.standard.bool(forKey: "TorBox.UseManualKey") { + return getToken() + } else { + return nil + } + } + + @Published var IAValues: [DebridIA] = [] + @Published var cloudDownloads: [DebridCloudDownload] = [] + @Published var cloudMagnets: [DebridCloudMagnet] = [] + var cloudTTL: Double = 0.0 + + private let baseApiUrl = "https://api.torbox.app/v1/api" + private let jsonDecoder = JSONDecoder() + private let jsonEncoder = JSONEncoder() + + init() { + // Populate user downloads and magnets + Task { + try? await getUserMagnets() + } + } + + // MARK: - Auth + + func setApiKey(_ key: String) { + FerriteKeychain.shared.set(key, forKey: "TorBox.ApiKey") + UserDefaults.standard.set(true, forKey: "TorBox.UseManualKey") + } + + func logout() async { + FerriteKeychain.shared.delete("TorBox.ApiKey") + UserDefaults.standard.removeObject(forKey: "TorBox.UseManualKey") + } + + private func getToken() -> String? { + FerriteKeychain.shared.get("TorBox.ApiKey") + } + + // 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 DebridError.InvalidToken + } + + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw DebridError.FailedRequest(description: "No HTTP response given") + } + + if response.statusCode >= 200, response.statusCode <= 299 { + return data + } else if response.statusCode == 401 { + throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.") + } else { + throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") + } + } + + // MARK: - Instant availability + + 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 components = URLComponents(string: "\(baseApiUrl)/torrents/checkcached")! + components.queryItems = sendMagnets.map { URLQueryItem(name: "hash", value: $0.hash) } + components.queryItems?.append(URLQueryItem(name: "format", value: "list")) + components.queryItems?.append(URLQueryItem(name: "list_files", value: "true")) + + guard let url = components.url else { + throw DebridError.InvalidUrl + } + + var request = URLRequest(url: url) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse.self, from: data) + + // If the data is a failure, return + guard case let .links(iaObjects) = rawResponse.data else { + return + } + + let availableHashes = iaObjects.map { iaObject in + DebridIA( + magnet: Magnet(hash: iaObject.hash, link: nil), + expiryTimeStamp: Date().timeIntervalSince1970 + 300, + files: iaObject.files.enumerated().compactMap { index, iaFile in + guard let fileName = iaFile.name.split(separator: "/").last else { + return nil + } + + return DebridIAFile( + id: index, + name: String(fileName) + ) + } + ) + } + + IAValues += availableHashes + } + + // MARK: - Downloading + + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { + let cloudMagnetId = try await createTorrent(magnet: magnet) + let cloudMagnetList = try await myTorrentList() + guard let filteredCloudMagnet = cloudMagnetList.first(where: { $0.id == cloudMagnetId }) else { + throw DebridError.FailedRequest(description: "Could not find a cached magnet. Are you sure it's cached?") + } + + // If the user magnet isn't saved, it's considered as caching + guard cachedStatus.contains(filteredCloudMagnet.downloadState) else { + throw DebridError.IsCaching + } + + guard let cloudMagnetFile = filteredCloudMagnet.files[safe: iaFile?.id ?? 0] else { + throw DebridError.EmptyUserMagnets + } + + let restrictedFile = DebridIAFile(id: cloudMagnetFile.id, name: cloudMagnetFile.name, streamUrlString: String(cloudMagnetId)) + return (restrictedFile, nil) + } + + private func createTorrent(magnet: Magnet) async throws -> Int { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/createtorrent")!) + request.httpMethod = "POST" + + guard let magnetLink = magnet.link else { + throw DebridError.EmptyData + } + + let formData = FormDataBody(params: ["magnet": magnetLink]) + request.setValue("multipart/form-data; boundary=\(formData.boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = formData.body + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse.self, from: data) + + guard let torrentId = rawResponse.data?.torrentId else { + throw DebridError.EmptyData + } + + return torrentId + } + + private func myTorrentList() async throws -> [MyTorrentListResponse] { + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/mylist")!) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse<[MyTorrentListResponse]>.self, from: data) + + guard let torrentList = rawResponse.data else { + throw DebridError.EmptyData + } + + return torrentList + } + + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { + var components = URLComponents(string: "\(baseApiUrl)/torrents/requestdl")! + components.queryItems = [ + URLQueryItem(name: "token", value: getToken()), + URLQueryItem(name: "torrent_id", value: restrictedFile.streamUrlString), + URLQueryItem(name: "file_id", value: String(restrictedFile.id)) + ] + + guard let url = components.url else { + throw DebridError.InvalidUrl + } + + var request = URLRequest(url: url) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(TBResponse.self, from: data) + + guard let unrestrictedLink = rawResponse.data else { + throw DebridError.FailedRequest(description: "Could not get an unrestricted URL from TorBox.") + } + + return unrestrictedLink + } + + // MARK: - Cloud methods + + // Unused + func getUserDownloads() {} + + func checkUserDownloads(link: String) -> String? { + link + } + + func deleteUserDownload(downloadId: String) {} + + func getUserMagnets() async throws { + let cloudMagnetList = try await myTorrentList() + cloudMagnets = cloudMagnetList.map { cloudMagnet in + + // Only need one link to force a green badge + DebridCloudMagnet( + id: String(cloudMagnet.id), + fileName: cloudMagnet.name, + status: cloudMagnet.downloadState, + hash: cloudMagnet.hash, + links: cloudMagnet.files.map { String($0.id) } + ) + } + } + + func deleteUserMagnet(cloudMagnetId: String?) async throws { + guard let cloudMagnetId else { + throw DebridError.InvalidPostBody + } + + var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/controltorrent")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ControlTorrentRequest(torrentId: cloudMagnetId, operation: "Delete") + request.httpBody = try jsonEncoder.encode(body) + + try await performRequest(request: &request, requestName: "controltorrent") + } +} diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift index 730fbdb..37dbaf8 100644 --- a/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift @@ -10,4 +10,4 @@ import CoreData import Foundation @objc(Bookmark) -public class Bookmark: NSManagedObject {} +class Bookmark: NSManagedObject {} diff --git a/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift index 39a6268..c242630 100644 --- a/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/Bookmark+CoreDataProperties.swift @@ -9,7 +9,7 @@ import CoreData import Foundation -public extension Bookmark { +extension Bookmark { @nonobjc class func fetchRequest() -> NSFetchRequest { NSFetchRequest(entityName: "Bookmark") } diff --git a/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift index 11f9202..edc0c77 100644 --- a/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/SourceHtmlParser+CoreDataProperties.swift @@ -16,6 +16,7 @@ public extension SourceHtmlParser { @NSManaged var rows: String @NSManaged var searchUrl: String? + @NSManaged var request: SourceRequest? @NSManaged var magnetHash: SourceMagnetHash? @NSManaged var magnetLink: SourceMagnetLink? @NSManaged var parentSource: Source? diff --git a/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift index 81d7287..bb52c0a 100644 --- a/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/SourceJsonParser+CoreDataProperties.swift @@ -17,6 +17,7 @@ public extension SourceJsonParser { @NSManaged var results: String? @NSManaged var subResults: String? @NSManaged var searchUrl: String + @NSManaged var request: SourceRequest? @NSManaged var magnetHash: SourceMagnetHash? @NSManaged var magnetLink: SourceMagnetLink? @NSManaged var parentSource: Source? diff --git a/Ferrite/DataManagement/Classes/SourceRequest+CoreDataClass.swift b/Ferrite/DataManagement/Classes/SourceRequest+CoreDataClass.swift new file mode 100644 index 0000000..18d7bf4 --- /dev/null +++ b/Ferrite/DataManagement/Classes/SourceRequest+CoreDataClass.swift @@ -0,0 +1,13 @@ +// +// SourceRequest+CoreDataClass.swift +// Ferrite +// +// Created by Brian Dashore on 6/10/24. +// +// + +import CoreData +import Foundation + +@objc(SourceRequest) +public class SourceRequest: NSManagedObject {} diff --git a/Ferrite/DataManagement/Classes/SourceRequest+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceRequest+CoreDataProperties.swift new file mode 100644 index 0000000..decdb10 --- /dev/null +++ b/Ferrite/DataManagement/Classes/SourceRequest+CoreDataProperties.swift @@ -0,0 +1,25 @@ +// +// SourceRequest+CoreDataProperties.swift +// Ferrite +// +// Created by Brian Dashore on 6/10/24. +// +// + +import CoreData +import Foundation + +public extension SourceRequest { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "SourceRequest") + } + + @NSManaged var method: String? + @NSManaged var headers: [String: String]? + @NSManaged var body: String? + @NSManaged var parentHtmlParser: SourceHtmlParser? + @NSManaged var parentRssParser: SourceRssParser? + @NSManaged var parentJsonParser: SourceJsonParser? +} + +extension SourceRequest: Identifiable {} diff --git a/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift b/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift index 6e8cc8e..743d2cc 100644 --- a/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift +++ b/Ferrite/DataManagement/Classes/SourceRssParser+CoreDataProperties.swift @@ -17,6 +17,7 @@ public extension SourceRssParser { @NSManaged var items: String @NSManaged var rssUrl: String? @NSManaged var searchUrl: String + @NSManaged var request: SourceRequest? @NSManaged var magnetHash: SourceMagnetHash? @NSManaged var magnetLink: SourceMagnetLink? @NSManaged var parentSource: Source? diff --git a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents index eb3bb9c..0b4a0ce 100644 --- a/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents +++ b/Ferrite/DataManagement/FerriteDB.xcdatamodeld/FerriteDB_v2.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -106,6 +106,7 @@ + @@ -118,6 +119,7 @@ + @@ -134,6 +136,14 @@ + + + + + + + + @@ -141,6 +151,7 @@ + diff --git a/Ferrite/Extensions/Color.swift b/Ferrite/Extensions/Color.swift index 8d28825..3cfe031 100644 --- a/Ferrite/Extensions/Color.swift +++ b/Ferrite/Extensions/Color.swift @@ -7,7 +7,7 @@ import SwiftUI -public extension Color { +extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 diff --git a/Ferrite/Extensions/View.swift b/Ferrite/Extensions/View.swift index d5f4880..6126b97 100644 --- a/Ferrite/Extensions/View.swift +++ b/Ferrite/Extensions/View.swift @@ -9,6 +9,15 @@ import Introspect import SwiftUI extension View { + // Modifies properties of a view. Works the same way as a ViewModifier + // From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10 + func modifyViewProp(_ body: (inout Self) -> Void) -> Self { + var result = self + body(&result) + + return result + } + // MARK: Modifiers func conditionalContextMenu(id: some Hashable, diff --git a/Ferrite/Models/ActionModels.swift b/Ferrite/Models/ActionModels.swift index 59dd4f2..cd32128 100644 --- a/Ferrite/Models/ActionModels.swift +++ b/Ferrite/Models/ActionModels.swift @@ -7,30 +7,30 @@ import Foundation -public struct ActionJson: Codable, Hashable, PluginJson { - public let name: String - public let version: Int16 +struct ActionJson: Codable, Hashable, PluginJson { + let name: String + let version: Int16 let minVersion: String? let about: String? let website: String? let requires: [ActionRequirement] let deeplink: [DeeplinkActionJson]? - public let author: String? - public let listId: UUID? - public let listName: String? - public let tags: [PluginTagJson]? + let author: String? + let listId: UUID? + let listName: String? + let tags: [PluginTagJson]? - public init(name: String, - version: Int16, - minVersion: String?, - about: String?, - website: String?, - requires: [ActionRequirement], - deeplink: [DeeplinkActionJson]?, - author: String?, - listId: UUID?, - listName: String?, - tags: [PluginTagJson]?) + init(name: String, + version: Int16, + minVersion: String?, + about: String?, + website: String?, + requires: [ActionRequirement], + deeplink: [DeeplinkActionJson]?, + author: String?, + listId: UUID?, + listName: String?, + tags: [PluginTagJson]?) { self.name = name self.version = version @@ -45,7 +45,7 @@ public struct ActionJson: Codable, Hashable, PluginJson { self.tags = tags } - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) version = try container.decode(Int16.self, forKey: .version) @@ -68,7 +68,7 @@ public struct ActionJson: Codable, Hashable, PluginJson { } } -public struct DeeplinkActionJson: Codable, Hashable { +struct DeeplinkActionJson: Codable, Hashable { let os: [String] let scheme: String @@ -77,7 +77,7 @@ public struct DeeplinkActionJson: Codable, Hashable { self.scheme = scheme } - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let os = try? container.decode(String.self, forKey: .os) { @@ -92,7 +92,7 @@ public struct DeeplinkActionJson: Codable, Hashable { } } -public extension ActionJson { +extension ActionJson { // Fetches all tags without optional requirement // Avoids the need for extra tag additions in DB func getTags() -> [PluginTagJson] { @@ -100,7 +100,7 @@ public extension ActionJson { } } -public enum ActionRequirement: String, Codable { +enum ActionRequirement: String, Codable { case magnet case debrid } diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift index 755ae4a..3faeeb8 100644 --- a/Ferrite/Models/AllDebridModels.swift +++ b/Ferrite/Models/AllDebridModels.swift @@ -7,21 +7,7 @@ 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) - } - +extension AllDebrid { // MARK: - Generic AllDebrid response // Uses a generic parametr for whatever underlying response is present @@ -67,7 +53,7 @@ public extension AllDebrid { // MARK: - AddMagnetData - internal struct AddMagnetData: Codable { + struct AddMagnetData: Codable { let magnet, hash, name, filenameOriginal: String let size: Int let ready: Bool @@ -85,7 +71,7 @@ public extension AllDebrid { struct MagnetStatusResponse: Codable { let magnets: [MagnetStatusData] - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) { @@ -117,7 +103,7 @@ public extension AllDebrid { // MARK: - MagnetStatusLink // Abridged for required parameters - internal struct MagnetStatusLink: Codable { + struct MagnetStatusLink: Codable { let link: String let filename: String let size: Int @@ -130,6 +116,19 @@ public extension AllDebrid { let link: String } + // MARK: - SavedLinksResponse + + struct SavedLinksResponse: Codable { + let links: [SavedLink] + } + + struct SavedLink: Codable, Hashable { + let link: String + let date: Int + let filename: String + let size: Int + } + // MARK: - InstantAvailabilityResponse struct InstantAvailabilityResponse: Codable { @@ -138,7 +137,7 @@ public extension AllDebrid { // MARK: - IAMagnetResponse - internal struct InstantAvailabilityMagnet: Codable { + struct InstantAvailabilityMagnet: Codable { let magnet, hash: String let instant: Bool let files: [InstantAvailabilityFile]? @@ -146,24 +145,11 @@ public extension AllDebrid { // MARK: - IAFileResponse - internal struct InstantAvailabilityFile: Codable { + struct InstantAvailabilityFile: Codable { let name: String enum CodingKeys: String, CodingKey { 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/BackupModels.swift b/Ferrite/Models/BackupModels.swift index 3ebefcc..b35c898 100644 --- a/Ferrite/Models/BackupModels.swift +++ b/Ferrite/Models/BackupModels.swift @@ -8,7 +8,7 @@ import Foundation // Version is optional until v1 is phased out -public struct Backup: Codable { +struct Backup: Codable { let version: Int? var bookmarks: [BookmarkJson]? var history: [HistoryJson]? diff --git a/Ferrite/Models/DebridManagerModels.swift b/Ferrite/Models/DebridManagerModels.swift index 484b3ef..56ea691 100644 --- a/Ferrite/Models/DebridManagerModels.swift +++ b/Ferrite/Models/DebridManagerModels.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - Universal IA enum (IA = InstantAvailability) -public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable { +enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable { case full = "Cached" case partial = "Batch" case none = "Uncached" @@ -18,7 +18,7 @@ public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable { // MARK: - Enum for debrid differentiation. 0 is nil -public enum DebridType: Int, Codable, Hashable, CaseIterable { +enum DebridType: Int, Codable, Hashable, CaseIterable { case realDebrid = 1 case allDebrid = 2 case premiumize = 3 @@ -47,7 +47,7 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable { } // Wrapper struct for magnet links to contain both the link and hash for easy access -public struct Magnet: Codable, Hashable, Sendable { +struct Magnet: Codable, Hashable, Sendable { var hash: String? var link: String? @@ -56,11 +56,13 @@ public struct Magnet: Codable, Hashable, Sendable { self.hash = parseHash(hash) self.link = generateLink(hash: hash, title: title, trackers: trackers) } else if let link, hash == nil { + let (link, hash) = parseLink(link) + self.link = link - self.hash = parseHash(extractHash(link: link)) + self.hash = hash } else { self.hash = parseHash(hash) - self.link = link + self.link = parseLink(link).link } } @@ -107,4 +109,36 @@ public struct Magnet: Codable, Hashable, Sendable { return String(magnetHash).lowercased() } } + + func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) { + let separator = "magnet:?xt=urn:btih:" + + // Remove percent encoding from the link and ensure it's a magnet + guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else { + return (nil, nil) + } + + // Isolate the magnet link if it's bundled with another protocol + let isolatedLink: String? + if decodedLink.starts(with: separator) { + isolatedLink = decodedLink + } else { + let splitLink = decodedLink.components(separatedBy: separator) + isolatedLink = splitLink.last.map { separator + $0 } + } + + guard let isolatedLink else { + return (nil, nil) + } + + // If the hash can be extracted, decrypt it if necessary and return the revised link + hash + if let originalHash = extractHash(link: isolatedLink), + let parsedHash = parseHash(originalHash) + { + let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash) + return (replacedLink, parsedHash) + } else { + return (decodedLink, nil) + } + } } diff --git a/Ferrite/Models/DebridModels.swift b/Ferrite/Models/DebridModels.swift new file mode 100644 index 0000000..d613b7e --- /dev/null +++ b/Ferrite/Models/DebridModels.swift @@ -0,0 +1,55 @@ +// +// DebridModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/2/24. +// + +import Foundation + +struct DebridIA: Hashable, Sendable { + let magnet: Magnet + let expiryTimeStamp: Double + var files: [DebridIAFile] +} + +struct DebridIAFile: Hashable, Sendable { + let id: Int + let name: String + let streamUrlString: String? + let batchIds: [Int] + + init(id: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) { + self.id = id + self.name = name + self.streamUrlString = streamUrlString + self.batchIds = batchIds + } +} + +struct DebridCloudDownload: Hashable, Sendable { + let id: String + let fileName: String + let link: String +} + +struct DebridCloudMagnet: Hashable, Sendable { + let id: String + let fileName: String + let status: String + let hash: String + let links: [String] +} + +enum DebridError: Error { + case InvalidUrl + case InvalidPostBody + case InvalidResponse + case InvalidToken + case EmptyData + case EmptyUserMagnets + case IsCaching + case FailedRequest(description: String) + case AuthQuery(description: String) + case NotImplemented +} diff --git a/Ferrite/Models/GithubModels.swift b/Ferrite/Models/GithubModels.swift index 08e007e..0533694 100644 --- a/Ferrite/Models/GithubModels.swift +++ b/Ferrite/Models/GithubModels.swift @@ -7,7 +7,7 @@ import Foundation -public extension Github { +extension Github { struct Release: Codable, Hashable, Sendable { let htmlUrl: String let tagName: String diff --git a/Ferrite/Models/OffCloudModels.swift b/Ferrite/Models/OffCloudModels.swift new file mode 100644 index 0000000..3781fe9 --- /dev/null +++ b/Ferrite/Models/OffCloudModels.swift @@ -0,0 +1,70 @@ +// +// OffCloudModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/12/24. +// + +import Foundation + +extension OffCloud { + struct ErrorResponse: Codable, Sendable { + let error: String + } + + struct InstantAvailabilityRequest: Codable, Sendable { + let hashes: [String] + } + + struct InstantAvailabilityResponse: Codable, Sendable { + let cachedItems: [String] + } + + struct CloudDownloadRequest: Codable, Sendable { + let url: String + } + + struct CloudDownloadResponse: Codable, Sendable { + let requestId: String + let fileName: String + let status: String + let originalLink: String + let url: String + } + + enum CloudExploreResponse: Codable { + case links([String]) + case error(ErrorResponse) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Only continue if the data is a List which indicates a success + if let linkArray = try? container.decode([String].self) { + self = .links(linkArray) + } else { + let value = try container.decode(ErrorResponse.self) + self = .error(value) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .links(array): + try container.encode(array) + case let .error(value): + try container.encode(value) + } + } + } + + struct CloudHistoryResponse: Codable, Sendable { + let requestId: String + let fileName: String + let status: String + let originalLink: String + let isDirectory: Bool + let server: String + } +} diff --git a/Ferrite/Models/PluginModels.swift b/Ferrite/Models/PluginModels.swift index 83ae8a5..e3dbdcd 100644 --- a/Ferrite/Models/PluginModels.swift +++ b/Ferrite/Models/PluginModels.swift @@ -7,7 +7,7 @@ import Foundation -public struct PluginListJson: Codable { +struct PluginListJson: Codable { let name: String let author: String var sources: [SourceJson]? @@ -16,8 +16,8 @@ public struct PluginListJson: Codable { // Color: Hex value public struct PluginTagJson: Codable, Hashable, Sendable { - public let name: String - public let colorHex: String? + let name: String + let colorHex: String? enum CodingKeys: String, CodingKey { case name diff --git a/Ferrite/Models/PremiumizeModels.swift b/Ferrite/Models/PremiumizeModels.swift index 21c928e..a3fdf97 100644 --- a/Ferrite/Models/PremiumizeModels.swift +++ b/Ferrite/Models/PremiumizeModels.swift @@ -7,21 +7,7 @@ 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) - } - +extension Premiumize { // MARK: - CacheCheckResponse struct CacheCheckResponse: Codable { @@ -33,8 +19,7 @@ public extension Premiumize { struct DDLResponse: Codable { let status: String - let content: [DDLData] - let location: String + let content: [DDLData]? let filename: String let filesize: Int } @@ -45,27 +30,12 @@ public extension Premiumize { let path: String let size: Int let link: String - let streamLink: String enum CodingKeys: String, CodingKey { case path, size, link - case streamLink = "stream_link" } } - // 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..fd25dca 100644 --- a/Ferrite/Models/RealDebridModels.swift +++ b/Ferrite/Models/RealDebridModels.swift @@ -8,21 +8,7 @@ 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) - } - +extension RealDebrid { // MARK: - device code endpoint struct DeviceCodeResponse: Codable, Sendable { @@ -72,7 +58,7 @@ public extension RealDebrid { struct InstantAvailabilityResponse: Codable, Sendable { var data: InstantAvailabilityData? - public init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let data = try? container.decode(InstantAvailabilityData.self) { @@ -81,23 +67,16 @@ public extension RealDebrid { } } - internal struct InstantAvailabilityData: Codable, Sendable { + struct InstantAvailabilityData: Codable, Sendable { var rd: [[String: InstantAvailabilityInfo]] } - internal struct InstantAvailabilityInfo: Codable, Sendable { + struct InstantAvailabilityInfo: Codable, Sendable { var filename: String 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 { @@ -123,7 +96,7 @@ public extension RealDebrid { // MARK: - torrentInfo endpoint - internal struct TorrentInfoResponse: Codable, Sendable { + struct TorrentInfoResponse: Codable, Sendable { let id, filename, originalFilename, hash: String let bytes, originalBytes: Int let host: String @@ -144,7 +117,7 @@ public extension RealDebrid { } } - internal struct TorrentInfoFile: Codable, Sendable { + struct TorrentInfoFile: Codable, Sendable { let id: Int let path: String let bytes, selected: Int @@ -163,7 +136,7 @@ public extension RealDebrid { // MARK: - unrestrictLink endpoint - internal struct UnrestrictLinkResponse: Codable, Sendable { + struct UnrestrictLinkResponse: Codable, Sendable { let id, filename: String let mimeType: String? let filesize: Int diff --git a/Ferrite/Models/SearchModels.swift b/Ferrite/Models/SearchModels.swift index e2a9598..49ef182 100644 --- a/Ferrite/Models/SearchModels.swift +++ b/Ferrite/Models/SearchModels.swift @@ -8,7 +8,7 @@ import Foundation // A raw search result structure displayed on the UI -public struct SearchResult: Codable, Hashable, Sendable { +struct SearchResult: Codable, Hashable, Sendable { let title: String? let source: String let size: String? diff --git a/Ferrite/Models/SourceModels.swift b/Ferrite/Models/SourceModels.swift index b0f7d91..ee5e5d9 100644 --- a/Ferrite/Models/SourceModels.swift +++ b/Ferrite/Models/SourceModels.swift @@ -7,14 +7,14 @@ import Foundation -public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable { +enum ApiCredentialResponseType: String, Codable, Hashable, Sendable { case json case text } -public struct SourceJson: Codable, Hashable, Sendable, PluginJson { - public let name: String - public let version: Int16 +struct SourceJson: Codable, Hashable, Sendable, PluginJson { + let name: String + let version: Int16 let minVersion: String? let about: String? let website: String? @@ -25,33 +25,33 @@ public struct SourceJson: Codable, Hashable, Sendable, PluginJson { let jsonParser: SourceJsonParserJson? let rssParser: SourceRssParserJson? let htmlParser: SourceHtmlParserJson? - public let author: String? - public let listId: UUID? - public let listName: String? - public let tags: [PluginTagJson]? + let author: String? + let listId: UUID? + let listName: String? + let tags: [PluginTagJson]? } -public extension SourceJson { +extension SourceJson { // Fetches all tags without optional requirement func getTags() -> [PluginTagJson] { tags ?? [] } } -public enum SourcePreferredParser: Int16, CaseIterable, Sendable { +enum SourcePreferredParser: Int16, CaseIterable, Sendable { // case none = 0 case scraping = 1 case rss = 2 case siteApi = 3 } -public struct SourceApiJson: Codable, Hashable, Sendable { +struct SourceApiJson: Codable, Hashable, Sendable { let apiUrl: String? let clientId: SourceApiCredentialJson? let clientSecret: SourceApiCredentialJson? } -public struct SourceApiCredentialJson: Codable, Hashable, Sendable { +struct SourceApiCredentialJson: Codable, Hashable, Sendable { let query: String? let value: String? let dynamic: Bool? @@ -60,8 +60,9 @@ public struct SourceApiCredentialJson: Codable, Hashable, Sendable { let expiryLength: Double? } -public struct SourceJsonParserJson: Codable, Hashable, Sendable { +struct SourceJsonParserJson: Codable, Hashable, Sendable { let searchUrl: String + let request: SourceRequestJson? let results: String? let subResults: String? let title: SourceComplexQueryJson @@ -72,9 +73,10 @@ public struct SourceJsonParserJson: Codable, Hashable, Sendable { let sl: SourceSLJson? } -public struct SourceRssParserJson: Codable, Hashable, Sendable { +struct SourceRssParserJson: Codable, Hashable, Sendable { let rssUrl: String? let searchUrl: String + let request: SourceRequestJson? let items: String let title: SourceComplexQueryJson let magnetHash: SourceComplexQueryJson? @@ -84,8 +86,9 @@ public struct SourceRssParserJson: Codable, Hashable, Sendable { let sl: SourceSLJson? } -public struct SourceHtmlParserJson: Codable, Hashable, Sendable { +struct SourceHtmlParserJson: Codable, Hashable, Sendable { let searchUrl: String? + let request: SourceRequestJson? let rows: String let title: SourceComplexQueryJson let magnet: SourceMagnetJson @@ -94,21 +97,21 @@ public struct SourceHtmlParserJson: Codable, Hashable, Sendable { let sl: SourceSLJson? } -public struct SourceComplexQueryJson: Codable, Hashable, Sendable { +struct SourceComplexQueryJson: Codable, Hashable, Sendable { let query: String let discriminator: String? let attribute: String? let regex: String? } -public struct SourceMagnetJson: Codable, Hashable, Sendable { +struct SourceMagnetJson: Codable, Hashable, Sendable { let query: String let attribute: String let regex: String? let externalLinkQuery: String? } -public struct SourceSLJson: Codable, Hashable, Sendable { +struct SourceSLJson: Codable, Hashable, Sendable { let seeders: String? let leechers: String? let combined: String? @@ -117,3 +120,9 @@ public struct SourceSLJson: Codable, Hashable, Sendable { let seederRegex: String? let leecherRegex: String? } + +struct SourceRequestJson: Codable, Hashable, Sendable { + let method: String? + let headers: [String: String]? + let body: String? +} diff --git a/Ferrite/Models/TorBoxModels.swift b/Ferrite/Models/TorBoxModels.swift new file mode 100644 index 0000000..d2c0af8 --- /dev/null +++ b/Ferrite/Models/TorBoxModels.swift @@ -0,0 +1,110 @@ +// +// TorBoxModels.swift +// Ferrite +// +// Created by Brian Dashore on 6/11/24. +// + +import Foundation + +extension TorBox { + struct TBResponse: Codable { + let success: Bool + let detail: String + let data: TBData? + } + + // MARK: - InstantAvailability + + enum InstantAvailabilityData: Codable { + case links([InstantAvailabilityDataObject]) + case failure(InstantAvailabilityDataFailure) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Only continue if the data is a List which indicates a success + if let linkArray = try? container.decode([InstantAvailabilityDataObject].self) { + self = .links(linkArray) + } else { + let value = try container.decode(InstantAvailabilityDataFailure.self) + self = .failure(value) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .links(array): + try container.encode(array) + case let .failure(value): + try container.encode(value) + } + } + } + + struct InstantAvailabilityDataObject: Codable, Sendable { + let name: String + let size: Int + let hash: String + let files: [InstantAvailabilityFile] + } + + struct InstantAvailabilityFile: Codable, Sendable { + let name: String + let size: Int + } + + struct InstantAvailabilityDataFailure: Codable, Sendable { + let data: Bool + } + + struct CreateTorrentResponse: Codable, Sendable { + let hash: String + let torrentId: Int + let authId: String + + enum CodingKeys: String, CodingKey { + case hash + case torrentId = "torrent_id" + case authId = "auth_id" + } + } + + struct MyTorrentListResponse: Codable, Sendable { + let id: Int + let hash: String + let name: String + let downloadState: String + let files: [MyTorrentListFile] + + enum CodingKeys: String, CodingKey { + case id, hash, name, files + case downloadState = "download_state" + } + } + + struct MyTorrentListFile: Codable, Sendable { + let id: Int + let hash: String + let name: String + let shortName: String + + enum CodingKeys: String, CodingKey { + case id, hash, name + case shortName = "short_name" + } + } + + typealias RequestDLResponse = String + + struct ControlTorrentRequest: Codable, Sendable { + let torrentId: String + let operation: String + + enum CodingKeys: String, CodingKey { + case operation + case torrentId = "torrent_id" + } + } +} diff --git a/Ferrite/Protocols/Debrid.swift b/Ferrite/Protocols/Debrid.swift new file mode 100644 index 0000000..c325bb8 --- /dev/null +++ b/Ferrite/Protocols/Debrid.swift @@ -0,0 +1,83 @@ +// +// Debrid.swift +// Ferrite +// +// Created by Brian Dashore on 6/1/24. +// + +import Foundation + +protocol DebridSource: AnyObservableObject { + // ID of the service + // var id: DebridInfo { get } + var id: String { get } + var abbreviation: String { get } + var website: String { get } + var description: String? { get } + var cachedStatus: [String] { get } + + // Auth variables + var authProcessing: Bool { get set } + var isLoggedIn: Bool { get } + + // Manual API key + var manualToken: String? { get } + + // Instant availability variables + var IAValues: [DebridIA] { get set } + + // Cloud variables + var cloudDownloads: [DebridCloudDownload] { get set } + var cloudMagnets: [DebridCloudMagnet] { get set } + var cloudTTL: Double { get set } + + // Common authentication functions + func setApiKey(_ key: String) + func logout() async + + // Instant availability functions + func instantAvailability(magnets: [Magnet]) async throws + + // Fetches a download link from a source + // Include the instant availability information with the args + // Cloud magnets also checked here + func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) + + // Unrestricts a locked file + func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String + + // User downloads functions + func getUserDownloads() async throws + func checkUserDownloads(link: String) async throws -> String? + func deleteUserDownload(downloadId: String) async throws + + // User magnet functions + func getUserMagnets() async throws + func deleteUserMagnet(cloudMagnetId: String?) async throws +} + +extension DebridSource { + var description: String? { + nil + } + + var cachedStatus: [String] { + [] + } +} + +protocol PollingDebridSource: DebridSource { + // Task reference for polling + var authTask: Task? { get set } + + // Fetches the Auth URL + func getAuthUrl() async throws -> URL +} + +protocol OAuthDebridSource: DebridSource { + // Fetches the auth URL + func getAuthUrl() throws -> URL + + // Handles an OAuth callback + func handleAuthCallback(url: URL) throws +} diff --git a/Ferrite/Protocols/Plugin.swift b/Ferrite/Protocols/Plugin.swift index adbe1ee..65cc4bf 100644 --- a/Ferrite/Protocols/Plugin.swift +++ b/Ferrite/Protocols/Plugin.swift @@ -8,7 +8,7 @@ import CoreData import Foundation -public protocol Plugin: ObservableObject, NSManagedObject { +protocol Plugin: ObservableObject, NSManagedObject { var id: UUID { get set } var listId: UUID? { get set } var name: String { get set } @@ -27,7 +27,7 @@ extension Plugin { } } -public protocol PluginJson: Hashable { +protocol PluginJson: Hashable { var name: String { get } var version: Int16 { get } var author: String? { get } diff --git a/Ferrite/Utils/Application.swift b/Ferrite/Utils/Application.swift index d27584e..e125246 100644 --- a/Ferrite/Utils/Application.swift +++ b/Ferrite/Utils/Application.swift @@ -9,7 +9,7 @@ import Foundation -public class Application { +class Application { static let shared = Application() // OS name for Plugins to read. Lowercase for ease of use diff --git a/Ferrite/Utils/FerriteKeychain.swift b/Ferrite/Utils/FerriteKeychain.swift new file mode 100644 index 0000000..b39ea83 --- /dev/null +++ b/Ferrite/Utils/FerriteKeychain.swift @@ -0,0 +1,13 @@ +// +// FerriteKeychain.swift +// Ferrite +// +// Created by Brian Dashore on 4/30/23. +// + +import Foundation +import KeychainSwift + +class FerriteKeychain { + static let shared = KeychainSwift() +} diff --git a/Ferrite/Utils/FormDataBody.swift b/Ferrite/Utils/FormDataBody.swift new file mode 100644 index 0000000..d76e84b --- /dev/null +++ b/Ferrite/Utils/FormDataBody.swift @@ -0,0 +1,27 @@ +// +// MultipartFormDataRequest.swift +// Ferrite +// +// Created by Brian Dashore on 6/12/24. +// + +import Foundation + +struct FormDataBody { + let boundary: String = UUID().uuidString + let body: Data + + init(params: [String: String]) { + var body = Data() + + for (key, value) in params { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + + self.body = body + } +} diff --git a/Ferrite/Utils/Store.swift b/Ferrite/Utils/Store.swift new file mode 100644 index 0000000..773d39c --- /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()) + } +} + +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 +struct Store { + /// The underlying object being stored. + let wrappedValue: ObjectType + + // See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38 + fileprivate var _observableObject: ObservedObject + + @MainActor 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) + /// } + /// } + /// ``` + 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`. + 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:)``. + 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 + struct Wrapper { + private var store: Store + + init(_ store: Store) { + self.store = store + } + + /// Returns a binding to the resulting value of a given key path. + subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> Binding { + Binding { + self.store.wrappedValue[keyPath: keyPath] + } set: { + self.store.wrappedValue[keyPath: keyPath] = $0 + } + } + } +} + +extension Store: DynamicProperty { + nonisolated mutating func update() { + _observableObject.update() + } +} diff --git a/Ferrite/ViewModels/BackupManager.swift b/Ferrite/ViewModels/BackupManager.swift index 588c1ac..bfbafce 100644 --- a/Ferrite/ViewModels/BackupManager.swift +++ b/Ferrite/ViewModels/BackupManager.swift @@ -7,9 +7,9 @@ import Foundation -public class BackupManager: ObservableObject { +class BackupManager: ObservableObject { // Constant variable for backup versions - let latestBackupVersion: Int = 2 + private let latestBackupVersion: Int = 2 var logManager: LoggingManager? @@ -21,17 +21,17 @@ public class BackupManager: ObservableObject { @Published var selectedBackupUrl: URL? @MainActor - func updateRestoreCompletedMessage(newString: String) { + private func updateRestoreCompletedMessage(newString: String) { restoreCompletedMessage.append(newString) } @MainActor - func toggleRestoreCompletedAlert() { + private func toggleRestoreCompletedAlert() { showRestoreCompletedAlert.toggle() } @MainActor - func updateBackupUrls(newUrl: URL) { + private func updateBackupUrls(newUrl: URL) { backupUrls.append(newUrl) } diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 107fbc0..654110c 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -9,29 +9,34 @@ import Foundation import SwiftUI @MainActor -public class DebridManager: ObservableObject { +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() + @Published var torbox: TorBox = .init() + @Published var offcloud: OffCloud = .init() + + lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud] // UI Variables @Published var showWebView: Bool = false @Published var showAuthSession: Bool = false + @Published var enabledDebrids: [DebridSource] = [] - // Service agnostic variables - @Published var enabledDebrids: Set = [] { + @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? + var requiresUnrestrict: Bool = false + + // TODO: Figure out a way to remove this var + private var selectedOAuthDebridSource: OAuthDebridSource? @Published var filteredIAStatus: Set = [] @@ -39,92 +44,48 @@ public class DebridManager: ObservableObject { var downloadUrl: String = "" var authUrl: URL? - // RealDebrid auth variables - @Published 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 - @Published 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] = [] - var allDebridCloudTTL: Double = 0.0 - - // Premiumize auth variables - @Published 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 + @Published var showWebLoginAlert: Bool = false + @Published var showNotImplementedAlert: Bool = false + @Published var notImplementedMessage: String = "" init() { - if let rawDebridList = UserDefaults.standard.string(forKey: "Debrid.EnabledArray"), - let serializedDebridList = Set(rawValue: rawDebridList) - { - enabledDebrids = serializedDebridList - } + // Update the UI for debrid services that are enabled + enabledDebrids = debridSources.filter(\.isLoggedIn) - // If a UserDefaults integer isn't set, it's usually 0 - let rawPreferredService = UserDefaults.standard.integer(forKey: "Debrid.PreferredService") - selectedDebridType = DebridType(rawValue: rawPreferredService) + // Set the preferred service. Contains migration logic for earlier versions + if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") { + let debridServiceId: String? - // If a user has one logged in service, automatically set the preferred service to that one - if enabledDebrids.count == 1 { - selectedDebridType = enabledDebrids.first + if let preferredServiceInt = Int(rawPreferredService) { + debridServiceId = migratePreferredService(preferredServiceInt) + } else { + debridServiceId = rawPreferredService + } + + // 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 + private 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 // Error can be suppressed to end user but must be printed in logs - func sendDebridError( + private func sendDebridError( _ error: Error, prefix: String, presentError: Bool = true, @@ -151,173 +112,69 @@ public class DebridManager: ObservableObject { } // Cleans all cached IA values in the event of a full IA refresh - public func clearIAValues() { - realDebridIAValues = [] - allDebridIAValues = [] - premiumizeIAValues = [] - } - - // 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 + func clearIAValues() { + for debridSource in debridSources { + debridSource.IAValues = [] } } + // Clears all selected files and items + func clearSelectedDebridItems() { + selectedDebridItem = nil + selectedDebridFile = nil + } + // Common function to populate hashes for debrid services - public func populateDebridIA(_ resultMagnets: [Magnet]) async { - do { - 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 - } + func populateDebridIA(_ resultMagnets: [Magnet]) async { + for debridSource in debridSources { + if !debridSource.isLoggedIn { + continue } - if !sendMagnets.isEmpty { - if enabledDebrids.contains(.realDebrid) { - let fetchedRealDebridIA = try await realDebrid.instantAvailability(magnets: sendMagnets) - realDebridIAValues += fetchedRealDebridIA - } - - if enabledDebrids.contains(.allDebrid) { - let fetchedAllDebridIA = try await allDebrid.instantAvailability(magnets: sendMagnets) - allDebridIAValues += fetchedAllDebridIA - } - - if enabledDebrids.contains(.premiumize) { - // 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 - } - } + // 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") } - } catch { - await sendDebridError(error, prefix: "Hash population error") } } // Common function to match a magnet hash with a provided debrid service - public func matchMagnetHash(_ magnet: Magnet) -> IAStatus { + func matchMagnetHash(_ magnet: Magnet) -> IAStatus { guard let magnetHash = magnet.hash else { 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 } } - public func selectDebridResult(magnet: Magnet) -> Bool { + func selectDebridResult(magnet: Magnet) -> Bool { guard let magnetHash = magnet.hash else { - logManager?.error("DebridManager: Could not find the torrent magnet hash") + logManager?.error("DebridManager: Could not find the magnet hash") 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 + guard let selectedSource = selectedDebridSource else { + return false + } + + if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) { + selectedDebridItem = IAItem + + if IAItem.files.count == 1 { + selectedDebridFile = IAItem.files[safe: 0] } - 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: + + return true + } else { + logManager?.error("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)") return false } } @@ -325,48 +182,83 @@ public class DebridManager: ObservableObject { // MARK: - Authentication UI linked functions // Common function to delegate what debrid service to authenticate with - public func authenticateDebrid(debridType: DebridType) async { - switch debridType { - case .realDebrid: - let success = await authenticateRd() - completeDebridAuth(debridType, success: success) - case .allDebrid: - let success = await authenticateAd() - completeDebridAuth(debridType, success: success) - case .premiumize: - await authenticatePm() + func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async { + defer { + // Don't cancel processing if using OAuth + if !(debridSource is OAuthDebridSource) { + debridSource.authProcessing = false + } + + if enabledDebrids.count == 1 { + selectedDebridSource = debridSource + } + } + + // Set an API key if manually provided + if let apiKey { + debridSource.setApiKey(apiKey) + enabledDebrids.append(debridSource) + + 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 + enabledDebrids.append(debridSource) + } 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 { + // Let the user know that a traditional auth method doesn't exist + showWebLoginAlert.toggle() + + logManager?.error( + "DebridManager: Auth: \(debridSource.id) does not have a login portal.", + showToast: false + ) + + return } } - public func getAuthProcessingBool(debridType: DebridType) -> Bool { - switch debridType { - case .realDebrid: - return realDebridAuthProcessing - case .allDebrid: - return allDebridAuthProcessing - case .premiumize: - return premiumizeAuthProcessing - } - } + // Get a truncated manual API key if it's being used + func getManualAuthKey(_ debridSource: some DebridSource) async -> String? { + if let debridToken = debridSource.manualToken { + let splitString = debridToken.suffix(4) - // Callback to finish debrid auth since functions can be split - func completeDebridAuth(_ debridType: DebridType, success: Bool = true) { - if enabledDebrids.count == 1, success { - selectedDebridType = enabledDebrids.first - } - - switch debridType { - case .realDebrid: - realDebridAuthProcessing = false - case .allDebrid: - allDebridAuthProcessing = false - case .premiumize: - premiumizeAuthProcessing = false + if debridToken.count > 4 { + return String(repeating: "*", count: debridToken.count - 4) + splitString + } else { + return String(splitString) + } + } else { + return nil } } // Wrapper function to validate and present an auth URL to the user - @discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { + @discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { guard let url else { logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))") return false @@ -382,133 +274,56 @@ 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) - enabledDebrids.insert(.realDebrid) - } else { - throw RealDebrid.RDError.AuthQuery(description: "The verification URL was invalid") - } - - return true - } 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) - enabledDebrids.insert(.allDebrid) - } else { - throw AllDebrid.ADError.AuthQuery(description: "The PIN URL was invalid") - } - - return true - } 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 { + func handleAuthCallback(url: URL?, error: Error?) async { + defer { + if enabledDebrids.count == 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) - enabledDebrids.insert(.premiumize) - completeDebridAuth(.premiumize) + try oauthDebridSource.handleAuthCallback(url: callbackUrl) + enabledDebrids.append(oauthDebridSource) } 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() + 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) + enabledDebrids.removeAll { $0.id == debridSource.id } } // MARK: - Debrid fetch UI linked functions // Common function to delegate what debrid service to fetch from // Cloudinfo is used for any extra information provided by debrid cloud - public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { + func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { defer { - currentDebridTask = nil logManager?.hideIndeterminateToast() + currentDebridTask = nil } logManager?.updateIndeterminateToast("Loading content", cancelAction: { @@ -516,268 +331,154 @@ 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 - } - } - - public func fetchDebridCloud() async { - switch selectedDebridType { - case .realDebrid: - await fetchRdCloud() - case .allDebrid: - await fetchAdCloud() - case .premiumize: - await fetchPmCloud() - case .none: + guard let debridSource = selectedDebridSource else { return } - } - - 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] - } 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) - { - downloadUrl = downloadLink - } else if let magnet { - // Add a magnet after all the cache checks fail - selectedRealDebridID = try await realDebrid.addMagnet(magnet: magnet) + if let cloudInfo { + downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? "" + return + } - var fileIds: [Int] = [] - if let iaFile = selectedRealDebridFile { - guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else { - return + if let magnet { + let (restrictedFile, newIA) = try await debridSource.getRestrictedFile( + magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile + ) + + // Indicate that a link needs to be selected (batch) + if let newIA { + if newIA.files.isEmpty { + throw DebridError.EmptyData } - fileIds = iaBatchFromFile.files.map(\.id) + selectedDebridItem = newIA + requiresUnrestrict = true + + return } - 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." - ) + guard let restrictedFile else { + throw DebridError.FailedRequest(description: "No files found for your request") } + + // Update the UI + downloadUrl = try await debridSource.unrestrictFile(restrictedFile) } 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") } + } + } + func unrestrictDownload() async { + defer { logManager?.hideIndeterminateToast() + requiresUnrestrict = false + currentDebridTask = nil + } + + logManager?.updateIndeterminateToast("Loading content", cancelAction: { + self.currentDebridTask?.cancel() + self.currentDebridTask = nil + }) + + guard let debridFile = selectedDebridFile, let debridSource = selectedDebridSource else { + logManager?.error("DebridManager: Could not unrestrict the selected debrid file.") + + return + } + + do { + let downloadLink = try await debridSource.unrestrictFile(debridFile) + + downloadUrl = downloadLink + } catch { + await sendDebridError(error, prefix: "\(debridSource.id) unrestrict error", cancelString: "Unrestrict cancelled") } } - // Refreshes torrents and downloads from a RD user's account - public func fetchRdCloud(bypassTTL: Bool = false) async { - if bypassTTL || Date().timeIntervalSince1970 > realDebridCloudTTL { + // Wrapper to handle cloud fetching + func fetchDebridCloud(bypassTTL: Bool = false) async { + guard let selectedSource = selectedDebridSource else { + return + } + + if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL { do { - realDebridCloudTorrents = try await realDebrid.userTorrents() - realDebridCloudDownloads = try await realDebrid.userDownloads() + // Populates the inner downloads and magnet arrays + try await selectedSource.getUserDownloads() + try await selectedSource.getUserMagnets() - // 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 - } - } - - 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 { - downloadUrl = try await allDebrid.unlockLink(lockedLink: lockedLink) - } else if let magnet { - let magnetID = try await allDebrid.addMagnet(magnet: magnet) - let lockedLink = try await allDebrid.fetchMagnetStatus( - magnetId: magnetID, - selectedIndex: selectedAllDebridFile?.id ?? 0 - ) - - 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") - } - } - - // 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() - - // 5 minutes - allDebridCloudTTL = Date().timeIntervalSince1970 + 300 - } catch { - await sendDebridError(error, prefix: "AlLDebrid cloud fetch error") - } - } - } - - func deleteAdMagnet(magnetId: Int) async { - do { - try await allDebrid.deleteMagnet(magnetId: magnetId) - - await fetchAdCloud(bypassTTL: true) - } catch { - await sendDebridError(error, prefix: "AllDebrid 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) + 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.deleteUserDownload(downloadId: download.id) + + await fetchDebridCloud(bypassTTL: true) } catch { - await sendDebridError(error, prefix: "Premiumize cloud delete error") + switch error { + case DebridError.NotImplemented: + let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website." + + notImplementedMessage = message + showNotImplementedAlert.toggle() + logManager?.error( + "DebridManager: \(message)", + showToast: false + ) + default: + await sendDebridError(error, prefix: "\(selectedSource.id) download delete error") + } + } + } + + func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async { + guard let selectedSource = selectedDebridSource else { + return + } + + do { + try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id) + + await fetchDebridCloud(bypassTTL: true) + } catch { + switch error { + case DebridError.NotImplemented: + let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website." + + notImplementedMessage = message + showNotImplementedAlert.toggle() + logManager?.error( + "DebridManager: \(message)", + showToast: false + ) + default: + await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error") + } } } } diff --git a/Ferrite/ViewModels/LoggingManager.swift b/Ferrite/ViewModels/LoggingManager.swift index 3e84b14..c790fb6 100644 --- a/Ferrite/ViewModels/LoggingManager.swift +++ b/Ferrite/ViewModels/LoggingManager.swift @@ -70,8 +70,8 @@ class LoggingManager: ObservableObject { // TODO: Maybe append to a constant logfile? - public func info(_ message: String, - description: String? = nil) + func info(_ message: String, + description: String? = nil) { let log = Log( level: .info, @@ -88,8 +88,8 @@ class LoggingManager: ObservableObject { print("LOG: \(log.toMessage())") } - public func warn(_ message: String, - description: String? = nil) + func warn(_ message: String, + description: String? = nil) { let log = Log( level: .warn, @@ -106,9 +106,9 @@ class LoggingManager: ObservableObject { print("LOG: \(log.toMessage())") } - public func error(_ message: String, - description: String? = nil, - showToast: Bool = true) + func error(_ message: String, + description: String? = nil, + showToast: Bool = true) { let log = Log( level: .error, @@ -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." } } @@ -132,7 +132,7 @@ class LoggingManager: ObservableObject { // MARK: - Indeterminate functions - public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) { + func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) { indeterminateToastDescription = description if let cancelAction { @@ -144,13 +144,13 @@ class LoggingManager: ObservableObject { } } - public func hideIndeterminateToast() { + func hideIndeterminateToast() { showIndeterminateToast = false indeterminateToastDescription = "" indeterminateCancelAction = nil } - public func exportLogs() { + func exportLogs() { logFormatter.dateFormat = "yyyy-MM-dd-HHmmss" let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt" let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs") diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index 910ce27..1095b85 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -8,12 +8,12 @@ import SwiftUI @MainActor -public class NavigationViewModel: ObservableObject { +class NavigationViewModel: ObservableObject { var logManager: LoggingManager? // Used between SearchResultsView and MagnetChoiceView - public enum ChoiceSheetType: Identifiable { - public var id: Int { + enum ChoiceSheetType: Identifiable { + var id: Int { hashValue } @@ -53,7 +53,7 @@ public class NavigationViewModel: ObservableObject { @Published var currentSortFilter: SortFilter? @Published var currentSortOrder: SortOrder = .forward - public func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool { + func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool { switch currentSortFilter { case .leechers: guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else { @@ -97,7 +97,7 @@ public class NavigationViewModel: ObservableObject { @Published var searchPrompt: String = "Search" @Published var lastSearchPromptIndex: Int = -1 - let searchBarTextArray: [String] = [ + private let searchBarTextArray: [String] = [ "What's on your mind?", "Discover something interesting", "Find an engaging show", diff --git a/Ferrite/ViewModels/PluginManager.swift b/Ferrite/ViewModels/PluginManager.swift index 664fdb9..58c57ab 100644 --- a/Ferrite/ViewModels/PluginManager.swift +++ b/Ferrite/ViewModels/PluginManager.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI import Yams -public class PluginManager: ObservableObject { +class PluginManager: ObservableObject { var logManager: LoggingManager? let kodi: Kodi = .init() @@ -25,18 +25,18 @@ public class PluginManager: ObservableObject { @Published var actionSuccessAlertMessage: String = "" @MainActor - func cleanAvailablePlugins() { + private func cleanAvailablePlugins() { availableSources = [] availableActions = [] } @MainActor - func updateAvailablePlugins(_ newPlugins: AvailablePlugins) { + private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) { availableSources += newPlugins.availableSources availableActions += newPlugins.availableActions } - public func fetchPluginsFromUrl() async { + func fetchPluginsFromUrl() async { let pluginListRequest = PluginList.fetchRequest() guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else { await logManager?.error("PluginManager: No plugin lists found") @@ -97,7 +97,7 @@ public class PluginManager: ObservableObject { await logManager?.info("Plugin list fetch finished") } - func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? { + private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? { var tempSources: [SourceJson] = [] var tempActions: [ActionJson] = [] @@ -176,7 +176,7 @@ public class PluginManager: ObservableObject { } // Checks if a deeplink action is present and if there's a single action for the OS (or fallback) - func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? { + private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? { let osArray = deeplinks.filter { deeplink in deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() }) } @@ -244,7 +244,7 @@ public class PluginManager: ObservableObject { } } - func fetchCastedPlugins(_ forType: PJ.Type) -> [PJ] { + private func fetchCastedPlugins(_ forType: PJ.Type) -> [PJ] { switch String(describing: PJ.self) { case "SourceJson": return availableSources as? [PJ] ?? [] @@ -256,7 +256,7 @@ public class PluginManager: ObservableObject { } // Checks if the current app version is supported by the source - func checkAppVersion(minVersion: String?) -> Bool { + private func checkAppVersion(minVersion: String?) -> Bool { // If there's no min version, assume that every version is supported guard let minVersion else { return true @@ -266,7 +266,7 @@ public class PluginManager: ObservableObject { } // Fetches sources using the background context - public func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] { + func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] { let backgroundContext = PersistenceController.shared.backgroundContext if !filteredInstalledSources.isEmpty, !searchResultsEmpty { @@ -279,7 +279,7 @@ public class PluginManager: ObservableObject { } @MainActor - public func runDefaultAction(urlString: String?, navModel: NavigationViewModel) { + func runDefaultAction(urlString: String?, navModel: NavigationViewModel) { let context = PersistenceController.shared.backgroundContext guard let urlString else { @@ -332,7 +332,7 @@ public class PluginManager: ObservableObject { // The iOS version of Ferrite only runs deeplink actions @MainActor - public func runDeeplinkAction(_ action: Action, urlString: String?) { + func runDeeplinkAction(_ action: Action, urlString: String?) { guard let deeplink = action.deeplink, let urlString else { actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!" showActionErrorAlert.toggle() @@ -355,7 +355,7 @@ public class PluginManager: ObservableObject { } @MainActor - public func sendToKodi(urlString: String?, server: KodiServer) async { + func sendToKodi(urlString: String?, server: KodiServer) async { guard let urlString else { actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send" showActionErrorAlert.toggle() @@ -380,7 +380,7 @@ public class PluginManager: ObservableObject { } } - public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async { + func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async { guard let actionJson else { await logManager?.error("Action addition: No action present. Contact the app dev!") return @@ -448,7 +448,7 @@ public class PluginManager: ObservableObject { } } - public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async { + func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async { guard let sourceJson else { await logManager?.error("Source addition: No source present. Contact the app dev!") return @@ -535,7 +535,7 @@ public class PluginManager: ObservableObject { } } - func addSourceApi(newSource: Source, apiJson: SourceApiJson) { + private func addSourceApi(newSource: Source, apiJson: SourceApiJson) { let backgroundContext = PersistenceController.shared.backgroundContext let newSourceApi = SourceApi(context: backgroundContext) @@ -570,7 +570,8 @@ public class PluginManager: ObservableObject { newSource.api = newSourceApi } - func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) { + // TODO: Migrate parser addition to a common protocol + private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) { let backgroundContext = PersistenceController.shared.backgroundContext let newSourceJsonParser = SourceJsonParser(context: backgroundContext) @@ -578,6 +579,13 @@ public class PluginManager: ObservableObject { newSourceJsonParser.results = jsonParserJson.results newSourceJsonParser.subResults = jsonParserJson.subResults + if let requestJson = newSourceJsonParser.request { + let newParserRequest = SourceRequest(context: backgroundContext) + newParserRequest.method = requestJson.method + newParserRequest.headers = requestJson.headers + newParserRequest.body = requestJson.body + } + // Tune these complex queries to the final JSON parser format if let magnetLinkJson = jsonParserJson.magnetLink { let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) @@ -638,7 +646,7 @@ public class PluginManager: ObservableObject { newSource.jsonParser = newSourceJsonParser } - func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) { + private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) { let backgroundContext = PersistenceController.shared.backgroundContext let newSourceRssParser = SourceRssParser(context: backgroundContext) @@ -646,6 +654,13 @@ public class PluginManager: ObservableObject { newSourceRssParser.searchUrl = rssParserJson.searchUrl newSourceRssParser.items = rssParserJson.items + if let requestJson = newSourceRssParser.request { + let newParserRequest = SourceRequest(context: backgroundContext) + newParserRequest.method = requestJson.method + newParserRequest.headers = requestJson.headers + newParserRequest.body = requestJson.body + } + if let magnetLinkJson = rssParserJson.magnetLink { let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) newSourceMagnetLink.query = magnetLinkJson.query @@ -710,7 +725,7 @@ public class PluginManager: ObservableObject { newSource.rssParser = newSourceRssParser } - func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) { + private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) { let backgroundContext = PersistenceController.shared.backgroundContext let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext) @@ -726,6 +741,16 @@ public class PluginManager: ObservableObject { newSourceHtmlParser.subName = newSourceSubName } + if let requestJson = htmlParserJson.request { + print(requestJson) + let newParserRequest = SourceRequest(context: backgroundContext) + newParserRequest.method = requestJson.method + newParserRequest.headers = requestJson.headers + newParserRequest.body = requestJson.body + + newSourceHtmlParser.request = newParserRequest + } + // Adds a title complex query let newSourceTitle = SourceTitle(context: backgroundContext) newSourceTitle.query = htmlParserJson.title.query @@ -770,7 +795,7 @@ public class PluginManager: ObservableObject { // Adds a plugin list // Can move this to PersistenceController if needed - public func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws { + func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws { let backgroundContext = PersistenceController.shared.backgroundContext if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") { diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index ab7d9a3..82d5c69 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -27,18 +27,18 @@ class ScrapingViewModel: ObservableObject { // Only add results with valid magnet hashes to the search results array @MainActor - func updateSearchResults(newResults: [SearchResult]) { + private func updateSearchResults(newResults: [SearchResult]) { searchResults += newResults } @MainActor - func clearSearchResults() { + private func clearSearchResults() { searchResults = [] } @Published var currentSourceNames: Set = [] @MainActor - func updateCurrentSourceNames(_ newName: String) { + private func updateCurrentSourceNames(_ newName: String) { currentSourceNames.insert(newName) logManager?.updateIndeterminateToast( "Loading \(currentSourceNames.joined(separator: ", "))", @@ -47,7 +47,7 @@ class ScrapingViewModel: ObservableObject { } @MainActor - func removeCurrentSourceName(_ removedName: String) { + private func removeCurrentSourceName(_ removedName: String) { currentSourceNames.remove(removedName) logManager?.updateIndeterminateToast( "Loading \(currentSourceNames.joined(separator: ", "))", @@ -56,17 +56,39 @@ class ScrapingViewModel: ObservableObject { } @MainActor - func clearCurrentSourceNames() { + private func clearCurrentSourceNames() { currentSourceNames = [] logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil) } // Utility function to print source specific errors - func sendSourceError(_ description: String) async { + private func sendSourceError(_ description: String) async { await logManager?.error(description, showToast: false) } - public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async { + // Substitutes the given string with an arbitrary parameter dictionary + private func substituteParams(_ input: String, with params: [String: String]) -> String { + let replaced = params.reduce(input) { result, param -> String in + result.replacingOccurrences(of: "{\(param.key)}", with: param.value) + } + + return replaced + } + + // Cleans a SourceRequest's body and headers to be substituted + private func cleanRequest(request: SourceRequest, params: [String: String]) -> SourceRequest { + if let body = request.body { + request.body = substituteParams(body, with: params) + } + + if let headers = request.headers { + request.headers = headers.mapValues { substituteParams($0, with: params) } + } + + return request + } + + func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async { await logManager?.info("Started scanning sources for query \"\(searchText)\"") if sources.isEmpty { @@ -144,7 +166,7 @@ class ScrapingViewModel: ObservableObject { } } - func executeParser(source: Source) async -> SearchRequestResult? { + private func executeParser(source: Source) async -> SearchRequestResult? { guard let website = source.website else { await logManager?.error("Scraping: The base URL could not be found for source \(source.name)") @@ -160,18 +182,26 @@ class ScrapingViewModel: ObservableObject { return nil } + // Initial params dict to reference + // More params are added here as needed + var params: [String: String] = [ + "query": encodedQuery, + "queryFirstLetter": encodedQuery.first.map { String($0).lowercased() } ?? "" + ] + switch preferredParser { case .scraping: if let htmlParser = source.htmlParser { let replacedSearchUrl = htmlParser.searchUrl.map { - $0.replacingOccurrences(of: "{query}", with: encodedQuery) + substituteParams($0, with: params) } let data = await handleUrls( website: website, replacedSearchUrl: replacedSearchUrl, fallbackUrls: source.fallbackUrls, - sourceName: source.name + sourceName: source.name, + requestParams: htmlParser.request.map { cleanRequest(request: $0, params: params) } ) if let data, @@ -182,23 +212,25 @@ class ScrapingViewModel: ObservableObject { } case .rss: if let rssParser = source.rssParser { - let replacedSearchUrl = rssParser.searchUrl - .replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "") - .replacingOccurrences(of: "{query}", with: encodedQuery) + params.updateValue(source.api?.clientSecret?.value ?? "", forKey: "secret") + + let replacedSearchUrl = substituteParams(rssParser.searchUrl, with: params) // Do not use fallback URLs if the base URL isn't used let data: Data? if let rssUrl = rssParser.rssUrl { data = await fetchWebsiteData( urlString: rssUrl + replacedSearchUrl, - sourceName: source.name + sourceName: source.name, + requestParams: rssParser.request ) } else { data = await handleUrls( website: website, replacedSearchUrl: replacedSearchUrl, fallbackUrls: source.fallbackUrls, - sourceName: source.name + sourceName: source.name, + requestParams: rssParser.request ) } @@ -210,8 +242,7 @@ class ScrapingViewModel: ObservableObject { } case .siteApi: if let jsonParser = source.jsonParser { - var replacedSearchUrl = jsonParser.searchUrl - .replacingOccurrences(of: "{query}", with: encodedQuery) + var replacedSearchUrl = substituteParams(jsonParser.searchUrl, with: params) // Handle anything API related including tokens, client IDs, and appending the API URL // The source API key is for APIs that require extra credentials or use a different URL @@ -247,7 +278,8 @@ class ScrapingViewModel: ObservableObject { website: passedUrl, replacedSearchUrl: replacedSearchUrl, fallbackUrls: source.fallbackUrls, - sourceName: source.name + sourceName: source.name, + requestParams: jsonParser.request ) if let data { @@ -262,16 +294,16 @@ class ScrapingViewModel: ObservableObject { } // Checks the base URL for any website data then iterates through the fallback URLs - func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String) async -> Data? { + private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? { let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "") - if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) { + if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) { return data } if let fallbackUrls { for fallbackUrl in fallbackUrls { let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "") - if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) { + if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) { return data } } @@ -280,12 +312,12 @@ class ScrapingViewModel: ObservableObject { return nil } - public func handleApiCredential(_ credential: SourceApiCredential, - replacement: String, - searchUrl: String, - apiUrl: String?, - website: String, - sourceName: String) async -> String? + private func handleApiCredential(_ credential: SourceApiCredential, + replacement: String, + searchUrl: String, + apiUrl: String?, + website: String, + sourceName: String) async -> String? { // Is the credential expired var isExpired = false @@ -297,8 +329,7 @@ class ScrapingViewModel: ObservableObject { // Fetch a new credential if it's expired or doesn't exist yet if let value = credential.value, !isExpired { - return searchUrl - .replacingOccurrences(of: replacement, with: value) + return substituteParams(searchUrl, with: [replacement: value]) } else if credential.value == nil || isExpired, let credentialUrl = credential.urlString, @@ -322,9 +353,9 @@ class ScrapingViewModel: ObservableObject { return nil } - public func fetchApiCredential(urlString: String, - credential: SourceApiCredential, - sourceName: String) async -> String? + private func fetchApiCredential(urlString: String, + credential: SourceApiCredential, + sourceName: String) async -> String? { guard let url = URL(string: urlString) else { await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.") @@ -368,7 +399,7 @@ class ScrapingViewModel: ObservableObject { } // Fetches the data for a URL - public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? { + private func fetchWebsiteData(urlString: String, sourceName: String, requestParams: SourceRequest?) async -> Data? { guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!") @@ -387,7 +418,12 @@ class ScrapingViewModel: ObservableObject { } } - let request = URLRequest(url: url, timeoutInterval: timeout) + var request = URLRequest(url: url, timeoutInterval: timeout) + request.httpMethod = requestParams?.method + request.httpBody = requestParams?.body?.data(using: .utf8) + requestParams?.headers?.forEach { field, value in + request.addValue(value, forHTTPHeaderField: field) + } do { let (data, _) = try await URLSession.shared.data(for: request) @@ -410,7 +446,7 @@ class ScrapingViewModel: ObservableObject { } } - public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? { + private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? { guard let jsonParser = source.jsonParser else { return nil } @@ -485,10 +521,10 @@ class ScrapingViewModel: ObservableObject { } // TODO: Add regex parsing for API - public func parseJsonResult(_ result: JSON, - jsonParser: SourceJsonParser, - source: Source, - existingSearchResult: SearchResult? = nil) -> SearchResult? + private func parseJsonResult(_ result: JSON, + jsonParser: SourceJsonParser, + source: Source, + existingSearchResult: SearchResult? = nil) -> SearchResult? { // Enforce these parsers guard let titleParser = jsonParser.title else { @@ -579,7 +615,7 @@ class ScrapingViewModel: ObservableObject { } // RSS feed scraper - public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? { + private func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? { guard let rssParser = source.rssParser else { return nil } @@ -714,11 +750,11 @@ class ScrapingViewModel: ObservableObject { } // Complex query parsing for RSS scraping - func runRssComplexQuery(item: Element, - query: String, - attribute: String, - discriminator: String?, - regexString: String?) throws -> String? + private func runRssComplexQuery(item: Element, + query: String, + attribute: String, + discriminator: String?, + regexString: String?) throws -> String? { var parsedValue: String? @@ -747,7 +783,7 @@ class ScrapingViewModel: ObservableObject { } // HTML scraper - public func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? { + private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? { guard let htmlParser = source.htmlParser else { return nil } @@ -799,7 +835,7 @@ class ScrapingViewModel: ObservableObject { let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl guard - let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name), + let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request), let magnetHtml = String(data: data, encoding: .utf8) else { continue @@ -884,7 +920,7 @@ class ScrapingViewModel: ObservableObject { ) } - if let leecherQuery = seederLeecher.seeders { + if let leecherQuery = seederLeecher.leechers { leechers = try? runHtmlComplexQuery( row: row, query: leecherQuery, @@ -919,10 +955,10 @@ class ScrapingViewModel: ObservableObject { } // Complex query parsing for HTML scraping - func runHtmlComplexQuery(row: Element, - query: String, - attribute: String, - regexString: String?) throws -> String? + private func runHtmlComplexQuery(row: Element, + query: String, + attribute: String, + regexString: String?) throws -> String? { var parsedValue: String? @@ -944,7 +980,7 @@ class ScrapingViewModel: ObservableObject { } } - func runRegex(parsedValue: String, regexString: String) -> String? { + private func runRegex(parsedValue: String, regexString: String) -> String? { // TODO: Maybe dynamically parse flags let replacedRegexString = regexString .replacingOccurrences(of: "{query}", with: cleanedSearchText) @@ -967,7 +1003,7 @@ class ScrapingViewModel: ObservableObject { } } - func parseSizeString(sizeString: String) -> String? { + private func parseSizeString(sizeString: String) -> String? { // Test if the string can be a full integer guard let size = Int(sizeString) else { return nil @@ -989,7 +1025,7 @@ class ScrapingViewModel: ObservableObject { } } - func cleanApiCreds(api: SourceApi, sourceName: String) async { + private func cleanApiCreds(api: SourceApi, sourceName: String) async { let backgroundContext = PersistenceController.shared.backgroundContext let hasCredentials = api.clientId != nil || api.clientSecret != nil diff --git a/Ferrite/Views/CommonViews/HybridSecureField.swift b/Ferrite/Views/CommonViews/HybridSecureField.swift index df3fec0..981859f 100644 --- a/Ferrite/Views/CommonViews/HybridSecureField.swift +++ b/Ferrite/Views/CommonViews/HybridSecureField.swift @@ -14,22 +14,34 @@ struct HybridSecureField: View { } @Binding var text: String + var onCommit: () -> Void = {} + @State private var showPassword = false @FocusState private var focusedField: Field? + private var isFieldDisabled: Bool = false + + init(text: Binding, onCommit: (() -> Void)? = nil, showPassword: Bool = false) { + _text = text + if let onCommit { + self.onCommit = onCommit + } + self.showPassword = showPassword + } var body: some View { HStack { Group { if showPassword { - TextField("Password", text: $text) + TextField("Password", text: $text, onCommit: onCommit) .focused($focusedField, equals: .plain) } else { - SecureField("Password", text: $text) + SecureField("Password", text: $text, onCommit: onCommit) .focused($focusedField, equals: .secure) } } .autocorrectionDisabled(true) .autocapitalization(.none) + .disabledAppearance(isFieldDisabled) Button { showPassword.toggle() @@ -42,3 +54,9 @@ struct HybridSecureField: View { } } } + +extension HybridSecureField { + func fieldDisabled(_ isFieldDisabled: Bool) -> Self { + modifyViewProp { $0.isFieldDisabled = isFieldDisabled } + } +} diff --git a/Ferrite/Views/CommonViews/NavView.swift b/Ferrite/Views/CommonViews/NavView.swift index fe2c172..6c8297b 100644 --- a/Ferrite/Views/CommonViews/NavView.swift +++ b/Ferrite/Views/CommonViews/NavView.swift @@ -14,18 +14,16 @@ struct NavView: View { @ViewBuilder var content: Content var body: some View { - // Uncomment once NavigationStack issues are fixed - /* - if #available(iOS 16, *) { - NavigationStack { - content - } - } else { - */ - NavigationView { - content + // NavigationStack issues are fixed on iOS 17 + if #available(iOS 17, *) { + NavigationStack { + content + } + } else { + NavigationView { + content + } + .navigationViewStyle(.stack) } - .navigationViewStyle(.stack) - // } } } 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..c0b7d5e 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.enabledDebrids.isEmpty { let magnets = bookmarks.compactMap { if let magnetHash = $0.magnetHash { return Magnet(hash: magnetHash, link: $0.magnetLink) diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/CloudDownloadView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/CloudDownloadView.swift new file mode 100644 index 0000000..f68f27c --- /dev/null +++ b/Ferrite/Views/ComponentViews/Library/Cloud/CloudDownloadView.swift @@ -0,0 +1,60 @@ +// +// 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 + var historyEntry = HistoryEntryJson( + name: cloudDownload.fileName, + source: debridSource.id + ) + + debridManager.currentDebridTask = Task { + await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link) + + if !debridManager.downloadUrl.isEmpty { + historyEntry.url = debridManager.downloadUrl + PersistenceController.shared.createHistory(historyEntry, 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/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/CloudMagnetView.swift similarity index 50% rename from Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift rename to Ferrite/Views/ComponentViews/Library/Cloud/CloudMagnetView.swift index f871e34..870f63a 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/CloudMagnetView.swift @@ -1,82 +1,93 @@ // -// AllDebridCloudView.swift +// CloudMagnetView.swift // Ferrite // -// Created by Brian Dashore on 1/5/23. +// Created by Brian Dashore on 6/6/24. // import SwiftUI -struct AllDebridCloudView: View { - @EnvironmentObject var debridManager: DebridManager +struct CloudMagnetView: 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("Magnets") { - ForEach(debridManager.allDebridCloudMagnets.filter { - searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) - }, id: \.id) { magnet in + ForEach(debridSource.cloudMagnets.filter { + searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased()) + }, id: \.self) { cloudMagnet in Button { - if magnet.status == "Ready", !magnet.links.isEmpty { + if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty { navModel.resultFromCloud = true - navModel.selectedTitle = magnet.filename + navModel.selectedTitle = cloudMagnet.fileName var historyInfo = HistoryEntryJson( - name: magnet.filename, - source: DebridType.allDebrid.toString() + name: cloudMagnet.fileName, + source: debridSource.id ) Task { - if magnet.links.count == 1 { - if let lockedLink = magnet.links[safe: 0]?.link { - await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: lockedLink) + let magnet = Magnet(hash: cloudMagnet.hash, link: nil) + await debridManager.populateDebridIA([magnet]) + if debridManager.selectDebridResult(magnet: magnet) { + // Is this a batch? + + if cloudMagnet.links.count == 1 { + await debridManager.fetchDebridDownload(magnet: magnet) + + // Bump to batch + if debridManager.requiresUnrestrict { + navModel.selectedHistoryInfo = historyInfo + navModel.currentChoiceSheet = .batch + + return + } 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) { + } else { + navModel.selectedMagnet = magnet navModel.selectedHistoryInfo = historyInfo navModel.currentChoiceSheet = .batch } } } } - } label: { VStack(alignment: .leading, spacing: 10) { - Text(magnet.filename) + Text(cloudMagnet.fileName) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) HStack { - Text(magnet.status) + Text(cloudMagnet.status.capitalizingFirstLetter()) Spacer() - DebridLabelView(cloudLinks: magnet.links.map(\.link)) + DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links) } .font(.caption) } } .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.black) + .tint(.primary) } .onDelete { offsets in for index in offsets { - if let magnet = debridManager.allDebridCloudMagnets[safe: index] { + if let cloudMagnet = debridSource.cloudMagnets[safe: index] { Task { - await debridManager.deleteAdMagnet(magnetId: magnet.id) + await debridManager.deleteUserMagnet(cloudMagnet) } } } diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift deleted file mode 100644 index 380d959..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(.black) - } - .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 11059e9..0000000 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ /dev/null @@ -1,126 +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 - ) - } - .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 b0343ea..412a829 100644 --- a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -10,29 +10,23 @@ 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() - } + CloudDownloadView(debridSource: debridSource, searchText: $searchText) + CloudMagnetView(debridSource: debridSource, searchText: $searchText) } .listStyle(.plain) .task { await debridManager.fetchDebridCloud() } .refreshable { - await debridManager.fetchDebridCloud() + 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/SearchResult/SearchFilterHeaderView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift index 2602c68..9a96ab3 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchFilterHeaderView.swift @@ -53,7 +53,7 @@ struct SearchFilterHeaderView: View { SelectedDebridFilterView { FilterLabelView( - name: debridManager.selectedDebridType?.toString(), + name: debridManager.selectedDebridSource?.id, fallbackName: "Debrid" ) } diff --git a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift index efe438b..d523bee 100644 --- a/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift +++ b/Ferrite/Views/ComponentViews/SearchResult/SearchResultButtonView.swift @@ -28,21 +28,28 @@ struct SearchResultButtonView: View { navModel.selectedTitle = result.title ?? "" navModel.resultFromCloud = false + var historyEntry = HistoryEntryJson( + name: result.title, + source: result.source + ) + switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) { case .full: if debridManager.selectDebridResult(magnet: result.magnet) { debridManager.currentDebridTask = Task { await debridManager.fetchDebridDownload(magnet: result.magnet) + // Bump to batch + if debridManager.requiresUnrestrict { + navModel.selectedHistoryInfo = historyEntry + navModel.currentChoiceSheet = .batch + + return + } + if !debridManager.downloadUrl.isEmpty { - PersistenceController.shared.createHistory( - HistoryEntryJson( - name: result.title, - url: debridManager.downloadUrl, - source: result.source - ), - performSave: true - ) + historyEntry.url = debridManager.downloadUrl + PersistenceController.shared.createHistory(historyEntry, performSave: true) pluginManager.runDefaultAction( urlString: debridManager.downloadUrl, @@ -57,21 +64,12 @@ struct SearchResultButtonView: View { } case .partial: if debridManager.selectDebridResult(magnet: result.magnet) { - navModel.selectedHistoryInfo = HistoryEntryJson( - name: result.title, - source: result.source - ) + navModel.selectedHistoryInfo = historyEntry navModel.currentChoiceSheet = .batch } case .none: - PersistenceController.shared.createHistory( - HistoryEntryJson( - name: result.title, - url: result.magnet.link, - source: result.source - ), - performSave: true - ) + historyEntry.url = result.magnet.link + PersistenceController.shared.createHistory(historyEntry, performSave: true) pluginManager.runDefaultAction( urlString: result.magnet.link, @@ -127,14 +125,15 @@ struct SearchResultButtonView: View { .alert("Caching file", isPresented: $debridManager.showDeleteAlert) { Button("Yes", role: .destructive) { Task { - await debridManager.deleteRdTorrent() + try? await debridManager.selectedDebridSource?.deleteUserMagnet(cloudMagnetId: nil) } } Button("Cancel", role: .cancel) {} } message: { Text( - "RealDebrid is currently caching this file. Would you like to delete it? \n\n" + - "Progress can be checked on the RealDebrid website." + "\(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") is currently caching this file. " + + "Would you like to delete it? \n\n" + + "Progress can be checked on the \(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") website." ) } .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in 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 0872a2a..479acb9 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift @@ -10,15 +10,19 @@ import SwiftUI struct SettingsDebridInfoView: View { @EnvironmentObject var debridManager: DebridManager - let debridType: DebridType + @Store var debridSource: DebridSource + + @State private var apiKeyTempText: String = "" var body: some 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.description ?? + "\(debridSource.id) is a debrid service that is used for 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")!) } } @@ -28,24 +32,56 @@ struct SettingsDebridInfoView: View { ) { Button { Task { - if debridManager.enabledDebrids.contains(debridType) { - await debridManager.logoutDebrid(debridType: debridType) - } else if !debridManager.getAuthProcessingBool(debridType: debridType) { - await debridManager.authenticateDebrid(debridType: debridType) + if debridSource.isLoggedIn { + await debridManager.logout(debridSource) + } else if !debridSource.authProcessing { + await debridManager.authenticateDebrid(debridSource, apiKey: nil) } + + apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? "" } } label: { Text( - debridManager.enabledDebrids.contains(debridType) + debridSource.isLoggedIn ? "Logout" - : (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login") + : (debridSource.authProcessing ? "Processing" : "Login") ) - .foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue) + .foregroundColor(debridSource.isLoggedIn ? .red : .blue) + } + .alert("Invalid web login", isPresented: $debridManager.showWebLoginAlert) { + Button("OK", role: .cancel) {} + } message: { + Text( + "\(debridSource.id) does not have a login portal. Please use an API key to login." + ) + } + } + + Section( + header: InlineHeader("API key"), + footer: Text("Add a permanent API key here. Only use this if web authentication does not work!") + ) { + HybridSecureField( + text: $apiKeyTempText, + onCommit: { + Task { + if !apiKeyTempText.isEmpty { + await debridManager.authenticateDebrid(debridSource, apiKey: apiKeyTempText) + apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? "" + } + } + } + ) + .fieldDisabled(debridSource.isLoggedIn) + } + .onAppear { + Task { + 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..03fab5b 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 @@ -90,6 +96,11 @@ struct LibraryView: View { .esAutocapitalization(autocorrectSearch ? .sentences : .none) .environment(\.editMode, $editMode) } + .alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(debridManager.notImplementedMessage) + } .onChange(of: navModel.libraryPickerSelection) { _ in editMode = .inactive } diff --git a/Ferrite/Views/RepresentableViews/ExpandedSearchable.swift b/Ferrite/Views/RepresentableViews/ExpandedSearchable.swift index e361f07..58b1748 100644 --- a/Ferrite/Views/RepresentableViews/ExpandedSearchable.swift +++ b/Ferrite/Views/RepresentableViews/ExpandedSearchable.swift @@ -7,7 +7,7 @@ import SwiftUI -public extension View { +extension View { // A dismissAction must be added in the parent view struct due to lifecycle issues func expandedSearchable(text: Binding, isSearching: Binding? = nil, diff --git a/Ferrite/Views/RepresentableViews/WebView.swift b/Ferrite/Views/RepresentableViews/WebView.swift index e903622..e9b87a8 100644 --- a/Ferrite/Views/RepresentableViews/WebView.swift +++ b/Ferrite/Views/RepresentableViews/WebView.swift @@ -9,19 +9,23 @@ import SwiftUI import WebKit struct WebView: UIViewRepresentable { + @AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth: Bool = true var url: URL func makeUIView(context: Context) -> WKWebView { - // Make the WebView ephemeral + // Make the WebView ephemeral depending on the ephemeral auth setting let config = WKWebViewConfiguration() - config.websiteDataStore = WKWebsiteDataStore.nonPersistent() + + config.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default() let webView = WKWebView(frame: .zero, configuration: config) let _ = webView.load(URLRequest(url: url)) return webView } - func updateUIView(_ uiView: WKWebView, context: Context) {} + func updateUIView(_ webView: WKWebView, context: Context) { + webView.configuration.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default() + } } struct WebView_Previews: PreviewProvider { diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 8e08cfd..692ce43 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -8,6 +8,7 @@ import BetterSafariView import Introspect import SwiftUI +import WebKit struct SettingsView: View { @EnvironmentObject var debridManager: DebridManager @@ -24,6 +25,7 @@ struct SettingsView: View { @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true @AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false + @AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth = true @AppStorage("Behavior.DisableRequestTimeout") var disableRequestTimeout = false @AppStorage("Behavior.RequestTimeoutSecs") var requestTimeoutSecs: Double = 15 @@ -44,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) } } @@ -73,7 +75,10 @@ struct SettingsView: View { Section( header: InlineHeader("Behavior"), - footer: Text("Only disable search timeout if results are slow to fetch") + footer: VStack(alignment: .leading, spacing: 8) { + Text("Temporarily disable ephemeral auth if you cannot log into a service") + Text("Only disable search timeout if results are slow to fetch") + } ) { Toggle(isOn: $autocorrectSearch) { Text("Autocorrect search") @@ -83,6 +88,21 @@ struct SettingsView: View { Text("Random searchbar text") } + Toggle(isOn: $useEphemeralAuth) { + Text("Ephemeral authentication") + } + .onChange(of: useEphemeralAuth) { changed in + // Does not work with ASWebAuthenticationSession + if changed { + Task { + let dataRecords = await WKWebsiteDataStore.default().dataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) + + await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: dataRecords) + } + } + } + + // TODO: Change this to enable search timeout instead Toggle(isOn: $disableRequestTimeout) { Text("Disable search timeout") } @@ -108,7 +128,7 @@ struct SettingsView: View { } Section(header: InlineHeader("Default actions")) { - if debridManager.enabledDebrids.count > 0 { + if !debridManager.enabledDebrids.isEmpty { NavigationLink { DefaultActionPickerView( actionRequirement: .debrid, @@ -207,10 +227,10 @@ struct SettingsView: View { callbackURLScheme: "ferrite" ) { callbackURL, error in Task { - await debridManager.handleCallback(url: callbackURL, error: error) + await debridManager.handleAuthCallback(url: callbackURL, error: error) } } - .prefersEphemeralWebBrowserSession(true) + .prefersEphemeralWebBrowserSession(useEphemeralAuth) } .navigationTitle("Settings") .toolbar { diff --git a/Ferrite/Views/SheetViews/BatchChoiceView.swift b/Ferrite/Views/SheetViews/BatchChoiceView.swift index 771395f..a18d355 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) @@ -75,6 +50,7 @@ struct BatchChoiceView: View { try? await Task.sleep(seconds: 1) debridManager.clearSelectedDebridItems() + debridManager.requiresUnrestrict = false } } } @@ -85,7 +61,11 @@ 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) + if debridManager.requiresUnrestrict { + await debridManager.unrestrictDownload() + } else { + await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet) + } if !debridManager.downloadUrl.isEmpty { try? await Task.sleep(seconds: 1) diff --git a/Misc/Media/Demo/Dark/Bookmarks.png b/Misc/Media/Demo/Dark/Bookmarks.png new file mode 100644 index 0000000..511d323 Binary files /dev/null and b/Misc/Media/Demo/Dark/Bookmarks.png differ diff --git a/Misc/Media/Demo/Dark/Cloud.png b/Misc/Media/Demo/Dark/Cloud.png new file mode 100644 index 0000000..be257f2 Binary files /dev/null and b/Misc/Media/Demo/Dark/Cloud.png differ diff --git a/Misc/Media/Demo/Dark/History.png b/Misc/Media/Demo/Dark/History.png new file mode 100644 index 0000000..7366970 Binary files /dev/null and b/Misc/Media/Demo/Dark/History.png differ diff --git a/Misc/Media/Demo/Dark/Plugins.png b/Misc/Media/Demo/Dark/Plugins.png new file mode 100644 index 0000000..1e3ef9f Binary files /dev/null and b/Misc/Media/Demo/Dark/Plugins.png differ diff --git a/Misc/Media/Demo/Dark/Search.png b/Misc/Media/Demo/Dark/Search.png new file mode 100644 index 0000000..3a25c14 Binary files /dev/null and b/Misc/Media/Demo/Dark/Search.png differ diff --git a/Misc/Media/Demo/Light/Bookmarks.png b/Misc/Media/Demo/Light/Bookmarks.png new file mode 100644 index 0000000..2d35db5 Binary files /dev/null and b/Misc/Media/Demo/Light/Bookmarks.png differ diff --git a/Misc/Media/Demo/Light/Cloud.png b/Misc/Media/Demo/Light/Cloud.png new file mode 100644 index 0000000..ab078aa Binary files /dev/null and b/Misc/Media/Demo/Light/Cloud.png differ diff --git a/Misc/Media/Demo/Light/History.png b/Misc/Media/Demo/Light/History.png new file mode 100644 index 0000000..e77177c Binary files /dev/null and b/Misc/Media/Demo/Light/History.png differ diff --git a/Misc/Media/Demo/Light/Plugins.png b/Misc/Media/Demo/Light/Plugins.png new file mode 100644 index 0000000..b660d4a Binary files /dev/null and b/Misc/Media/Demo/Light/Plugins.png differ diff --git a/Misc/Media/Demo/Light/Search.png b/Misc/Media/Demo/Light/Search.png new file mode 100644 index 0000000..208b13b Binary files /dev/null and b/Misc/Media/Demo/Light/Search.png differ diff --git a/Misc/Referrals/TorBox.md b/Misc/Referrals/TorBox.md new file mode 100644 index 0000000..c48f48c --- /dev/null +++ b/Misc/Referrals/TorBox.md @@ -0,0 +1,5 @@ +Enter the following code on [TorBox's subscription page](https://torbox.app/subscription) + +bb2d4f54-61bf-4d64-af08-8db0a900485a + +Thanks for the referral! diff --git a/README.md b/README.md index ca4bb28..3b98270 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,61 @@ # Ferrite +

+ Swift 5.2 + Platform: iOS | iPadOS + + License: GPL v3 + + + GitHub all releases + +

+ +

+ + Nightly Build Status + + + Discord Server + +

+ +

+ + Refer on RealDebrid + + + Refer on TorBox + + + Support on Ko-Fi + +

+ A media search engine for iOS with a plugin API to extend its functionality. +## Screenshots + +### Dark Mode + +| Search | Bookmarks | History | +| ------------- | -------- | -------- | +| ![1](Misc/Media/Demo/Dark/Search.png) | ![2](Misc/Media/Demo/Dark/Bookmarks.png) | ![3](Misc/Media/Demo/Dark/History.png) | + +| Debrid Cloud | Plugins | +| ----------- | -------------------- | +| ![4](Misc/Media/Demo/Dark/Cloud.png) | ![5](Misc/Media/Demo/Dark/Plugins.png) | + +### Light Mode + +| Search | Bookmarks | History | +| ------------- | -------- | -------- | +| ![1](Misc/Media/Demo/Light/Search.png) | ![2](Misc/Media/Demo/Light/Bookmarks.png) | ![3](Misc/Media/Demo/Light/History.png) | + +| Debrid Cloud | Plugins | +| ----------- | -------------------- | +| ![4](Misc/Media/Demo/Light/Cloud.png) | ![5](Misc/Media/Demo/Light/Plugins.png) | + ## Disclaimer This project is developed with a hobbyist/educational purpose and I am not responsible for what happens when you install Ferrite. @@ -16,31 +70,62 @@ However, the main problem is that these websites tend to suck in terms of UI or I also wanted to support the use of debrid services since there aren't any (free) options on iOS that have support for this service. +## Features + +- [x] Ad-free +- [x] Clean UI with native performance +- [x] Powerful search with an intuitive filter system +- [x] Modular plugin system +- [x] Integrates with many debrid providers +- [x] Flexible parser system written in native Swift +- [x] Local library with bookmarks and history +- [x] Manage your debrid cloud +- [x] Does not pollute your debrid cloud +- [x] Kodi integration + +If there's a feature that's not listed here, open an issue or ask in the support Discord. + ## What iOS versions are supported? +To decide what minimum version of iOS is supported, Ferrite follows an "n - 2" patten. For example, if iOS 18 is the latest version, the minimum supported iOS version is 16 (18-2 = 16). + +To make this easier, the minimum required iOS version and Ferrite versions are listed below: + +- v0.8 and up: iOS 16 and up + - v0.7 and up: iOS 15 and up - v0.6.x and lower: iOS 14 and up -## Planned features +## Supported debrid services -More of these can be found in [issues](https://github.com/bdashore3/Ferrite/issues), but here is a small snippet: +Ferrite primarily uses Debrid services for instant streaming. A list of supported services are provided below: -- More involved search filtering +- RealDebrid +- AllDebrid +- Premiumize +- TorBox +- OffCloud -- Companion apps for playback on other devices +Want another debrid service? Make a request in issues or the support Discord. ## Downloads -Ferrite will only exist as an ipa. There will never be any plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts. +At this time, Ferrite will only exist as an ipa. There are no plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts. ## Plugins/Sources -Sources are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease. +Plugins are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease. + +There are two types of plugins: +- Source: A plugin that looks something up in an indexer +- Action: A plugin that "does" something, such as opening a result in a separate application + +To start off, a plugin list is located here (you can copy and paste this in the app) -> [https://raw.githubusercontent.com/Ferrite-iOS/example-sources/default/public-domain-plugins.yml](https://raw.githubusercontent.com/Ferrite-iOS/example-sources/default/public-domain-plugins.yml) ## Building from source -Xcode 14 must be used. +Use the latest stable version of Xcode. There are currently two branches in the repository: @@ -67,7 +152,7 @@ If you have issues with the app: - Describe the issue in detail - If you have a feature request, please indicate it as so. Planned features are in a different section of the README, so be sure to read those before submitting. -- Please join [the discord](https://discord.gg/sYQxnuD7Fj) for more info +- Please join [the discord](https://discord.gg/sYQxnuD7Fj) for more info and support ## Developers and Permissions