v0.6.0 #19

Merged
kingbri1 merged 19 commits from next into default 2023-01-07 23:58:29 +00:00
61 changed files with 3051 additions and 905 deletions

View file

@ -14,18 +14,19 @@ jobs:
with:
xcode-version: latest
- name: Get commit SHA
id: commitinfo
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- 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:
IS_NIGHTLY: YES
- name: Package ipa
run: |
mkdir Payload
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa Payload
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
- name: Upload artifacts
uses: actions/upload-artifact@v2
with:
name: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
if-no-files-found: error

View file

@ -8,10 +8,14 @@
/* Begin PBXBuildFile section */
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0167DB29293FA900B65783 /* RealDebridModels.swift */; };
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */; };
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; };
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; };
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; };
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; };
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; };
@ -20,6 +24,11 @@
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; };
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; };
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; };
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */; };
0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; };
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.swift */; };
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; };
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; };
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; };
@ -28,11 +37,14 @@
0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */; };
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; };
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultRDView.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 */; };
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; };
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; };
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; };
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; };
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
@ -89,6 +101,7 @@
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 */; };
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 0CB6516728C5A5EC00DCA721 /* Introspect */; };
@ -99,16 +112,22 @@
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; };
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; };
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; };
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; };
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0C0167DB29293FA900B65783 /* RealDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = "<group>"; };
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = "<group>"; };
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = "<group>"; };
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = "<group>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; };
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@ -117,6 +136,11 @@
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = "<group>"; };
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; };
0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = "<group>"; };
0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridChoiceView.swift; sourceTree = "<group>"; };
0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = "<group>"; };
0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
0C44E2AC28D51C63007711AE /* BackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManager.swift; sourceTree = "<group>"; };
0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = "<group>"; };
@ -124,9 +148,12 @@
0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = "<group>"; };
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = "<group>"; };
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = "<group>"; };
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = "<group>"; };
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = "<group>"; };
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = "<group>"; };
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
@ -181,6 +208,7 @@
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CAF9318296399190050812A /* PremiumizeCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeCloudView.swift; sourceTree = "<group>"; };
0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = "<group>"; };
0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = "<group>"; };
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = "<group>"; };
@ -191,6 +219,7 @@
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; };
0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; };
0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -207,12 +236,43 @@
0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */,
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0C0755C22934241F00ECA142 /* SheetViews */ = {
isa = PBXGroup;
children = (
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
);
path = SheetViews;
sourceTree = "<group>";
};
0C0755C32934244500ECA142 /* ComponentViews */ = {
isa = PBXGroup;
children = (
0C0755C42934245800ECA142 /* Debrid */,
0CA3B23528C265FD00616D3A /* Library */,
0C44E2AB28D4E126007711AE /* SearchResult */,
0CA0545C288F7CB200850554 /* Settings */,
0C794B65289DAC9F00DD1CC8 /* Source */,
);
path = ComponentViews;
sourceTree = "<group>";
};
0C0755C42934245800ECA142 /* Debrid */ = {
isa = PBXGroup;
children = (
0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */,
0C0755C5293424A200ECA142 /* DebridLabelView.swift */,
);
path = Debrid;
sourceTree = "<group>";
};
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
@ -241,8 +301,11 @@
0C0D50E3288DFE6E0035ECC8 /* Models */ = {
isa = PBXGroup;
children = (
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */,
@ -251,6 +314,16 @@
path = Models;
sourceTree = "<group>";
};
0C2886D52960C4F800D6FC16 /* Cloud */ = {
isa = PBXGroup;
children = (
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */,
0CAF9318296399190050812A /* PremiumizeCloudView.swift */,
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */,
);
path = Cloud;
sourceTree = "<group>";
};
0C44E2A628D4DDC6007711AE /* Classes */ = {
isa = PBXGroup;
children = (
@ -281,25 +354,25 @@
path = Buttons;
sourceTree = "<group>";
};
0C44E2AB28D4E126007711AE /* SearchResultViews */ = {
0C44E2AB28D4E126007711AE /* SearchResult */ = {
isa = PBXGroup;
children = (
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */,
);
path = SearchResultViews;
path = SearchResult;
sourceTree = "<group>";
};
0C794B65289DAC9F00DD1CC8 /* SourceViews */ = {
0C794B65289DAC9F00DD1CC8 /* Source */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
);
path = SourceViews;
path = Source;
sourceTree = "<group>";
};
0CA0545C288F7CB200850554 /* SettingsViews */ = {
0CA0545C288F7CB200850554 /* Settings */ = {
isa = PBXGroup;
children = (
0C44E2AE28D52E8A007711AE /* BackupsView.swift */,
@ -308,7 +381,7 @@
0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */,
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
);
path = SettingsViews;
path = Settings;
sourceTree = "<group>";
};
0CA148BA288903F000DE2211 /* Ferrite */ = {
@ -357,6 +430,8 @@
0CA148C8288903F000DE2211 /* Extensions */ = {
isa = PBXGroup;
children = (
0CD72E16293D9928001A7EA4 /* Array.swift */,
0C445C61293F9A0B0060744D /* Bundle.swift */,
0CA148C9288903F000DE2211 /* Collection.swift */,
0CA148CA288903F000DE2211 /* Data.swift */,
0CA429F728C5098D000D0610 /* DateFormatter.swift */,
@ -365,6 +440,7 @@
0CA148CB288903F000DE2211 /* Task.swift */,
0C7D11FD28AA03FE00ED92DB /* View.swift */,
0C7ED14228D65518009E29AD /* FileManager.swift */,
0C42B5972932F6DD008057A0 /* Set.swift */,
0C7C128528DAA3CD00381CD1 /* URL.swift */,
);
path = Extensions;
@ -373,12 +449,10 @@
0CA148EE2889061200DE2211 /* Views */ = {
isa = PBXGroup;
children = (
0CA3B23528C265FD00616D3A /* LibraryViews */,
0C794B65289DAC9F00DD1CC8 /* SourceViews */,
0C0755C32934244500ECA142 /* ComponentViews */,
0CA148F02889062700DE2211 /* RepresentableViews */,
0CA148C0288903F000DE2211 /* CommonViews */,
0C44E2AB28D4E126007711AE /* SearchResultViews */,
0CA0545C288F7CB200850554 /* SettingsViews */,
0C0755C22934241F00ECA142 /* SheetViews */,
0CA148D1288903F000DE2211 /* MainView.swift */,
0CA148D4288903F000DE2211 /* ContentView.swift */,
0CA148D3288903F000DE2211 /* SearchResultsView.swift */,
@ -387,8 +461,6 @@
0CA148BB288903F000DE2211 /* SettingsView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */,
0CA148BC288903F000DE2211 /* LoginWebView.swift */,
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -417,21 +489,25 @@
0CA148F12889066000DE2211 /* API */ = {
isa = PBXGroup;
children = (
0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */,
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */,
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
);
path = API;
sourceTree = "<group>";
};
0CA3B23528C265FD00616D3A /* LibraryViews */ = {
0CA3B23528C265FD00616D3A /* Library */ = {
isa = PBXGroup;
children = (
0C2886D52960C4F800D6FC16 /* Cloud */,
0CA3B23828C2660D00616D3A /* BookmarksView.swift */,
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */,
0CA3B23628C2660700616D3A /* HistoryView.swift */,
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */,
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */,
);
path = LibraryViews;
path = Library;
sourceTree = "<group>";
};
0CAF1C5F286F5C0D00296F86 = {
@ -470,6 +546,7 @@
0CAF1C64286F5C0E00296F86 /* Sources */,
0CAF1C65286F5C0E00296F86 /* Frameworks */,
0CAF1C66286F5C0E00296F86 /* Resources */,
0C445C60293F99360060744D /* Insert environment vars into Plist */,
);
buildRules = (
);
@ -484,6 +561,7 @@
0C7376EF28A97D1400D60918 /* SwiftUIX */,
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
0CB6516728C5A5EC00DCA721 /* Introspect */,
0CDDDE042935235E006810B1 /* BetterSafariView */,
);
productName = Torrenter;
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
@ -521,6 +599,7 @@
0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */,
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
);
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
projectDirPath = "";
@ -544,6 +623,29 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
0C445C60293F99360060744D /* Insert environment vars into Plist */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
$BUILT_PRODUCTS_DIR/$INFOPLIST_PATH,
);
name = "Insert environment vars into Plist";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n# From https://stackoverflow.com/questions/26514866/how-can-i-include-the-git-commit-hash-in-xcode\n\nINFO_PLIST=\"${TARGET_BUILD_DIR}\"/\"${INFOPLIST_PATH}\"\n\n# MARK: Adds commit hash to plist\ncommitValue=$(/usr/libexec/PlistBuddy -c 'print :GitCommitHash' \"${INFO_PLIST}\" 2>/dev/null)\n\n# Check if value is empty\nif [ -z \"$commitValue\" ] \nthen\n /usr/libexec/PlistBuddy -c \"Add :GitCommitHash string\" \"${INFO_PLIST}\"\nfi\n\n/usr/libexec/PlistBuddy -c \"Set :GitCommitHash `git rev-parse --short HEAD`\" \"${INFO_PLIST}\"\n\n# MARK: Adds the git build type to plist (examples: nightly, stable)\nbuildTypeValue=$(/usr/libexec/PlistBuddy -c 'print :IsNightly' \"${INFO_PLIST}\" 2>/dev/null)\n\n# Check if value is empty\nif [ -z \"$buildTypeValue\" ] \nthen\n /usr/libexec/PlistBuddy -c \"Add :IsNightly bool\" \"${INFO_PLIST}\"\nfi\n\n/usr/libexec/PlistBuddy -c \"Set :IsNightly ${IS_NIGHTLY}\" \"${INFO_PLIST}\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0CAF1C64286F5C0E00296F86 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -562,11 +664,14 @@
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */,
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */,
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
@ -577,8 +682,10 @@
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */,
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
@ -587,7 +694,10 @@
0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */,
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */,
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */,
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */,
@ -596,7 +706,10 @@
0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */,
0CA148E3288903F000DE2211 /* Task.swift in Sources */,
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */,
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 */,
0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */,
@ -606,7 +719,8 @@
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */,
0CA148E2288903F000DE2211 /* Data.swift in Sources */,
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */,
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */,
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */,
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
@ -618,6 +732,7 @@
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
@ -630,6 +745,7 @@
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */,
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */,
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */,
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */,
0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */,
@ -759,7 +875,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
@ -778,7 +894,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.1;
MARKETING_VERSION = 0.6.0;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -794,7 +910,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
@ -813,7 +929,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.1;
MARKETING_VERSION = 0.6.0;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -903,6 +1019,14 @@
kind = branch;
};
};
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/stleamist/BetterSafariView";
requirement = {
branch = main;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -941,6 +1065,11 @@
package = 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = Introspect;
};
0CDDDE042935235E006810B1 /* BetterSafariView */ = {
isa = XCSwiftPackageProductDependency;
package = 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */;
productName = BetterSafariView;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View file

@ -0,0 +1,227 @@
//
// AllDebridWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 11/25/22.
//
import Foundation
import KeychainSwift
// TODO: Fix errors
public class AllDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
let baseApiUrl = "https://api.alldebrid.com/v4"
let appName = "Ferrite"
var authTask: Task<Void, Error>?
// Fetches information for PIN auth
public func getPinInfo() async throws -> PinResponse {
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<PinResponse>.self, from: data).data
return rawResponse
} catch {
print("Couldn't get pin information!")
throw ADError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches API keys
public func getApiKey(checkID: String, pin: String) async throws {
let queryItems = [
URLQueryItem(name: "agent", value: appName),
URLQueryItem(name: "check", value: checkID),
URLQueryItem(name: "pin", value: pin)
]
let request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
// Timer to poll AD API for key
authTask = Task {
var count = 0
while count < 12 {
if Task.isCancelled {
throw ADError.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(ADResponse<ApiKeyResponse>.self, from: data).data
// If there's an API key from the response, end the task successfully
if let apiKeyResponse = rawResponse {
keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
throw ADError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
if case let .failure(error) = await authTask?.result {
throw error
}
}
// Clears tokens. No endpoint to deregister a device
public func deleteTokens() {
keychain.delete("AllDebrid.ApiKey")
}
// 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
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
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")
}
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.")
} else {
throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Builds a URL for further requests
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: urlString) else {
throw ADError.InvalidUrl
}
components.queryItems = [
URLQueryItem(name: "agent", value: appName)
] + queryItems
if let url = components.url {
return url
} else {
throw ADError.InvalidUrl
}
}
// Adds a magnet link to the user's AD account
public func addMagnet(magnet: Magnet) async throws -> Int {
guard let magnetLink = magnet.link else {
throw ADError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [
URLQueryItem(name: "magnets[]", value: magnetLink)
]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
if let magnet = rawResponse.magnets[safe: 0] {
return magnet.id
} else {
throw ADError.InvalidResponse
}
}
public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String {
let queryItems = [
URLQueryItem(name: "id", value: String(magnetId))
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.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
} else {
throw ADError.EmptyTorrents
}
}
public func userMagnets() async throws -> [MagnetStatusData] {
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
if rawResponse.magnets.isEmpty {
throw ADError.EmptyData
} else {
return rawResponse.magnets
}
}
public func deleteMagnet(magnetId: Int) async throws {
let queryItems = [
URLQueryItem(name: "id", value: String(magnetId))
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
public func unlockLink(lockedLink: String) async throws -> String {
let queryItems = [
URLQueryItem(name: "link", value: lockedLink)
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.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<InstantAvailabilityResponse>.self, from: data).data
let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil }
let availableHashes = filteredMagnets.map { magnetResp in
// Force unwrap is OK here since the filter caught any nil values
let files = magnetResp.files!.enumerated().map { index, magnetFile in
IAFile(id: index, fileName: magnetFile.name)
}
return IA(
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
}
return availableHashes
}
}

View file

@ -8,21 +8,21 @@
import Foundation
public class Github {
public func fetchLatestRelease() async throws -> GithubRelease? {
public func fetchLatestRelease() async throws -> Release? {
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases/latest")!
let (data, _) = try await URLSession.shared.data(from: url)
let rawResponse = try JSONDecoder().decode(GithubRelease.self, from: data)
let rawResponse = try JSONDecoder().decode(Release.self, from: data)
return rawResponse
}
public func fetchReleases() async throws -> [GithubRelease]? {
public func fetchReleases() async throws -> [Release]? {
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases")!
let (data, _) = try await URLSession.shared.data(from: url)
let rawResponse = try JSONDecoder().decode([GithubRelease].self, from: data)
let rawResponse = try JSONDecoder().decode([Release].self, from: data)
return rawResponse
}
}

View file

@ -0,0 +1,242 @@
//
// PremiumizeWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 11/28/22.
//
import Foundation
import KeychainSwift
public class Premiumize {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
let baseAuthUrl = "https://www.premiumize.me/authorize"
let baseApiUrl = "https://www.premiumize.me/api"
let clientId = "791565696"
public func buildAuthUrl() throws -> URL {
var urlComponents = URLComponents(string: baseAuthUrl)!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "response_type", value: "token"),
URLQueryItem(name: "state", value: UUID().uuidString)
]
if let url = urlComponents.url {
return url
} else {
throw PMError.InvalidUrl
}
}
public func handleAuthCallback(url: URL) throws {
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let callbackFragment = callbackComponents?.fragment else {
throw PMError.InvalidResponse
}
var fragmentComponents = URLComponents()
fragmentComponents.query = callbackFragment
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
throw PMError.InvalidToken
}
keychain.set(accessToken, forKey: "Premiumize.AccessToken")
}
// Clears tokens. No endpoint to deregister a device
public func deleteTokens() {
keychain.delete("Premiumize.AccessToken")
}
// 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
}
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")
}
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.")
} else {
throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// 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] {
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
for chunk in magnets.chunked(into: 100) {
group.addTask {
try await self.checkCache(magnets: chunk)
}
}
var chunkedMagnets: [Magnet] = []
for try await magnetArray in group {
chunkedMagnets += magnetArray
}
return chunkedMagnets
}
return availableMagnets
}
// Parent function for initial checking of the cache
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
}
var request = URLRequest(url: url)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
if rawResponse.response.isEmpty {
throw PMError.EmptyData
} else {
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
if rawResponse.response[safe: index] == true {
return magnet
} else {
return nil
}
}
return availableMagnets
}
}
// 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)
}
}
var chunkedIA: [Premiumize.IA] = []
for try await ia in group {
chunkedIA.append(ia)
}
return chunkedIA
}
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
)
} else {
throw PMError.EmptyData
}
}
func createTransfer(magnet: Magnet) async throws {
guard let magnetLink = magnet.link else {
throw PMError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnetLink)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
try await performRequest(request: &request, requestName: #function)
}
func userItems() async throws -> [UserItem] {
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
}
return rawResponse.files
}
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
}
var request = URLRequest(url: url)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ItemDetailsResponse.self, from: data)
return rawResponse
}
func deleteItem(itemID: 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)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
try await performRequest(request: &request, requestName: #function)
}
}

View file

@ -8,17 +8,6 @@
import Foundation
import KeychainSwift
public enum RealDebridError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
public class RealDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
@ -38,7 +27,7 @@ public class RealDebrid {
]
guard let url = urlComponents.url else {
throw RealDebridError.InvalidUrl
throw RDError.InvalidUrl
}
let request = URLRequest(url: url)
@ -49,7 +38,7 @@ public class RealDebrid {
return rawResponse
} catch {
print("Couldn't get the new client creds!")
throw RealDebridError.AuthQuery(description: error.localizedDescription)
throw RDError.AuthQuery(description: error.localizedDescription)
}
}
@ -62,7 +51,7 @@ public class RealDebrid {
]
guard let url = urlComponents.url else {
throw RealDebridError.InvalidUrl
throw RDError.InvalidUrl
}
let request = URLRequest(url: url)
@ -71,9 +60,9 @@ public class RealDebrid {
authTask = Task {
var count = 0
while count < 20 {
while count < 12 {
if Task.isCancelled {
throw RealDebridError.AuthQuery(description: "Token request cancelled.")
throw RDError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
@ -95,7 +84,7 @@ public class RealDebrid {
}
}
throw RealDebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
if case let .failure(error) = await authTask?.result {
@ -106,11 +95,11 @@ public class RealDebrid {
// Fetch all tokens for the user and store in keychain
public func getTokens(deviceCode: String) async throws {
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
throw RealDebridError.EmptyData
throw RDError.EmptyData
}
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
throw RealDebridError.EmptyData
throw RDError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
@ -172,9 +161,9 @@ public class RealDebrid {
}
// Wrapper request function which matches the responses and returns data
@discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await fetchToken() else {
throw RealDebridError.InvalidToken
throw RDError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -182,24 +171,24 @@ public class RealDebrid {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw RealDebridError.FailedRequest(description: "No HTTP response given")
throw RDError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
try await deleteTokens()
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
} else {
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Checks if the magnet is streamable on RD
// Currently does not work for batch links
public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] {
var availableHashes: [RealDebridIA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!)
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: "/"))")!)
let data = try await performRequest(request: &request, requestName: #function)
@ -219,17 +208,17 @@ public class RealDebrid {
if data.rd.count > 1 || data.rd[0].count > 1 {
// Batch array
let batches = data.rd.map { fileDict in
let batchFiles: [RealDebridIABatchFile] = fileDict.map { key, value in
let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
RealDebridIABatchFile(id: Int(key)!, fileName: value.filename)
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
return RealDebridIABatch(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: [RealDebridIAFile] = []
var files: [RealDebrid.IAFile] = []
for index in batches.indices {
let batchFiles = batches[index].files
@ -239,7 +228,7 @@ public class RealDebrid {
if !files.contains(where: { $0.name == batchFile.fileName }) {
files.append(
RealDebridIAFile(
RealDebrid.IAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
@ -251,8 +240,8 @@ public class RealDebrid {
// TTL: 5 minutes
availableHashes.append(
RealDebridIA(
hash: hash,
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches
@ -260,8 +249,8 @@ public class RealDebrid {
)
} else {
availableHashes.append(
RealDebridIA(
hash: hash,
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300
)
)
@ -272,7 +261,11 @@ public class RealDebrid {
}
// Adds a magnet link to the user's RD account
public func addMagnet(magnetLink: String) async throws -> String {
public func addMagnet(magnet: Magnet) async throws -> String {
guard let magnetLink = magnet.link else {
throw RDError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -319,9 +312,9 @@ public class RealDebrid {
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
return torrentLink
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
throw RealDebridError.EmptyTorrents
throw RDError.EmptyTorrents
} else {
throw RealDebridError.EmptyData
throw RDError.EmptyData
}
}
@ -369,4 +362,11 @@ public class RealDebrid {
return rawResponse
}
public func deleteDownload(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
}

View file

@ -20,11 +20,18 @@ public class Application {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
}
// Debug = development, Nightly = actions, Release = stable
var buildType: String {
#if DEBUG
return "Debug"
#else
return "Release"
if Bundle.main.isNightly {
return "Nightly"
} else {
return "Release"
}
#endif
}
let osVersion: OperatingSystemVersion = ProcessInfo().operatingSystemVersion
}

View file

@ -16,8 +16,7 @@ public class Bookmark: NSManagedObject {
title: title,
source: source,
size: size,
magnetLink: magnetLink,
magnetHash: magnetHash,
magnet: Magnet(hash: magnetHash, link: magnetLink),
seeders: seeders,
leechers: leechers
)

View file

@ -112,9 +112,11 @@ struct PersistenceController {
newBookmark.magnetLink = bookmarkJson.magnetLink
newBookmark.seeders = bookmarkJson.seeders
newBookmark.leechers = bookmarkJson.leechers
save(backgroundContext)
}
func createHistory(entryJson: HistoryEntryJson, date: Double?) {
func createHistory(_ entryJson: HistoryEntryJson, date: Double? = nil) {
let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date()
let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate)
@ -153,6 +155,8 @@ struct PersistenceController {
newHistoryEntry.parentHistory?.dateString = historyDateString
newHistoryEntry.parentHistory?.date = historyDate
save(backgroundContext)
}
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {

View file

@ -0,0 +1,17 @@
//
// Array.swift
// Ferrite
//
// Created by Brian Dashore on 12/4/22.
//
import Foundation
extension Array {
// From https://www.hackingwithswift.com/example-code/language/how-to-split-an-array-into-chunks
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0 ..< Swift.min($0 + size, count)])
}
}
}

View file

@ -0,0 +1,18 @@
//
// Bundle.swift
// Ferrite
//
// Created by Brian Dashore on 12/6/22.
//
import Foundation
extension Bundle {
var commitHash: String? {
infoDictionary?["GitCommitHash"] as? String
}
var isNightly: Bool {
infoDictionary?["IsNightly"] as? Bool ?? false
}
}

View file

@ -0,0 +1,26 @@
//
// Array.swift
// Ferrite
//
// Created by Brian Dashore on 11/26/22.
//
import Foundation
extension Set: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode(Set<Element>.self, from: data)
else { return nil }
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}

View file

@ -4,12 +4,21 @@
//
// Created by Brian Dashore on 8/31/22.
//
// From https://stackoverflow.com/a/59307884
//
import Foundation
extension String {
// From https://www.hackingwithswift.com/example-code/strings/how-to-capitalize-the-first-letter-of-a-string
func capitalizingFirstLetter() -> String {
prefix(1).capitalized + dropFirst()
}
mutating func capitalizeFirstLetter() {
self = capitalizingFirstLetter()
}
// From https://stackoverflow.com/a/59307884
private func compare(toVersion targetVersion: String) -> ComparisonResult {
let versionDelimiter = "."
var result: ComparisonResult = .orderedSame

View file

@ -23,6 +23,16 @@ extension View {
))
}
// From https://github.com/siteline/SwiftUI-Introspect/pull/129
public func introspectSearchController(customize: @escaping (UISearchController) -> Void) -> some View {
introspectNavigationController { navigationController in
let navigationBar = navigationController.navigationBar
if let searchController = navigationBar.topItem?.searchController {
customize(searchController)
}
}
}
// MARK: Modifiers
func conditionalContextMenu(id: some Hashable,

View file

@ -15,6 +15,19 @@
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Ferrite</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ferrite://</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>

View file

@ -0,0 +1,169 @@
//
// AllDebridModels.swift
// Ferrite
//
// Created by Brian Dashore on 11/25/22.
//
import Foundation
public extension AllDebrid {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum ADError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
// MARK: - Generic AllDebrid response
// Uses a generic parametr for whatever underlying response is present
struct ADResponse<ADData: Codable>: Codable {
let status: String
let data: ADData
}
// MARK: - PinResponse
struct PinResponse: Codable {
let pin, check: String
let expiresIn: Int
let userURL, baseURL, checkURL: String
enum CodingKeys: String, CodingKey {
case pin, check
case expiresIn = "expires_in"
case userURL = "user_url"
case baseURL = "base_url"
case checkURL = "check_url"
}
}
// MARK: - ApiKeyResponse
struct ApiKeyResponse: Codable {
let apikey: String
let activated: Bool
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case apikey, activated
case expiresIn = "expires_in"
}
}
// MARK: - AddMagnetResponse
struct AddMagnetResponse: Codable {
let magnets: [AddMagnetData]
}
// MARK: - AddMagnetData
internal struct AddMagnetData: Codable {
let magnet, hash, name, filenameOriginal: String
let size: Int
let ready: Bool
let id: Int
enum CodingKeys: String, CodingKey {
case magnet, hash, name
case filenameOriginal = "filename_original"
case size, ready, id
}
}
// MARK: - MagnetStatusResponse
struct MagnetStatusResponse: Codable {
let magnets: [MagnetStatusData]
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
magnets = [data]
} else if let data = try? container.decode([MagnetStatusData].self, forKey: .magnets) {
magnets = data
} else {
magnets = []
}
}
}
// MARK: - MagnetStatusData
struct MagnetStatusData: Codable {
let id: Int
let filename: String
let size: Int
let hash, status: String
let statusCode, downloaded, uploaded, seeders: Int
let downloadSpeed, processingPerc, uploadSpeed, uploadDate: Int
let completionDate: Int
let links: [MagnetStatusLink]
let type: String
let notified: Bool
let version: Int
}
// MARK: - MagnetStatusLink
// Abridged for required parameters
internal struct MagnetStatusLink: Codable {
let link: String
let filename: String
let size: Int
}
// MARK: - UnlockLinkResponse
// Abridged for required parameters
struct UnlockLinkResponse: Codable {
let link: String
}
// MARK: - InstantAvailabilityResponse
struct InstantAvailabilityResponse: Codable {
let magnets: [InstantAvailabilityMagnet]
}
// MARK: - IAMagnetResponse
internal struct InstantAvailabilityMagnet: Codable {
let magnet, hash: String
let instant: Bool
let files: [InstantAvailabilityFile]?
}
// MARK: - IAFileResponse
internal 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
}
}

View file

@ -8,6 +8,7 @@
import Foundation
public struct Backup: Codable {
let version: Int
var bookmarks: [BookmarkJson]?
var history: [HistoryJson]?
var sourceNames: [String]?
@ -16,7 +17,16 @@ public struct Backup: Codable {
// MARK: - CoreData translation
typealias BookmarkJson = SearchResult
// Don't typealias to search result as this is a reflection of CoreData's struct
struct BookmarkJson: Codable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let seeders: String?
let leechers: String?
}
// Date is an epoch timestamp
struct HistoryJson: Codable {
@ -26,10 +36,10 @@ struct HistoryJson: Codable {
}
struct HistoryEntryJson: Codable {
let name: String
let subName: String?
let url: String
let timeStamp: Double?
var name: String? = nil
var subName: String? = nil
var url: String? = nil
var timeStamp: Double? = nil
let source: String?
}

View file

@ -0,0 +1,99 @@
//
// DebridManagerModels.swift
// Ferrite
//
// Created by Brian Dashore on 11/27/22.
//
import Base32
import Foundation
// MARK: - Universal IA enum (IA = InstantAvailability)
public enum IAStatus: Codable, Hashable, Sendable {
case full
case partial
case none
}
// MARK: - Enum for debrid differentiation. 0 is nil
public enum DebridType: Int, Codable, Hashable, CaseIterable {
case realDebrid = 1
case allDebrid = 2
case premiumize = 3
func toString(abbreviated: Bool = false) -> String {
switch self {
case .realDebrid:
return abbreviated ? "RD" : "RealDebrid"
case .allDebrid:
return abbreviated ? "AD" : "AllDebrid"
case .premiumize:
return abbreviated ? "PM" : "Premiumize"
}
}
}
// Wrapper struct for magnet links to contain both the link and hash for easy access
public struct Magnet: Codable, Hashable, Sendable {
var hash: String?
var link: String?
init(hash: String?, link: String?, title: String? = nil, trackers: [String]? = nil) {
if let hash, link == nil {
self.hash = parseHash(hash)
self.link = generateLink(hash: hash, title: title, trackers: trackers)
} else if let link, hash == nil {
self.link = link
self.hash = parseHash(extractHash(link: link))
} else {
self.hash = parseHash(hash)
self.link = link
}
}
func generateLink(hash: String, title: String?, trackers: [String]?) -> String {
var magnetLinkArray = ["magnet:?xt=urn:btih:", hash]
if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
magnetLinkArray.append("&dn=\(encodedTitle)")
}
if let trackers {
for trackerUrl in trackers {
if URL(string: trackerUrl) != nil,
let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
{
magnetLinkArray.append("&tr=\(encodedUrlString)")
}
}
}
return magnetLinkArray.joined()
}
func extractHash(link: String) -> String? {
if let firstSplit = link.split(separator: ":")[safe: 3],
let tempHash = firstSplit.split(separator: "&")[safe: 0]
{
return String(tempHash)
} else {
return nil
}
}
// Is this a Base32hex hash?
func parseHash(_ magnetHash: String?) -> String? {
guard let magnetHash else {
return nil
}
if magnetHash.count == 32 {
let decryptedMagnetHash = base32DecodeToData(String(magnetHash))
return decryptedMagnetHash?.hexEncodedString()
} else {
return String(magnetHash).lowercased()
}
}
}

View file

@ -7,12 +7,14 @@
import Foundation
public struct GithubRelease: Codable, Hashable, Sendable {
let htmlUrl: String
let tagName: String
public extension Github {
struct Release: Codable, Hashable, Sendable {
let htmlUrl: String
let tagName: String
enum CodingKeys: String, CodingKey {
case htmlUrl = "html_url"
case tagName = "tag_name"
enum CodingKeys: String, CodingKey {
case htmlUrl = "html_url"
case tagName = "tag_name"
}
}
}

View file

@ -0,0 +1,104 @@
//
// PremiumizeModels.swift
// Ferrite
//
// Created by Brian Dashore on 11/28/22.
//
import Foundation
public extension Premiumize {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum PMError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
// MARK: - CacheCheckResponse
struct CacheCheckResponse: Codable {
let status: String
let response: [Bool]
}
// MARK: - DDLResponse
struct DDLResponse: Codable {
let status: String
let content: [DDLData]
let location: String
let filename: String
let filesize: Int
}
// MARK: Content
struct DDLData: Codable {
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 {
let status: String
let files: [UserItem]
}
// MARK: User Items
// Abridged for required parameters
struct UserItem: Codable {
let id: String
let name: String
let mimeType: String
enum CodingKeys: String, CodingKey {
case id, name
case mimeType = "mime_type"
}
}
// MARK: - ItemDetailsResponse
// Abridged for required parameters
struct ItemDetailsResponse: Codable {
let id: String
let name: String
let link: String
let mimeType: String
enum CodingKeys: String, CodingKey {
case id, name, link
case mimeType = "mime_type"
}
}
}

View file

@ -8,187 +8,197 @@
import Foundation
// MARK: - device code endpoint
public extension RealDebrid {
// MARK: - Errors
public struct DeviceCodeResponse: Codable, Sendable {
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case interval
case expiresIn = "expires_in"
case verificationURL = "verification_url"
case directVerificationURL = "direct_verification_url"
// TODO: Hybridize debrid errors in one structure
enum RDError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
}
// MARK: - device credentials endpoint
// MARK: - device code endpoint
public struct DeviceCredentialsResponse: Codable, Sendable {
let clientID, clientSecret: String?
struct DeviceCodeResponse: Codable, Sendable {
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case interval
case expiresIn = "expires_in"
case verificationURL = "verification_url"
case directVerificationURL = "direct_verification_url"
}
}
}
// MARK: - token endpoint
// MARK: - device credentials endpoint
public struct TokenResponse: Codable, Sendable {
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
struct DeviceCredentialsResponse: Codable, Sendable {
let clientID, clientSecret: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case tokenType = "token_type"
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
}
}
}
// MARK: - instantAvailability endpoint
// MARK: - token endpoint
// Thanks Skitty!
public struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
struct TokenResponse: Codable, Sendable {
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case tokenType = "token_type"
}
}
if let data = try? container.decode(InstantAvailabilityData.self) {
self.data = data
// MARK: - instantAvailability endpoint
// Thanks Skitty!
struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) {
self.data = data
}
}
}
internal struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
internal 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] = []
}
struct IABatch: Codable, Hashable, Sendable {
let files: [IABatchFile]
}
struct IABatchFile: Codable, Hashable, Sendable {
let id: Int
let fileName: String
}
struct IAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
// MARK: - addMagnet endpoint
struct AddMagnetResponse: Codable, Sendable {
let id: String
let uri: String
}
// MARK: - torrentInfo endpoint
internal struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
let split, progress: Int
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String?
let speed: Int?
let seeders: Int?
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended, speed, seeders
}
}
internal struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
}
struct UserTorrentsResponse: Codable, Hashable, Sendable {
let id, filename, hash: String
let bytes: Int
let host: String
let split, progress: Int
let status, added: String
let links: [String]
let speed, seeders: Int?
let ended: String?
}
// MARK: - unrestrictLink endpoint
internal struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks, crc: Int
let download: String
let streamable: Int
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, crc, download, streamable
}
}
// MARK: - User downloads list
struct UserDownloadsResponse: Codable, Hashable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks: Int
let download: String
let streamable: Int
let generated: String
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, download, streamable, generated
}
}
}
// MARK: - Instant Availability client side structures
struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String
var filesize: Int
}
public struct RealDebridIA: Codable, Hashable, Sendable {
let hash: String
let expiryTimeStamp: Double
var files: [RealDebridIAFile] = []
var batches: [RealDebridIABatch] = []
}
public struct RealDebridIABatch: Codable, Hashable, Sendable {
let files: [RealDebridIABatchFile]
}
public struct RealDebridIABatchFile: Codable, Hashable, Sendable {
let id: Int
let fileName: String
}
public struct RealDebridIAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
public enum RealDebridIAStatus: Codable, Hashable, Sendable {
case full
case partial
case none
}
// MARK: - addMagnet endpoint
public struct AddMagnetResponse: Codable, Sendable {
let id: String
let uri: String
}
// MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
let split, progress: Int
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String?
let speed: Int?
let seeders: Int?
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended, speed, seeders
}
}
struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
}
public struct UserTorrentsResponse: Codable, Sendable {
let id, filename, hash: String
let bytes: Int
let host: String
let split, progress: Int
let status, added: String
let links: [String]
let speed, seeders: Int?
let ended: String?
}
// MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks, crc: Int
let download: String
let streamable: Int
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, crc, download, streamable
}
}
// MARK: - User downloads list
public struct UserDownloadsResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks: Int
let download: String
let streamable: Int
let generated: String
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, download, streamable, generated
}
}

View file

@ -7,12 +7,11 @@
import Foundation
public struct SearchResult: Hashable, Codable, Sendable {
public struct SearchResult: Codable, Hashable, Sendable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let magnet: Magnet
let seeders: String?
let leechers: String?
}

View file

@ -8,6 +8,9 @@
import Foundation
public class BackupManager: ObservableObject {
// Constant variable for backup versions
let latestBackupVersion: Int = 1
var toastModel: ToastViewModel?
@Published var showRestoreAlert = false
@ -18,7 +21,7 @@ public class BackupManager: ObservableObject {
@Published var selectedBackupUrl: URL?
func createBackup() {
var backup = Backup()
var backup = Backup(version: latestBackupVersion)
let backgroundContext = PersistenceController.shared.backgroundContext
let bookmarkRequest = Bookmark.fetchRequest()
@ -123,7 +126,7 @@ public class BackupManager: ObservableObject {
if let storedHistories = backup.history {
for storedHistory in storedHistories {
for storedEntry in storedHistory.entries {
PersistenceController.shared.createHistory(entryJson: storedEntry, date: storedHistory.date)
PersistenceController.shared.createHistory(storedEntry, date: storedHistory.date)
}
}
}

View file

@ -13,137 +13,470 @@ public class DebridManager: ObservableObject {
// Linked classes
var toastModel: ToastViewModel?
let realDebrid: RealDebrid = .init()
let allDebrid: AllDebrid = .init()
let premiumize: Premiumize = .init()
// UI Variables
@Published var showWebView: Bool = false
@Published var showAuthSession: Bool = false
@Published var showLoadingProgress: Bool = false
// Service agnostic variables
var currentDebridTask: Task<Void, Never>?
// RealDebrid auth variables
@Published var realDebridEnabled: Bool = false {
@Published var enabledDebrids: Set<DebridType> = [] {
didSet {
UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled")
UserDefaults.standard.set(enabledDebrids.rawValue, forKey: "Debrid.EnabledArray")
}
}
@Published var selectedDebridType: DebridType? {
didSet {
UserDefaults.standard.set(selectedDebridType?.rawValue ?? 0, forKey: "Debrid.PreferredService")
}
}
var currentDebridTask: Task<Void, Never>?
var downloadUrl: String = ""
var authUrl: URL?
// RealDebrid auth variables
@Published var realDebridAuthProcessing: Bool = false
var realDebridAuthUrl: String = ""
// RealDebrid fetch variables
@Published var realDebridIAValues: [RealDebridIA] = []
var realDebridDownloadUrl: String = ""
@Published var realDebridIAValues: [RealDebrid.IA] = []
@Published var showDeleteAlert: Bool = false
// TODO: Switch to an individual item based sheet system to remove these variables
var selectedRealDebridItem: RealDebridIA?
var selectedRealDebridFile: RealDebridIAFile?
var selectedRealDebridItem: RealDebrid.IA?
var selectedRealDebridFile: RealDebrid.IAFile?
var selectedRealDebridID: String?
// 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
init() {
realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled")
if let rawDebridList = UserDefaults.standard.string(forKey: "Debrid.EnabledArray"),
let serializedDebridList = Set<DebridType>(rawValue: rawDebridList)
{
enabledDebrids = serializedDebridList
}
// If a UserDefaults integer isn't set, it's usually 0
let rawPreferredService = UserDefaults.standard.integer(forKey: "Debrid.PreferredService")
selectedDebridType = DebridType(rawValue: rawPreferredService)
// If a user has one logged in service, automatically set the preferred service to that one
if enabledDebrids.count == 1 {
selectedDebridType = enabledDebrids.first
}
}
public func populateDebridHashes(_ resultHashes: [String]) async {
// 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")
}
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")
}
}
// Wrapper function to match error descriptions
// Error can be suppressed to end user but must be printed in logs
func sendDebridError(_ error: Error, prefix: String, presentError: Bool = true, cancelString: String? = nil) async {
let error = error as NSError
if presentError {
if let cancelString, error.code == -999 {
toastModel?.updateToastDescription(cancelString, newToastType: .info)
} else if error.code != -999 {
toastModel?.updateToastDescription("\(prefix): \(error)")
}
}
print("\(prefix): \(error)")
}
// 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
}
}
// 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 sendHashes = resultHashes.filter { hash in
if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }) {
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
}
}
if !sendHashes.isEmpty {
let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes)
if !sendMagnets.isEmpty {
if enabledDebrids.contains(.realDebrid) {
let fetchedRealDebridIA = try await realDebrid.instantAvailability(magnets: sendMagnets)
realDebridIAValues += fetchedRealDebridIA
}
realDebridIAValues += fetchedIAValues
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
}
}
}
} catch {
let error = error as NSError
await sendDebridError(error, prefix: "Hash population error")
}
}
if error.code != -999 {
toastModel?.updateToastDescription("RealDebrid hash error: \(error)")
// Common function to match a magnet hash with a provided debrid service
public 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
}
print("RealDebrid hash error: \(error)")
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:
return .none
}
}
public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus {
guard let result else {
return .none
}
guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else {
return .none
}
if debridMatch.batches.isEmpty {
return .full
} else {
return .partial
}
}
public func setSelectedRdResult(result: SearchResult) -> Bool {
guard let magnetHash = result.magnetHash else {
public func selectDebridResult(magnet: Magnet) -> Bool {
guard let magnetHash = magnet.hash else {
toastModel?.updateToastDescription("Could not find the torrent magnet hash")
return false
}
if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) {
selectedRealDebridItem = realDebridItem
return true
} else {
toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
switch selectedDebridType {
case .realDebrid:
if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) {
selectedRealDebridItem = realDebridItem
return true
} else {
toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
return false
}
case .allDebrid:
if let allDebridItem = allDebridIAValues.first(where: { magnetHash == $0.magnet.hash }) {
selectedAllDebridItem = allDebridItem
return true
} else {
toastModel?.updateToastDescription("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 {
toastModel?.updateToastDescription("Could not find the associated Premiumize entry for magnet hash \(magnetHash)")
return false
}
case .none:
return false
}
}
public func authenticateRd() async {
// 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()
}
}
// 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
}
}
// Wrapper function to validate and present an auth URL to the user
@discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
guard let url else {
toastModel?.updateToastDescription("Authentication Error: Invalid URL created: \(String(describing: url))")
return false
}
authUrl = url
if useAuthSession {
showAuthSession.toggle()
} else {
showWebView.toggle()
}
return true
}
private func authenticateRd() async -> Bool {
do {
realDebridAuthProcessing = true
let verificationResponse = try await realDebrid.getVerificationInfo()
realDebridAuthUrl = verificationResponse.directVerificationURL
showWebView.toggle()
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")
}
try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode)
realDebridEnabled = true
return true
} catch {
toastModel?.updateToastDescription("RealDebrid authentication error: \(error)")
realDebrid.authTask?.cancel()
await sendDebridError(error, prefix: "RealDebrid authentication error")
print("RealDebrid authentication error: \(error)")
realDebrid.authTask?.cancel()
return false
}
}
public func logoutRd() async {
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 {
do {
if let error {
throw Premiumize.PMError.AuthQuery(description: "OAuth callback Error: \(error)")
}
if let callbackUrl = url {
try premiumize.handleAuthCallback(url: callbackUrl)
enabledDebrids.insert(.premiumize)
completeDebridAuth(.premiumize)
} else {
throw Premiumize.PMError.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
// 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()
}
// 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()
realDebridEnabled = false
realDebridAuthProcessing = false
enabledDebrids.remove(.realDebrid)
} catch {
toastModel?.updateToastDescription("RealDebrid logout error: \(error)")
print("RealDebrid logout error: \(error)")
await sendDebridError(error, prefix: "RealDebrid logout error")
}
}
public func fetchRdDownload(searchResult: SearchResult) async {
private func logoutAd() {
allDebrid.deleteTokens()
enabledDebrids.remove(.allDebrid)
toastModel?.updateToastDescription("Please manually delete the AllDebrid API key", newToastType: .info)
}
private func logoutPm() {
premiumize.deleteTokens()
enabledDebrids.remove(.premiumize)
}
// 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 {
defer {
currentDebridTask = nil
showLoadingProgress = false
@ -151,83 +484,252 @@ public class DebridManager: ObservableObject {
showLoadingProgress = true
guard let magnetLink = searchResult.magnetLink else {
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("RealDebrid error: Invalid magnet link")
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
}
}
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 {
var fileIds: [Int] = []
if let iaFile = selectedRealDebridFile {
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
return
}
fileIds = iaBatchFromFile.files.map(\.id)
}
// If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link
let existingTorrents = try await realDebrid.userTorrents().filter { $0.hash == selectedRealDebridItem?.hash }
// If the links match from a user's downloads, no need to re-run a download
if let existingTorrent = existingTorrents[safe: 0],
let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0]
if let torrentLink,
let downloadLink = await checkRdUserDownloads(userTorrentLink: torrentLink)
{
let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink }
if let existingLink = existingLinks[safe: 0]?.download {
realDebridDownloadUrl = existingLink
} else {
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
realDebridDownloadUrl = downloadLink
}
} else {
downloadUrl = downloadLink
} else if let magnet {
// Add a magnet after all the cache checks fail
selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink)
selectedRealDebridID = try await realDebrid.addMagnet(magnet: magnet)
var fileIds: [Int] = []
if let iaFile = selectedRealDebridFile {
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
return
}
fileIds = iaBatchFromFile.files.map(\.id)
}
if let realDebridId = selectedRealDebridID {
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0)
let torrentLink = try await realDebrid.torrentInfo(
debridID: realDebridId,
selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0
)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
realDebridDownloadUrl = downloadLink
downloadUrl = downloadLink
} else {
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
}
} else {
throw RealDebrid.RDError.FailedRequest(description: "Could not fetch your file from RealDebrid's cache or API")
}
// Fetch one more time to add updated data into the RD cloud cache
await fetchRdCloud(bypassTTL: true)
} catch {
switch error {
case RealDebridError.EmptyTorrents:
case RealDebrid.RDError.EmptyTorrents:
showDeleteAlert.toggle()
default:
let error = error as NSError
await sendDebridError(error, prefix: "RealDebrid download error", cancelString: "Download cancelled")
switch error.code {
case -999:
toastModel?.updateToastDescription("Download cancelled", newToastType: .info)
default:
toastModel?.updateToastDescription("RealDebrid download error: \(error)")
}
await deleteRdTorrent()
await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false)
}
showLoadingProgress = false
print("RealDebrid download error: \(error)")
}
}
public func deleteRdTorrent() async {
if let realDebridId = selectedRealDebridID {
try? await realDebrid.deleteTorrent(debridID: realDebridId)
// Refreshes torrents and downloads from a RD user's account
public func fetchRdCloud(bypassTTL: Bool = false) async {
if bypassTTL || Date().timeIntervalSince1970 > realDebridCloudTTL {
do {
realDebridCloudTorrents = try await realDebrid.userTorrents()
realDebridCloudDownloads = try await realDebrid.userDownloads()
// 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
}
selectedRealDebridID = nil
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
} catch {
let error = error as NSError
if error.code != -999 {
await sendDebridError(error, prefix: "Premiumize cloud fetch error")
}
}
}
}
public func deletePmItem(id: String) async {
do {
try await premiumize.deleteItem(itemID: id)
// Bypass TTL to get current RD values
await fetchPmCloud(bypassTTL: true)
} catch {
await sendDebridError(error, prefix: "Premiumize cloud delete error")
}
}
}

View file

@ -32,11 +32,13 @@ class NavigationViewModel: ObservableObject {
@Published var isEditingSearch: Bool = false
@Published var isSearching: Bool = false
@Published var selectedSearchResult: SearchResult?
@Published var selectedMagnet: Magnet?
@Published var selectedHistoryInfo: HistoryEntryJson?
@Published var resultFromCloud: Bool = false
// For giving information in magnet choice sheet
@Published var selectedTitle: String?
@Published var selectedBatchTitle: String?
@Published var selectedTitle: String = ""
@Published var selectedBatchTitle: String = ""
@Published var hideNavigationBar = false
@ -93,16 +95,18 @@ class NavigationViewModel: ObservableObject {
}
}
public func runMagnetAction(magnetString: String?, _ action: DefaultMagnetActionType? = nil) {
let selectedAction = action ?? defaultMagnetAction
guard let magnetLink = magnetString else {
public func runMagnetAction(magnet: Magnet?, _ action: DefaultMagnetActionType? = nil) {
// Fall back to selected magnet if the provided magnet is nil
let magnet = magnet ?? selectedMagnet
guard let magnetLink = magnet?.link else {
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("Magnet action error: The magnet link is invalid.")
return
}
let selectedAction = action ?? defaultMagnetAction
switch selectedAction {
case .none:
currentChoiceSheet = .magnet
@ -123,22 +127,4 @@ class NavigationViewModel: ObservableObject {
}
}
}
public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) {
let backgroundContext = PersistenceController.shared.backgroundContext
// The timeStamp and date are nil because the create function will make them automatically
PersistenceController.shared.createHistory(
entryJson: HistoryEntryJson(
name: name ?? "",
subName: subName,
url: url ?? "",
timeStamp: nil,
source: source
),
date: nil
)
PersistenceController.shared.save(backgroundContext)
}
}

View file

@ -12,8 +12,6 @@ import SwiftUI
import SwiftyJSON
class ScrapingViewModel: ObservableObject {
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
// Link the toast view model for single-directional communication
var toastModel: ToastViewModel?
let byteCountFormatter: ByteCountFormatter = .init()
@ -355,7 +353,7 @@ class ScrapingViewModel: ObservableObject {
source: source,
existingSearchResult: searchResult
),
let magnetLink = newSearchResult.magnetLink,
let magnetLink = newSearchResult.magnet.link,
magnetLink.starts(with: "magnet:"),
!tempResults.contains(newSearchResult)
{
@ -364,7 +362,7 @@ class ScrapingViewModel: ObservableObject {
}
} else if
let searchResult,
let magnetLink = searchResult.magnetLink,
let magnetLink = searchResult.magnet.link,
magnetLink.starts(with: "magnet:"),
!tempResults.contains(searchResult)
{
@ -376,18 +374,16 @@ class ScrapingViewModel: ObservableObject {
}
public func parseJsonResult(_ result: JSON, jsonParser: SourceJsonParser, source: Source, existingSearchResult: SearchResult? = nil) -> SearchResult? {
var magnetHash: String? = existingSearchResult?.magnetHash
var magnetHash: String? = existingSearchResult?.magnet.hash
if let magnetHashParser = jsonParser.magnetHash {
let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue
if !(rawHash is NSNull) {
magnetHash = fetchMagnetHash(existingHash: String(describing: rawHash))
magnetHash = String(describing: rawHash)
}
}
var title: String? = existingSearchResult?.title
if let titleParser = jsonParser.title {
if let existingTitle = existingSearchResult?.title,
let discriminatorQuery = titleParser.discriminator
@ -403,21 +399,13 @@ class ScrapingViewModel: ObservableObject {
}
}
var link: String? = existingSearchResult?.magnetLink
if let magnetLinkParser = jsonParser.magnetLink, existingSearchResult?.magnetLink == nil {
var link: String? = existingSearchResult?.magnet.link
if let magnetLinkParser = jsonParser.magnetLink, link == nil {
let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue
link = rawLink is NSNull ? nil : String(describing: rawLink)
} else if let magnetHash {
link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers)
}
if magnetHash == nil, let href = link {
magnetHash = fetchMagnetHash(magnetLink: href)
}
var size: String? = existingSearchResult?.size
if let sizeParser = jsonParser.size, existingSearchResult?.size == nil {
let rawSize = result[sizeParser.query.components(separatedBy: ".")].rawValue
size = rawSize is NSNull ? nil : String(describing: rawSize)
@ -446,8 +434,7 @@ class ScrapingViewModel: ObservableObject {
title: title,
source: source.name,
size: size,
magnetLink: link,
magnetHash: magnetHash,
magnet: Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers),
seeders: seeders,
leechers: leechers
)
@ -478,15 +465,13 @@ class ScrapingViewModel: ObservableObject {
// Parse magnet link or translate hash
var magnetHash: String?
if let magnetHashParser = rssParser.magnetHash {
let tempHash = try? runRssComplexQuery(
magnetHash = try? runRssComplexQuery(
item: item,
query: magnetHashParser.query,
attribute: magnetHashParser.attribute,
discriminator: magnetHashParser.discriminator,
regexString: magnetHashParser.regex
)
magnetHash = fetchMagnetHash(existingHash: tempHash)
}
var title: String?
@ -509,8 +494,6 @@ class ScrapingViewModel: ObservableObject {
discriminator: magnetLinkParser.discriminator,
regexString: magnetLinkParser.regex
)
} else if let magnetHash {
link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers)
} else {
continue
}
@ -519,10 +502,6 @@ class ScrapingViewModel: ObservableObject {
continue
}
if magnetHash == nil {
magnetHash = fetchMagnetHash(magnetLink: href)
}
var size: String?
if let sizeParser = rssParser.size {
size = try? runRssComplexQuery(
@ -566,8 +545,7 @@ class ScrapingViewModel: ObservableObject {
title: title ?? "No title",
source: source.name,
size: size ?? "",
magnetLink: href,
magnetHash: magnetHash,
magnet: Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers),
seeders: seeders,
leechers: leechers
)
@ -675,9 +653,6 @@ class ScrapingViewModel: ObservableObject {
continue
}
// Fetches the magnet hash
let magnetHash = fetchMagnetHash(magnetLink: href)
// Fetches the episode/movie title
var title: String?
if let titleParser = htmlParser.title {
@ -745,8 +720,7 @@ class ScrapingViewModel: ObservableObject {
title: title ?? "No title",
source: source.name,
size: size ?? "",
magnetLink: href,
magnetHash: magnetHash,
magnet: Magnet(hash: nil, link: href),
seeders: seeders,
leechers: leechers
)
@ -788,31 +762,6 @@ class ScrapingViewModel: ObservableObject {
}
}
// Fetches and possibly converts the magnet hash value to sha1
public func fetchMagnetHash(magnetLink: String? = nil, existingHash: String? = nil) -> String? {
var magnetHash: String
if let existingHash {
magnetHash = existingHash
} else if
let magnetLink,
let firstSplit = magnetLink.split(separator: ":")[safe: 3],
let tempHash = firstSplit.split(separator: "&")[safe: 0]
{
magnetHash = String(tempHash)
} else {
return nil
}
// Is this a Base32hex hash?
if magnetHash.count == 32 {
let decryptedMagnetHash = base32DecodeToData(String(magnetHash))
return decryptedMagnetHash?.hexEncodedString()
} else {
return String(magnetHash).lowercased()
}
}
func parseSizeString(sizeString: String) -> String? {
// Test if the string can be a full integer
guard let size = Int(sizeString) else {
@ -835,28 +784,6 @@ class ScrapingViewModel: ObservableObject {
}
}
public func generateMagnetLink(magnetHash: String, title: String?, trackers: [String]?) -> String {
var magnetLinkArray = ["magnet:?xt=urn:btih:"]
magnetLinkArray.append(magnetHash)
if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
magnetLinkArray.append("&dn=\(encodedTitle)")
}
if let trackers {
for trackerUrl in trackers {
if URL(string: trackerUrl) != nil,
let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
{
magnetLinkArray.append("&tr=\(encodedUrlString)")
}
}
}
return magnetLinkArray.joined()
}
func cleanApiCreds(api: SourceApi) async {
let backgroundContext = PersistenceController.shared.backgroundContext

View file

@ -14,6 +14,11 @@ struct AboutView: View {
ListRowTextView(leftText: "Version", rightText: Application.shared.appVersion)
ListRowTextView(leftText: "Build number", rightText: Application.shared.appBuild)
ListRowTextView(leftText: "Build type", rightText: Application.shared.buildType)
if let commitHash = Bundle.main.commitHash {
ListRowTextView(leftText: "Commit", rightText: commitHash)
}
ListRowLinkView(text: "Discord server", link: "https://discord.gg/sYQxnuD7Fj")
ListRowLinkView(text: "GitHub repository", link: "https://github.com/bdashore3/Ferrite")
} header: {

View file

@ -1,69 +0,0 @@
//
// BatchChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
struct BatchChoiceView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
var body: some View {
NavView {
List {
ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in
Button(file.name) {
debridManager.selectedRealDebridFile = file
if let searchResult = navModel.selectedSearchResult {
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: searchResult)
if !debridManager.realDebridDownloadUrl.isEmpty {
// The download may complete before this sheet dismisses
try? await Task.sleep(seconds: 1)
navModel.selectedBatchTitle = file.name
navModel.addToHistory(name: searchResult.title, source: searchResult.source, url: debridManager.realDebridDownloadUrl, subName: file.name)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl)
}
debridManager.selectedRealDebridFile = nil
debridManager.selectedRealDebridItem = nil
}
}
navModel.currentChoiceSheet = nil
}
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.navigationTitle("Select a file")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
navModel.currentChoiceSheet = nil
Task {
try? await Task.sleep(seconds: 1)
debridManager.selectedRealDebridItem = nil
}
}
}
}
}
}
}
struct BatchChoiceView_Previews: PreviewProvider {
static var previews: some View {
BatchChoiceView()
}
}

View file

@ -0,0 +1,37 @@
//
// DebridChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 11/26/22.
//
import SwiftUI
struct DebridChoiceView: View {
@EnvironmentObject var debridManager: DebridManager
var body: some View {
Menu {
Picker("", selection: $debridManager.selectedDebridType) {
Text("None")
.tag(nil as DebridType?)
ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in
if debridManager.enabledDebrids.contains(debridType) {
Text(debridType.toString())
.tag(DebridType?.some(debridType))
}
}
}
} label: {
Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid")
}
.animation(.none)
}
}
struct DebridChoiceView_Previews: PreviewProvider {
static var previews: some View {
DebridChoiceView()
}
}

View file

@ -0,0 +1,45 @@
//
// DebridLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 11/27/22.
//
import SwiftUI
struct DebridLabelView: View {
@EnvironmentObject var debridManager: DebridManager
@State var cloudLinks: [String] = []
var magnet: Magnet?
var body: some View {
if let selectedDebridType = debridManager.selectedDebridType {
Text(selectedDebridType.toString(abbreviated: true))
.fontWeight(.bold)
.padding(2)
.background {
Group {
if let magnet, cloudLinks.isEmpty {
switch debridManager.matchMagnetHash(magnet) {
case .full:
Color.green
case .partial:
Color.orange
case .none:
Color.red
}
} else if cloudLinks.count == 1 {
Color.green
} else if cloudLinks.count > 1 {
Color.orange
} else {
Color.red
}
}
.cornerRadius(4)
.opacity(0.5)
}
}
}
}

View file

@ -13,18 +13,17 @@ struct BookmarksView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
let backgroundContext = PersistenceController.shared.backgroundContext
var bookmarks: FetchedResults<Bookmark>
@Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
@State private var bookmarkPredicate: NSPredicate?
var body: some View {
ZStack {
if !bookmarks.isEmpty {
List {
DynamicFetchRequest(predicate: bookmarkPredicate) { (bookmarks: FetchedResults<Bookmark>) in
List {
if !bookmarks.isEmpty {
ForEach(bookmarks, id: \.self) { bookmark in
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
}
@ -33,7 +32,7 @@ struct BookmarksView: View {
if let bookmark = bookmarks[safe: index] {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
NotificationCenter.default.post(name: .didDeleteBookmark, object: nil)
NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
}
}
}
@ -49,20 +48,36 @@ struct BookmarksView: View {
PersistenceController.shared.save()
}
}
.inlinedList()
.listStyle(.insetGrouped)
}
.inlinedList()
.listStyle(.insetGrouped)
.onAppear {
if debridManager.enabledDebrids.count > 0 {
viewTask = Task {
let magnets = bookmarks.compactMap {
if let magnetHash = $0.magnetHash {
return Magnet(hash: magnetHash, link: $0.magnetLink)
} else {
return nil
}
}
await debridManager.populateDebridIA(magnets)
}
}
}
.onDisappear {
viewTask?.cancel()
}
}
.onAppear {
if realDebridEnabled {
viewTask = Task {
let hashes = bookmarks.compactMap(\.magnetHash)
await debridManager.populateDebridHashes(hashes)
}
}
applyPredicate()
}
.onDisappear {
viewTask?.cancel()
.onChange(of: searchText) { _ in
applyPredicate()
}
}
func applyPredicate() {
bookmarkPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
}
}

View file

@ -0,0 +1,92 @@
//
// AllDebridCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 1/5/23.
//
import SwiftUI
struct AllDebridCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
var body: some View {
DisclosureGroup("Magnets") {
ForEach(debridManager.allDebridCloudMagnets.filter {
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
}, id: \.id) { magnet in
Button {
if magnet.status == "Ready", !magnet.links.isEmpty {
navModel.resultFromCloud = true
navModel.selectedTitle = magnet.filename
var historyInfo = HistoryEntryJson(
name: magnet.filename,
source: DebridType.allDebrid.toString()
)
Task {
if magnet.links.count == 1 {
if let lockedLink = magnet.links[safe: 0]?.link {
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: lockedLink)
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}
}
} else {
let magnet = Magnet(hash: magnet.hash, link: nil)
// Do not clear old IA values
await debridManager.populateDebridIA([magnet])
if debridManager.selectDebridResult(magnet: magnet) {
navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch
}
}
}
}
} label: {
VStack(alignment: .leading, spacing: 10) {
Text(magnet.filename)
HStack {
Text(magnet.status)
Spacer()
DebridLabelView(cloudLinks: magnet.links.map(\.link))
}
.font(.caption)
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.backport.tint(.black)
}
.onDelete { offsets in
for index in offsets {
if let magnet = debridManager.allDebridCloudMagnets[safe: index] {
Task {
await debridManager.deleteAdMagnet(magnetId: magnet.id)
}
}
}
}
}
.onAppear {
viewTask = Task {
await debridManager.fetchAdCloud()
}
}
.onDisappear {
viewTask?.cancel()
}
}
}

View file

@ -0,0 +1,66 @@
//
// PremiumizeCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 1/2/23.
//
import SwiftUI
import SwiftUIX
struct PremiumizeCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
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()
)
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.backport.tint(.black)
}
.onDelete { offsets in
for index in offsets {
if let item = debridManager.premiumizeCloudItems[safe: index] {
Task {
await debridManager.deletePmItem(id: item.id)
}
}
}
}
}
.onAppear {
viewTask = Task {
await debridManager.fetchPmCloud()
}
}
.onDisappear {
viewTask?.cancel()
}
}
}

View file

@ -0,0 +1,128 @@
//
// RealDebridCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 12/31/22.
//
import SwiftUI
struct RealDebridCloudView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
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()
)
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}
.backport.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)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}
}
} 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))
.backport.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] {
Task {
await debridManager.deleteRdTorrent(torrentID: torrentResponse.id)
}
}
}
}
}
}
.onAppear {
viewTask = Task {
await debridManager.fetchRdCloud()
}
}
.onDisappear {
viewTask?.cancel()
}
}
}

View file

@ -0,0 +1,30 @@
//
// DebridCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 12/31/22.
//
import SwiftUI
struct DebridCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@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()
}
}
.listStyle(.plain)
}
}

View file

@ -16,27 +16,27 @@ struct HistoryButtonView: View {
var body: some View {
Button {
navModel.selectedTitle = entry.name
navModel.selectedBatchTitle = entry.subName
navModel.selectedTitle = entry.name ?? ""
navModel.selectedBatchTitle = entry.subName ?? ""
if let url = entry.url {
if url.starts(with: "https://") {
Task {
debridManager.realDebridDownloadUrl = url
debridManager.downloadUrl = url
navModel.runDebridAction(urlString: url)
if navModel.currentChoiceSheet != .magnet {
debridManager.realDebridDownloadUrl = ""
debridManager.downloadUrl = ""
}
}
} else {
navModel.runMagnetAction(magnetString: url)
navModel.runMagnetAction(magnet: Magnet(hash: nil, link: url))
}
} else {
toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.")
}
} label: {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading) {
Text(entry.name ?? "Unknown title")
.font(entry.subName == nil ? .body : .subheadline)

View file

@ -0,0 +1,110 @@
//
// HistoryView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct HistoryView: View {
@EnvironmentObject var navModel: NavigationViewModel
var history: FetchedResults<History>
@Binding var searchText: String
@State private var historyPredicate: NSPredicate?
var body: some View {
DynamicFetchRequest(predicate: historyPredicate) { (allEntries: FetchedResults<HistoryEntry>) in
List {
if !history.isEmpty {
ForEach(groupedHistory(history), id: \.self) { historyGroup in
HistorySectionView(allEntries: allEntries, historyGroup: historyGroup)
}
}
}
.listStyle(.insetGrouped)
}
.onAppear {
applyPredicate()
}
.onChange(of: searchText) { _ in
applyPredicate()
}
}
func applyPredicate() {
if searchText.isEmpty {
historyPredicate = nil
} else {
let namePredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased())
let subNamePredicate = NSPredicate(format: "subName CONTAINS[cd] %@", searchText.lowercased())
historyPredicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, subNamePredicate])
}
}
func groupedHistory(_ result: FetchedResults<History>) -> [[History]] {
Dictionary(grouping: result) { (element: History) in
element.dateString ?? ""
}
.values
.sorted { $0[0].date ?? Date() > $1[0].date ?? Date() }
}
}
struct HistorySectionView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
var formatter: DateFormatter = .init()
var allEntries: FetchedResults<HistoryEntry>
var historyGroup: [History]
init(allEntries: FetchedResults<HistoryEntry>, historyGroup: [History]) {
self.allEntries = allEntries
self.historyGroup = historyGroup
formatter.dateStyle = .medium
formatter.timeStyle = .none
}
var body: some View {
if compareGroup(historyGroup) > 0 {
Section(header: Text(formatter.string(from: historyGroup[0].date ?? Date()))) {
ForEach(historyGroup, id: \.self) { history in
ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in
HistoryButtonView(entry: entry)
}
.onDelete { offsets in
removeEntry(at: offsets, from: history)
}
}
}
}
}
func compareGroup(_ group: [History]) -> Int {
var totalCount = 0
for history in group {
totalCount += history.entryArray.reduce(0) { result, item in
result + (allEntries.contains { $0.name == item.name || (item.subName.map { !$0.isEmpty } ?? false && $0.subName == item.subName) } ? 1 : 0)
}
}
return totalCount
}
func removeEntry(at offsets: IndexSet, from history: History) {
for index in offsets {
if let entry = history.entryArray[safe: index] {
history.removeFromEntries(entry)
PersistenceController.shared.delete(entry, context: backgroundContext)
}
if history.entryArray.isEmpty {
PersistenceController.shared.delete(history, context: backgroundContext)
}
}
}
}

View file

@ -0,0 +1,160 @@
//
// SearchResultButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct SearchResultButtonView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
var result: SearchResult
@State private var runOnce = false
@State var existingBookmark: Bookmark? = nil
@State private var showConfirmation = false
var body: some View {
Button {
if debridManager.currentDebridTask == nil {
navModel.selectedMagnet = result.magnet
navModel.selectedTitle = result.title ?? ""
navModel.resultFromCloud = false
switch debridManager.matchMagnetHash(result.magnet) {
case .full:
if debridManager.selectDebridResult(magnet: result.magnet) {
debridManager.currentDebridTask = Task {
await debridManager.fetchDebridDownload(magnet: result.magnet)
if !debridManager.downloadUrl.isEmpty {
PersistenceController.shared.createHistory(
HistoryEntryJson(
name: result.title,
url: debridManager.downloadUrl,
source: result.source
)
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
if navModel.currentChoiceSheet != .magnet {
debridManager.downloadUrl = ""
}
}
}
}
case .partial:
if debridManager.selectDebridResult(magnet: result.magnet) {
navModel.selectedHistoryInfo = HistoryEntryJson(
name: result.title,
source: result.source
)
navModel.currentChoiceSheet = .batch
}
case .none:
PersistenceController.shared.createHistory(
HistoryEntryJson(
name: result.title,
url: result.magnet.link,
source: result.source
)
)
navModel.runMagnetAction(magnet: result.magnet)
}
}
} label: {
VStack(alignment: .leading, spacing: 10) {
Text(result.title ?? "No title")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(4)
SearchResultInfoView(result: result)
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
}
.disableInteraction(navModel.currentChoiceSheet != nil)
.backport.tint(.primary)
.conditionalContextMenu(id: existingBookmark) {
ZStack {
if let bookmark = existingBookmark {
Button {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
// When the entity is deleted, let other instances know to remove that reference
NotificationCenter.default.post(name: .didDeleteBookmark, object: existingBookmark)
} label: {
Text("Remove bookmark")
Image(systemName: "bookmark.slash.fill")
}
} else {
Button {
let newBookmark = Bookmark(context: backgroundContext)
newBookmark.title = result.title
newBookmark.source = result.source
newBookmark.magnetHash = result.magnet.hash
newBookmark.magnetLink = result.magnet.link
newBookmark.seeders = result.seeders
newBookmark.leechers = result.leechers
existingBookmark = newBookmark
PersistenceController.shared.save(backgroundContext)
} label: {
Text("Bookmark")
Image(systemName: "bookmark")
}
}
}
}
.backport.alert(
isPresented: $debridManager.showDeleteAlert,
title: "Caching file",
message: "RealDebrid is currently caching this file. Would you like to delete it? \n\nProgress can be checked on the RealDebrid website.",
buttons: [
AlertButton("Yes", role: .destructive) {
Task {
await debridManager.deleteRdTorrent()
}
},
AlertButton(role: .cancel)
]
)
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in
// If the instance contains the deleted bookmark, remove it.
if let deletedBookmark = notification.object as? Bookmark,
let bookmark = existingBookmark,
deletedBookmark.objectID == bookmark.objectID
{
existingBookmark = nil
}
}
.onAppear {
// Only run a exists request if a bookmark isn't passed to the view
if existingBookmark == nil, !runOnce {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "title == %@ AND source == %@ AND magnetLink == %@ AND magnetHash = %@",
result.title ?? "",
result.source,
result.magnet.link ?? "",
result.magnet.hash ?? ""
)
bookmarkRequest.fetchLimit = 1
if let fetchedBookmark = try? backgroundContext.fetch(bookmarkRequest).first {
existingBookmark = fetchedBookmark
}
runOnce = true
}
}
}
}

View file

@ -0,0 +1,37 @@
//
// SearchResultRDView.swift
// Ferrite
//
// Created by Brian Dashore on 7/26/22.
//
import SwiftUI
struct SearchResultInfoView: View {
@EnvironmentObject var debridManager: DebridManager
var result: SearchResult
var body: some View {
HStack {
Text(result.source)
Spacer()
if let seeders = result.seeders {
Text("S: \(seeders)")
}
if let leechers = result.leechers {
Text("L: \(leechers)")
}
if let size = result.size {
Text(size)
}
DebridLabelView(magnet: result.magnet)
}
.font(.caption)
}
}

View file

@ -11,7 +11,7 @@ struct SettingsAppVersionView: View {
@EnvironmentObject var toastModel: ToastViewModel
@State private var viewTask: Task<Void, Never>?
@State private var releases: [GithubRelease] = []
@State private var releases: [Github.Release] = []
@State private var loadedReleases = false

View file

@ -60,7 +60,7 @@ struct SourceSettingsView: View {
.onDisappear {
PersistenceController.shared.save()
}
.navigationTitle("Source settings")
.navigationTitle("Source Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {

View file

@ -14,13 +14,13 @@ struct ContentView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var sourceManager: SourceManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var selectedSource: Source? {
didSet {
scrapingModel.filteredSource = selectedSource
@ -64,6 +64,7 @@ struct ContentView: View {
Image(systemName: "chevron.down")
}
.foregroundColor(.primary)
.animation(.none)
Spacer()
}
@ -73,6 +74,9 @@ struct ContentView: View {
SearchResultsView()
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(
navModel.isSearching && Application.shared.osVersion.majorVersion > 14 ? .inline : .large
)
.navigationSearchBar {
SearchBar("Search",
text: $scrapingModel.searchText,
@ -86,12 +90,18 @@ struct ContentView: View {
let sources = sourceManager.fetchInstalledSources()
await scrapingModel.scanSources(sources: sources)
if realDebridEnabled, !scrapingModel.searchResults.isEmpty {
debridManager.realDebridIAValues = []
if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty {
debridManager.clearIAValues()
await debridManager.populateDebridHashes(
scrapingModel.searchResults.compactMap(\.magnetHash)
)
// Remove magnets that don't have a hash
let magnets = scrapingModel.searchResults.compactMap {
if let magnetHash = $0.magnet.hash {
return Magnet(hash: magnetHash, link: $0.magnet.link)
} else {
return nil
}
}
await debridManager.populateDebridIA(magnets)
}
navModel.showSearchProgress = false
@ -106,6 +116,16 @@ struct ContentView: View {
scrapingModel.searchText = ""
}
}
.introspectSearchController { searchController in
searchController.hidesNavigationBarDuringPresentation = false
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
DebridChoiceView()
}
}
}
}
}

View file

@ -6,20 +6,21 @@
//
import SwiftUI
import SwiftUIX
struct LibraryView: View {
enum LibraryPickerSegment {
case bookmarks
case history
case debridCloud
}
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@FetchRequest(
entity: Bookmark.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)
]
sortDescriptors: []
) var bookmarks: FetchedResults<Bookmark>
@FetchRequest(
@ -29,30 +30,55 @@ struct LibraryView: View {
]
) var history: FetchedResults<History>
@State private var historyEmpty = true
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var selectedSegment: LibraryPickerSegment = .bookmarks
@State private var editMode: EditMode = .inactive
@State private var searchText: String = ""
@State private var isEditingSearch = false
@State private var isSearching = false
var body: some View {
NavView {
VStack {
Picker("Segments", selection: $selectedSegment) {
Text("Bookmarks").tag(LibraryPickerSegment.bookmarks)
Text("History").tag(LibraryPickerSegment.history)
if !debridManager.enabledDebrids.isEmpty {
Text("Cloud").tag(LibraryPickerSegment.debridCloud)
}
}
.pickerStyle(.segmented)
.padding()
.padding(.horizontal)
.padding(.vertical, 5)
switch selectedSegment {
case .bookmarks:
BookmarksView(bookmarks: bookmarks)
BookmarksView(searchText: $searchText)
case .history:
HistoryView(history: history)
HistoryView(history: history, searchText: $searchText)
case .debridCloud:
DebridCloudView(searchText: $searchText)
}
Spacer()
}
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
.overlay {
switch selectedSegment {
case .bookmarks:
@ -63,18 +89,27 @@ struct LibraryView: View {
if history.isEmpty {
EmptyInstructionView(title: "No History", message: "Start watching to build history")
}
case .debridCloud:
if debridManager.selectedDebridType == nil {
EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service")
}
}
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
HStack(spacing: Application.shared.osVersion.majorVersion > 14 ? 10 : 18) {
Spacer()
EditButton()
if selectedSegment == .history {
switch selectedSegment {
case .bookmarks, .debridCloud:
DebridChoiceView()
case .history:
HistoryActionsView()
}
}
.animation(.none)
}
}
.environment(\.editMode, $editMode)

View file

@ -1,65 +0,0 @@
//
// HistoryView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct HistoryView: View {
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
var history: FetchedResults<History>
var formatter: DateFormatter = .init()
@State private var historyIndex = 0
init(history: FetchedResults<History>) {
self.history = history
formatter.dateStyle = .medium
formatter.timeStyle = .none
}
func groupedEntries(_ result: FetchedResults<History>) -> [[History]] {
Dictionary(grouping: result) { (element: History) in
element.dateString ?? ""
}.values.sorted { $0[0].date ?? Date() > $1[0].date ?? Date() }
}
var body: some View {
if !history.isEmpty {
List {
ForEach(groupedEntries(history), id: \.self) { (section: [History]) in
Section(header: Text(formatter.string(from: section[0].date ?? Date()))) {
ForEach(section, id: \.self) { history in
ForEach(history.entryArray) { entry in
HistoryButtonView(entry: entry)
}
.onDelete { offsets in
removeEntry(at: offsets, from: history)
}
}
}
}
}
.listStyle(.insetGrouped)
}
}
func removeEntry(at offsets: IndexSet, from history: History) {
for index in offsets {
if let entry = history.entryArray[safe: index] {
history.removeFromEntries(entry)
PersistenceController.shared.delete(entry, context: backgroundContext)
}
if history.entryArray.isEmpty {
PersistenceController.shared.delete(history, context: backgroundContext)
}
}
}
}

View file

@ -12,7 +12,11 @@ struct WebView: UIViewRepresentable {
var url: URL
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
// Make the WebView ephemeral
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
let _ = webView.load(URLRequest(url: url))
return webView
}

View file

@ -1,134 +0,0 @@
//
// SearchResultButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct SearchResultButtonView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
var result: SearchResult
@State private var runOnce = false
@State var existingBookmark: Bookmark? = nil
var body: some View {
Button {
if debridManager.currentDebridTask == nil {
navModel.selectedSearchResult = result
navModel.selectedTitle = result.title
switch debridManager.matchSearchResult(result: result) {
case .full:
if debridManager.setSelectedRdResult(result: result) {
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: result)
if !debridManager.realDebridDownloadUrl.isEmpty {
navModel.addToHistory(name: result.title, source: result.source, url: debridManager.realDebridDownloadUrl)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl)
if navModel.currentChoiceSheet != .magnet {
debridManager.realDebridDownloadUrl = ""
}
}
}
}
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navModel.currentChoiceSheet = .batch
}
case .none:
navModel.addToHistory(name: result.title, source: result.source, url: result.magnetLink)
navModel.runMagnetAction(magnetString: result.magnetLink)
}
}
} label: {
VStack(alignment: .leading, spacing: 10) {
Text(result.title ?? "No title")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(4)
SearchResultRDView(result: result)
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
}
.disableInteraction(navModel.currentChoiceSheet != nil)
.backport.tint(.primary)
.conditionalContextMenu(id: existingBookmark) {
if let bookmark = existingBookmark {
Button {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
// When the entity is deleted, let other instances know to remove that reference
NotificationCenter.default.post(name: .didDeleteBookmark, object: nil)
} label: {
Text("Remove bookmark")
Image(systemName: "bookmark.slash.fill")
}
} else {
Button {
let newBookmark = Bookmark(context: backgroundContext)
newBookmark.title = result.title
newBookmark.source = result.source
newBookmark.magnetHash = result.magnetHash
newBookmark.magnetLink = result.magnetLink
newBookmark.seeders = result.seeders
newBookmark.leechers = result.leechers
existingBookmark = newBookmark
PersistenceController.shared.save(backgroundContext)
} label: {
Text("Bookmark")
Image(systemName: "bookmark")
}
}
}
.backport.alert(
isPresented: $debridManager.showDeleteAlert,
title: "Caching file",
message: "RealDebrid is currently caching this file. Would you like to delete it? \n\nProgress can be checked on the RealDebrid website.",
buttons: [
AlertButton("Yes", role: .destructive) {
Task {
await debridManager.deleteRdTorrent()
}
},
AlertButton(role: .cancel)
]
)
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { _ in
existingBookmark = nil
}
.onAppear {
// Only run a exists request if a bookmark isn't passed to the view
if existingBookmark == nil, !runOnce {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "title == %@ AND source == %@ AND magnetLink == %@ AND magnetHash = %@",
result.title ?? "",
result.source,
result.magnetLink ?? "",
result.magnetHash ?? ""
)
bookmarkRequest.fetchLimit = 1
if let fetchedBookmark = try? backgroundContext.fetch(bookmarkRequest).first {
existingBookmark = fetchedBookmark
}
runOnce = true
}
}
}
}

View file

@ -1,57 +0,0 @@
//
// SearchResultRDView.swift
// Ferrite
//
// Created by Brian Dashore on 7/26/22.
//
import SwiftUI
struct SearchResultRDView: View {
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
var result: SearchResult
var body: some View {
HStack {
Text(result.source)
Spacer()
if let seeders = result.seeders {
Text("S: \(seeders)")
}
if let leechers = result.leechers {
Text("L: \(leechers)")
}
if let size = result.size {
Text(size)
}
if realDebridEnabled {
Text("RD")
.fontWeight(.bold)
.padding(2)
.background {
Group {
switch debridManager.matchSearchResult(result: result) {
case .full:
Color.green
case .partial:
Color.orange
case .none:
Color.red
}
}
.cornerRadius(4)
.opacity(0.5)
}
}
}
.font(.caption)
}
}

View file

@ -10,6 +10,7 @@ import SwiftUI
struct SearchResultsView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
var body: some View {
List {

View file

@ -5,6 +5,7 @@
// Created by Brian Dashore on 7/11/22.
//
import BetterSafariView
import Introspect
import SwiftUI
@ -14,6 +15,8 @@ struct SettingsView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@ -24,24 +27,61 @@ struct SettingsView: View {
Form {
Section(header: InlineHeader("Debrid Services")) {
HStack {
Text("Real Debrid")
Text("RealDebrid")
Spacer()
Button {
Task {
if debridManager.realDebridEnabled {
await debridManager.logoutRd()
if debridManager.enabledDebrids.contains(.realDebrid) {
await debridManager.logoutDebrid(debridType: .realDebrid)
} else if !debridManager.realDebridAuthProcessing {
await debridManager.authenticateRd()
await debridManager.authenticateDebrid(debridType: .realDebrid)
}
}
} label: {
Text(debridManager.realDebridEnabled ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login"))
.foregroundColor(debridManager.realDebridEnabled ? .red : .blue)
.onChange(of: debridManager.realDebridEnabled) { changed in
print("Debrid enabled changed to \(changed)")
}
Text(debridManager.enabledDebrids.contains(.realDebrid) ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login"))
.foregroundColor(debridManager.enabledDebrids.contains(.realDebrid) ? .red : .blue)
}
}
HStack {
Text("AllDebrid")
Spacer()
Button {
Task {
if debridManager.enabledDebrids.contains(.allDebrid) {
await debridManager.logoutDebrid(debridType: .allDebrid)
} else if !debridManager.allDebridAuthProcessing {
await debridManager.authenticateDebrid(debridType: .allDebrid)
}
}
} label: {
Text(debridManager.enabledDebrids.contains(.allDebrid) ? "Logout" : (debridManager.allDebridAuthProcessing ? "Processing" : "Login"))
.foregroundColor(debridManager.enabledDebrids.contains(.allDebrid) ? .red : .blue)
}
}
HStack {
Text("Premiumize")
Spacer()
Button {
Task {
if debridManager.enabledDebrids.contains(.premiumize) {
await debridManager.logoutDebrid(debridType: .premiumize)
} else if !debridManager.premiumizeAuthProcessing {
await debridManager.authenticateDebrid(debridType: .premiumize)
}
}
} label: {
Text(debridManager.enabledDebrids.contains(.premiumize) ? "Logout" : (debridManager.premiumizeAuthProcessing ? "Processing" : "Login"))
.foregroundColor(debridManager.enabledDebrids.contains(.premiumize) ? .red : .blue)
}
}
}
Section(header: Text("Behavior")) {
Toggle(isOn: $autocorrectSearch) {
Text("Autocorrect search")
}
}
Section(header: Text("Source management")) {
@ -49,7 +89,7 @@ struct SettingsView: View {
}
Section(header: Text("Default actions")) {
if debridManager.realDebridEnabled {
if debridManager.enabledDebrids.count > 0 {
NavigationLink(
destination: DebridActionPickerView(),
label: {
@ -118,7 +158,18 @@ struct SettingsView: View {
}
}
.sheet(isPresented: $debridManager.showWebView) {
LoginWebView(url: URL(string: debridManager.realDebridAuthUrl)!)
LoginWebView(url: debridManager.authUrl ?? URL(string: "https://google.com")!)
}
.webAuthenticationSession(isPresented: $debridManager.showAuthSession) {
WebAuthenticationSession(
url: debridManager.authUrl ?? URL(string: "https://google.com")!,
callbackURLScheme: "ferrite"
) { callbackURL, error in
Task {
await debridManager.handleCallback(url: callbackURL, error: error)
}
}
.prefersEphemeralWebBrowserSession(true)
}
.navigationTitle("Settings")
}

View file

@ -0,0 +1,99 @@
//
// BatchChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
struct BatchChoiceView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
var body: some View {
NavView {
List {
switch debridManager.selectedDebridType {
case .realDebrid:
ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in
Button(file.name) {
debridManager.selectedRealDebridFile = file
queueCommonDownload(fileName: file.name)
}
}
case .allDebrid:
ForEach(debridManager.selectedAllDebridItem?.files ?? [], id: \.self) { file in
Button(file.fileName) {
debridManager.selectedAllDebridFile = file
queueCommonDownload(fileName: file.fileName)
}
}
case .premiumize:
ForEach(debridManager.selectedPremiumizeItem?.files ?? [], id: \.self) { file in
Button(file.name) {
debridManager.selectedPremiumizeFile = file
queueCommonDownload(fileName: file.name)
}
}
case .none:
EmptyView()
}
}
.backport.tint(.primary)
.listStyle(.insetGrouped)
.inlinedList()
.navigationTitle("Select a file")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
navModel.currentChoiceSheet = nil
Task {
try? await Task.sleep(seconds: 1)
debridManager.clearSelectedDebridItems()
}
}
}
}
}
}
// 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.downloadUrl.isEmpty {
try? await Task.sleep(seconds: 1)
navModel.selectedBatchTitle = fileName
if var selectedHistoryInfo = navModel.selectedHistoryInfo {
selectedHistoryInfo.url = debridManager.downloadUrl
selectedHistoryInfo.subName = fileName
PersistenceController.shared.createHistory(selectedHistoryInfo)
}
navModel.runDebridAction(urlString: debridManager.downloadUrl)
}
debridManager.clearSelectedDebridItems()
}
navModel.currentChoiceSheet = nil
}
}
struct BatchChoiceView_Previews: PreviewProvider {
static var previews: some View {
BatchChoiceView()
}
}

View file

@ -25,34 +25,34 @@ struct MagnetChoiceView: View {
Form {
Section(header: "Now Playing") {
VStack(alignment: .leading, spacing: 5) {
Text(navModel.selectedTitle ?? "No title")
Text(navModel.selectedTitle)
.font(.callout)
.lineLimit(navModel.selectedBatchTitle == nil ? .max : 1)
.lineLimit(navModel.selectedBatchTitle.isEmpty ? .max : 1)
if let batchTitle = navModel.selectedBatchTitle {
Text(batchTitle)
if !navModel.selectedBatchTitle.isEmpty {
Text(navModel.selectedBatchTitle)
.foregroundColor(.gray)
.font(.subheadline)
}
}
}
if !debridManager.realDebridDownloadUrl.isEmpty {
Section(header: "Real Debrid options") {
if !debridManager.downloadUrl.isEmpty {
Section(header: "Debrid options") {
ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .outplayer)
navModel.runDebridAction(urlString: debridManager.downloadUrl, .outplayer)
}
ListRowButtonView("Play on VLC", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .vlc)
navModel.runDebridAction(urlString: debridManager.downloadUrl, .vlc)
}
ListRowButtonView("Play on Infuse", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .infuse)
navModel.runDebridAction(urlString: debridManager.downloadUrl, .infuse)
}
ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = debridManager.realDebridDownloadUrl
UIPasteboard.general.string = debridManager.downloadUrl
showLinkCopyAlert.toggle()
}
.backport.alert(
@ -63,7 +63,7 @@ struct MagnetChoiceView: View {
)
ListRowButtonView("Share download URL", systemImage: "square.and.arrow.up.fill") {
if let url = URL(string: debridManager.realDebridDownloadUrl) {
if let url = URL(string: debridManager.downloadUrl) {
navModel.activityItems = [url]
navModel.showLocalActivitySheet.toggle()
}
@ -71,30 +71,31 @@ struct MagnetChoiceView: View {
}
}
Section(header: "Magnet options") {
ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = navModel.selectedSearchResult?.magnetLink
showMagnetCopyAlert.toggle()
}
.backport.alert(
isPresented: $showMagnetCopyAlert,
title: "Copied",
message: "Magnet link copied successfully",
buttons: [AlertButton("OK")]
)
ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") {
if let result = navModel.selectedSearchResult,
let magnetLink = result.magnetLink,
let url = URL(string: magnetLink)
{
navModel.activityItems = [url]
navModel.showLocalActivitySheet.toggle()
if !navModel.resultFromCloud {
Section(header: "Magnet options") {
ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = navModel.selectedMagnet?.link
showMagnetCopyAlert.toggle()
}
}
.backport.alert(
isPresented: $showMagnetCopyAlert,
title: "Copied",
message: "Magnet link copied successfully",
buttons: [AlertButton("OK")]
)
ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") {
navModel.runMagnetAction(magnetString: navModel.selectedSearchResult?.magnetLink, .webtor)
ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") {
if let magnetLink = navModel.selectedMagnet?.link,
let url = URL(string: magnetLink)
{
navModel.activityItems = [url]
navModel.showLocalActivitySheet.toggle()
}
}
ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") {
navModel.runMagnetAction(magnet: navModel.selectedMagnet, .webtor)
}
}
}
}
@ -108,14 +109,19 @@ struct MagnetChoiceView: View {
}
}
.onDisappear {
debridManager.realDebridDownloadUrl = ""
debridManager.downloadUrl = ""
navModel.selectedTitle = ""
navModel.selectedBatchTitle = ""
navModel.resultFromCloud = false
}
.navigationTitle("Link actions")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
debridManager.realDebridDownloadUrl = ""
debridManager.downloadUrl = ""
navModel.selectedTitle = ""
navModel.selectedBatchTitle = ""
presentationMode.wrappedValue.dismiss()
}

View file

@ -15,13 +15,11 @@ struct SourcesView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var checkedForSources = false
@State private var isEditing = false
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var viewTask: Task<Void, Never>? = nil
@State private var searchText: String = ""
@ -35,7 +33,7 @@ struct SourcesView: View {
ZStack {
if !checkedForSources {
ProgressView()
} else if sources.isEmpty, sourceManager.availableSources.isEmpty {
} else if installedSources.isEmpty, sourceManager.availableSources.isEmpty {
EmptyInstructionView(title: "No Sources", message: "Add a source list in Settings")
} else {
List {
@ -119,11 +117,18 @@ struct SourcesView: View {
}
.navigationTitle("Sources")
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditing)
.showsCancelButton(isEditing)
.onCancel {
searchText = ""
}
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
}
}

View file

@ -14,27 +14,39 @@ Finding shows and movies is usually pretty easy because there are many websites
However, the main problem is that these websites tend to suck in terms of UI or finding media to watch. Ferrite aims to provide a better UI to search and find the media you want.
I also wanted to support the use of RealDebrid since there aren't any (free) options on iOS that have support for this service.
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.
## What iOS versions are supported?
iOS 14 and up. I was able to successfully backport the app!
- v0.7 and up: iOS 15 and up
- v0.6.x and lower: iOS 14 and up
## Planned features
- Website API support in sources: This allows for website APIs to be used in Ferrite sources which is quicker than scraping or RSS parsing
More of these can be found in [issues](https://github.com/bdashore3/Ferrite/issues), but here is a small snippet:
- A decentralized actions API: Allows for playback on other devices
- More involved search filtering
- Companion apps for playback on other devices
## Downloads
Ferrite will only exist as an ipa. There are and 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.
## 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.
## Building from source
Xcode 14 must be used since Ferrite requires some iOS 16 APIs that are not present in Xcode 13. Please make sure you have the right Xcode or download the beta xip from Apple's developer website.
Xcode 14 must be used.
There is currently one branch in the repository:
There are currently two branches in the repository:
- default: The current working branch. This will change in the future once a stable version is released.
- default: A snapshot of the latest stable build. Tags can also be used for older versions.
- next: The development branch. Nightlies are automatically built here.
## Nightly builds