Compare commits

..

No commits in common. "next" and "v0.6.1" have entirely different histories.
next ... v0.6.1

157 changed files with 3892 additions and 6956 deletions

View file

@ -6,13 +6,13 @@ on:
jobs:
build:
runs-on: macos-14
runs-on: macos-12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
xcode-version: latest
- name: Get commit SHA
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build
@ -25,7 +25,7 @@ jobs:
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa

View file

@ -7,13 +7,13 @@ on:
jobs:
build:
runs-on: macos-14
runs-on: macos-12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
xcode-version: latest
- name: Build
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
env:
@ -30,7 +30,7 @@ jobs:
run: |
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
- name: Upload release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip

View file

@ -1 +1 @@
5.8
5.7

View file

@ -12,26 +12,20 @@
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; };
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; };
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; };
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginListView.swift */; };
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; };
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; };
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; };
0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */; };
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; };
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; };
0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C2DD4CE29A6D47400293FC3 /* SwiftUIX */; };
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 */; };
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; };
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */; };
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */; };
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */; };
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */; };
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; };
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */; };
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */; };
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */; };
@ -39,8 +33,10 @@
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 /* DebridPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridPickerView.swift */; };
0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; };
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.swift */; };
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 0C448BE829A135F100F4E266 /* Introspect-Static */; };
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 */; };
@ -53,24 +49,19 @@
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C50B7CF299DF63C00A9FA3C /* UIDevice.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 */; };
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; };
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */; };
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */; };
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; };
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */; };
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */; };
0C6771FC29B3E0DB005D38D2 /* HybridSecureField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */; };
0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */; };
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; };
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; };
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
0C7075E429D374C50093DB2D /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E329D374C50093DB2D /* Color.swift */; };
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E529D3845D0093DB2D /* ShareSheet.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 */; };
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 0C748ED929D9256D0049B8BE /* Yams */; };
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; };
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; };
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; };
@ -80,7 +71,6 @@
0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; };
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; };
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; };
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */; };
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; };
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; };
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; };
@ -90,20 +80,7 @@
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; };
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */; };
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */; };
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */; };
0C84FCE329E4B42600B0DFE4 /* IAFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */; };
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */; };
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; };
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; };
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */; };
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */; };
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; };
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */; };
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */; };
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */; };
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; };
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; };
@ -111,6 +88,7 @@
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */; };
0CA148DB288903F000DE2211 /* NavView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C1288903F000DE2211 /* NavView.swift */; };
0CA148DC288903F000DE2211 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C2288903F000DE2211 /* Assets.xcassets */; };
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; };
0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; };
@ -120,7 +98,7 @@
0CA148E3288903F000DE2211 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CB288903F000DE2211 /* Task.swift */; };
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CD288903F000DE2211 /* DebridManager.swift */; };
0CA148E6288903F000DE2211 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CE288903F000DE2211 /* WebView.swift */; };
0CA148E7288903F000DE2211 /* LoggingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* LoggingManager.swift */; };
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* ToastViewModel.swift */; };
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */; };
0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; };
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; };
@ -132,35 +110,27 @@
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 */; };
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */; };
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */; };
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 */; };
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */; };
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.swift */; };
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; };
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; };
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; };
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */; };
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; };
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; };
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */; };
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; };
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */; };
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */; };
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FC299C083B00476DDB /* PluginPickerView.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 */; };
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; };
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; };
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; };
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */; };
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */; };
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; };
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; };
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -169,26 +139,19 @@
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.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>"; };
0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = "<group>"; };
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = "<group>"; };
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = "<group>"; };
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListView.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>"; };
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = "<group>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortFilterView.swift; sourceTree = "<group>"; };
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; };
0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.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>"; };
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiEditorView.swift; sourceTree = "<group>"; };
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataClass.swift"; sourceTree = "<group>"; };
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = "<group>"; };
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionModels.swift; sourceTree = "<group>"; };
0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsView.swift; sourceTree = "<group>"; };
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginModels.swift; sourceTree = "<group>"; };
@ -197,6 +160,7 @@
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 /* DebridPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridPickerView.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>"; };
@ -210,19 +174,15 @@
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.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>"; };
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; };
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppearHandler.swift; sourceTree = "<group>"; };
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; };
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; };
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; };
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HybridSecureField.swift; sourceTree = "<group>"; };
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDebridInfoView.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>"; };
0C7075E329D374C50093DB2D /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
0C7075E529D3845D0093DB2D /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.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>"; };
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
@ -242,20 +202,7 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceFilterView.swift; sourceTree = "<group>"; };
0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAFilterView.swift; sourceTree = "<group>"; };
0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedDebridFilterView.swift; sourceTree = "<group>"; };
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = "<group>"; };
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAmountLabelView.swift; sourceTree = "<group>"; };
0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = "<group>"; };
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxWrapper.swift; sourceTree = "<group>"; };
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxModels.swift; sourceTree = "<group>"; };
0C8AE2472C0FFB6600701675 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoView.swift; sourceTree = "<group>"; };
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsBaseUrlView.swift; sourceTree = "<group>"; };
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsApiView.swift; sourceTree = "<group>"; };
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsMethodView.swift; sourceTree = "<group>"; };
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDebridLinkView.swift; sourceTree = "<group>"; };
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionPickerView.swift; sourceTree = "<group>"; };
0CA05456288EE58200850554 /* SettingsPluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPluginListView.swift; sourceTree = "<group>"; };
0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
@ -263,6 +210,7 @@
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; };
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionChoiceView.swift; sourceTree = "<group>"; };
0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = "<group>"; };
0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = "<group>"; };
0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
@ -272,7 +220,7 @@
0CA148CB288903F000DE2211 /* Task.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
0CA148CD288903F000DE2211 /* DebridManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebridManager.swift; sourceTree = "<group>"; };
0CA148CE288903F000DE2211 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
0CA148CF288903F000DE2211 /* LoggingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingManager.swift; sourceTree = "<group>"; };
0CA148CF288903F000DE2211 /* ToastViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = "<group>"; };
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealDebridWrapper.swift; sourceTree = "<group>"; };
0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -284,35 +232,27 @@
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; };
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = "<group>"; };
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>"; };
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDownloadView.swift; sourceTree = "<group>"; };
0CB725332C123E760047FC0B /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.swift; sourceTree = "<group>"; };
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = "<group>"; };
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = "<group>"; };
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedSearchable.swift; sourceTree = "<group>"; };
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; };
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerriteKeychain.swift; sourceTree = "<group>"; };
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoMetaView.swift; sourceTree = "<group>"; };
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoAboutView.swift; 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>"; };
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScopeBar.swift; sourceTree = "<group>"; };
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginPickerView.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>"; };
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = "<group>"; };
0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = "<group>"; };
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataClass.swift"; sourceTree = "<group>"; };
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debrid.swift; sourceTree = "<group>"; };
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridModels.swift; sourceTree = "<group>"; };
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -321,13 +261,13 @@
buildActionMask = 2147483647;
files = (
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */,
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */,
0C64A4B4288903680079976D /* Base32 in Frameworks */,
0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */,
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */,
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -346,7 +286,6 @@
0C0755C32934244500ECA142 /* ComponentViews */ = {
isa = PBXGroup;
children = (
0C84FCDB29E4B3F400B0DFE4 /* Filters */,
0C3E00D4296F560800ECECB2 /* Plugin */,
0C0755C42934245800ECA142 /* Debrid */,
0CA3B23528C265FD00616D3A /* Library */,
@ -359,6 +298,7 @@
0C0755C42934245800ECA142 /* Debrid */ = {
isa = PBXGroup;
children = (
0C42B5952932F2D5008057A0 /* DebridPickerView.swift */,
0C0755C5293424A200ECA142 /* DebridLabelView.swift */,
);
path = Debrid;
@ -367,8 +307,6 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */,
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */,
0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */,
0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */,
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */,
@ -391,8 +329,6 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */,
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */,
);
path = Classes;
sourceTree = "<group>";
@ -404,18 +340,12 @@
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */,
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */,
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
);
path = Models;
sourceTree = "<group>";
@ -423,63 +353,43 @@
0C2886D52960C4F800D6FC16 /* Cloud */ = {
isa = PBXGroup;
children = (
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
0CB725332C123E760047FC0B /* CloudMagnetView.swift */,
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */,
0CAF9318296399190050812A /* PremiumizeCloudView.swift */,
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */,
);
path = Cloud;
sourceTree = "<group>";
};
0C3DD43C29B69664006429DB /* Kodi */ = {
isa = PBXGroup;
children = (
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */,
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */,
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */,
);
path = Kodi;
sourceTree = "<group>";
};
0C3DD43D29B69672006429DB /* PluginList */ = {
isa = PBXGroup;
children = (
0CA05456288EE58200850554 /* SettingsPluginListView.swift */,
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
);
path = PluginList;
sourceTree = "<group>";
};
0C3E00D4296F560800ECECB2 /* Plugin */ = {
isa = PBXGroup;
children = (
0CD4030829DA01A3008D9F03 /* Info */,
0C44E2AA28D4E09B007711AE /* Buttons */,
0C794B65289DAC9F00DD1CC8 /* Source */,
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */,
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */,
0C5005512992B6750064606A /* PluginTagsView.swift */,
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */,
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */,
);
path = Plugin;
sourceTree = "<group>";
};
0C44E2A628D4DDC6007711AE /* Utils */ = {
0C44E2A628D4DDC6007711AE /* Classes */ = {
isa = PBXGroup;
children = (
0C44E2A728D4DDDC007711AE /* Application.swift */,
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */,
0C8AE2472C0FFB6600701675 /* Store.swift */,
0C07C6032C1A859B00808A46 /* FormDataBody.swift */,
);
path = Utils;
path = Classes;
sourceTree = "<group>";
};
0C44E2A928D4DFC4007711AE /* Modifiers */ = {
isa = PBXGroup;
children = (
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */,
0CB6516228C5A57300DCA721 /* ConditionalId.swift */,
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */,
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */,
);
path = Modifiers;
sourceTree = "<group>";
@ -499,7 +409,6 @@
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */,
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */,
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */,
);
path = SearchResult;
sourceTree = "<group>";
@ -508,7 +417,6 @@
isa = PBXGroup;
children = (
0CE1C4172981E8D700418F20 /* Plugin.swift */,
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */,
);
path = Protocols;
sourceTree = "<group>";
@ -517,37 +425,18 @@
isa = PBXGroup;
children = (
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */,
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */,
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */,
);
path = Source;
sourceTree = "<group>";
};
0C84FCDB29E4B3F400B0DFE4 /* Filters */ = {
isa = PBXGroup;
children = (
0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */,
0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */,
0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */,
0C871BDE29994D9D005279AC /* FilterLabelView.swift */,
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */,
0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */,
);
path = Filters;
sourceTree = "<group>";
};
0CA0545C288F7CB200850554 /* Settings */ = {
isa = PBXGroup;
children = (
0C3DD43D29B69672006429DB /* PluginList */,
0C3DD43C29B69664006429DB /* Kodi */,
0C44E2AE28D52E8A007711AE /* BackupsView.swift */,
0CA05456288EE58200850554 /* SettingsPluginListView.swift */,
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */,
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -561,7 +450,7 @@
0C0D50E3288DFE6E0035ECC8 /* Models */,
0CA148EF2889061600DE2211 /* ViewModels */,
0CA148EE2889061200DE2211 /* Views */,
0C44E2A628D4DDC6007711AE /* Utils */,
0C44E2A628D4DDC6007711AE /* Classes */,
0C5005552992B9C20064606A /* Protocols */,
0CA148C8288903F000DE2211 /* Extensions */,
0CA148C5288903F000DE2211 /* Preview Content */,
@ -576,11 +465,16 @@
isa = PBXGroup;
children = (
0C44E2A928D4DFC4007711AE /* Modifiers */,
0CE66B3928E640D200F69346 /* Backport.swift */,
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */,
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */,
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
0C871BDE29994D9D005279AC /* FilterLabelView.swift */,
0CA148C1288903F000DE2211 /* NavView.swift */,
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
0C2D9652299316CC00A504B6 /* Tag.swift */,
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */,
);
path = CommonViews;
sourceTree = "<group>";
@ -599,7 +493,6 @@
0CD72E16293D9928001A7EA4 /* Array.swift */,
0C445C61293F9A0B0060744D /* Bundle.swift */,
0CA148C9288903F000DE2211 /* Collection.swift */,
0C7075E329D374C50093DB2D /* Color.swift */,
0CA148CA288903F000DE2211 /* Data.swift */,
0CA429F728C5098D000D0610 /* DateFormatter.swift */,
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */,
@ -607,11 +500,9 @@
0CA148CB288903F000DE2211 /* Task.swift */,
0C7D11FD28AA03FE00ED92DB /* View.swift */,
0C7ED14228D65518009E29AD /* FileManager.swift */,
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */,
0C42B5972932F6DD008057A0 /* Set.swift */,
0C7C128528DAA3CD00381CD1 /* URL.swift */,
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */,
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -639,7 +530,7 @@
children = (
0CA148CD288903F000DE2211 /* DebridManager.swift */,
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */,
0CA148CF288903F000DE2211 /* LoggingManager.swift */,
0CA148CF288903F000DE2211 /* ToastViewModel.swift */,
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */,
0CA05458288EE9E600850554 /* PluginManager.swift */,
0C44E2AC28D51C63007711AE /* BackupManager.swift */,
@ -651,8 +542,7 @@
isa = PBXGroup;
children = (
0CA148CE288903F000DE2211 /* WebView.swift */,
0C7075E529D3845D0093DB2D /* ShareSheet.swift */,
0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */,
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */,
);
path = RepresentableViews;
sourceTree = "<group>";
@ -664,9 +554,6 @@
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */,
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
);
path = API;
sourceTree = "<group>";
@ -711,15 +598,6 @@
path = DataManagement;
sourceTree = "<group>";
};
0CD4030829DA01A3008D9F03 /* Info */ = {
isa = PBXGroup;
children = (
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */,
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */,
);
path = Info;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -744,8 +622,8 @@
0C4CFC452897030D00AD9FAD /* Regex */,
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
0CDDDE042935235E006810B1 /* BetterSafariView */,
0C748ED929D9256D0049B8BE /* Yams */,
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */,
0C448BE829A135F100F4E266 /* Introspect-Static */,
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */,
);
productName = Torrenter;
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
@ -759,7 +637,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1400;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 1400;
TargetAttributes = {
0CAF1C67286F5C0E00296F86 = {
CreatedOnToolsVersion = 14.0;
@ -782,8 +660,8 @@
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */,
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */,
);
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
projectDirPath = "";
@ -835,81 +713,74 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */,
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */,
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */,
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */,
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */,
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */,
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */,
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */,
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */,
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */,
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */,
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */,
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */,
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */,
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */,
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* PluginListView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */,
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */,
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */,
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */,
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */,
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.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 /* ActionChoiceView.swift in Sources */,
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
0CA148E3288903F000DE2211 /* Task.swift in Sources */,
0CA148E7288903F000DE2211 /* LoggingManager.swift in Sources */,
0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */,
0C6771FC29B3E0DB005D38D2 /* HybridSecureField.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 */,
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
@ -920,58 +791,35 @@
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */,
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */,
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */,
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */,
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */,
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */,
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */,
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */,
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */,
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */,
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */,
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */,
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */,
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */,
0C7075E429D374C50093DB2D /* Color.swift in Sources */,
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
0C84FCE329E4B42600B0DFE4 /* IAFilterView.swift in Sources */,
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */,
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */,
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */,
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */,
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */,
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */,
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */,
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */,
@ -988,7 +836,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@ -1021,7 +868,6 @@
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@ -1036,14 +882,13 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = minimal;
};
name = Debug;
};
@ -1051,7 +896,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@ -1084,7 +928,6 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@ -1093,13 +936,12 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = minimal;
VALIDATE_PRODUCT = YES;
};
name = Release;
@ -1110,11 +952,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
@ -1124,13 +965,13 @@
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
INFOPLIST_KEY_UISupportsDocumentBrowser = NO;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.3;
MARKETING_VERSION = 0.6.1;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -1146,11 +987,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
@ -1160,13 +1000,13 @@
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
INFOPLIST_KEY_UISupportsDocumentBrowser = NO;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.3;
MARKETING_VERSION = 0.6.1;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -1200,6 +1040,22 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftUIX/SwiftUIX";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.1.3;
};
};
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect/";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.2.2;
};
};
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/Regex";
@ -1224,14 +1080,6 @@
kind = branch;
};
};
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jpsim/Yams";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 5.0.5;
};
};
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON";
@ -1240,14 +1088,6 @@
kind = branch;
};
};
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/swiftui-introspect";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
};
};
0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
@ -1267,6 +1107,16 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */ = {
isa = XCSwiftPackageProductDependency;
package = 0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */;
productName = SwiftUIX;
};
0C448BE829A135F100F4E266 /* Introspect-Static */ = {
isa = XCSwiftPackageProductDependency;
package = 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = "Introspect-Static";
};
0C4CFC452897030D00AD9FAD /* Regex */ = {
isa = XCSwiftPackageProductDependency;
package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */;
@ -1282,21 +1132,11 @@
package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
0C748ED929D9256D0049B8BE /* Yams */ = {
isa = XCSwiftPackageProductDependency;
package = 0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams;
};
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = {
isa = XCSwiftPackageProductDependency;
package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
productName = SwiftyJSON;
};
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */ = {
isa = XCSwiftPackageProductDependency;
package = 0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
productName = SwiftUIIntrospect;
};
0CAF1C7A286F5C8600296F86 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
LastUpgradeVersion = "1400"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -6,89 +6,43 @@
//
import Foundation
import KeychainSwift
class AllDebrid: PollingDebridSource, ObservableObject {
let id = "AllDebrid"
let abbreviation = "AD"
let website = "https://alldebrid.com"
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " +
"You must pay to access this service. \n\n" +
"It is not recommended to use this service since media cache checks are not possible via the API. " +
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
// TODO: Fix errors
public class AllDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
let baseApiUrl = "https://api.alldebrid.com/v4"
let appName = "Ferrite"
let cachedStatus: [String] = ["Ready"]
var authTask: Task<Void, Error>?
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") {
return getToken()
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseApiUrl = "https://api.alldebrid.com/v4"
private let appName = "Ferrite"
private let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth
// Fetches information for PIN auth
func getAuthUrl() async throws -> URL {
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)
// Validate the URL before doing anything else
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
guard let userUrl = URL(string: rawResponse.userURL) else {
throw DebridError.AuthQuery(description: "The login URL is invalid")
}
// Spawn the polling task separately
authTask = Task {
try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin)
}
return userUrl
return rawResponse
} catch {
print("Couldn't get pin information!")
throw DebridError.AuthQuery(description: error.localizedDescription)
throw ADError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches API keys
func getApiKey(checkID: String, pin: String) async throws {
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 = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
let request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
// Timer to poll AD API for key
authTask = Task {
@ -96,7 +50,7 @@ class AllDebrid: PollingDebridSource, ObservableObject {
while count < 12 {
if Task.isCancelled {
throw DebridError.AuthQuery(description: "Token request cancelled.")
throw ADError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
@ -106,7 +60,7 @@ class AllDebrid: PollingDebridSource, ObservableObject {
// If there's an API key from the response, end the task successfully
if let apiKeyResponse = rawResponse {
FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
return
} else {
@ -115,7 +69,7 @@ class AllDebrid: PollingDebridSource, ObservableObject {
}
}
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
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 {
@ -123,28 +77,15 @@ class AllDebrid: PollingDebridSource, ObservableObject {
}
}
// Adds a manual API key instead of web auth
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
}
func getToken() -> String? {
FerriteKeychain.shared.get("AllDebrid.ApiKey")
}
// Clears tokens. No endpoint to deregister a device
func logout() {
FerriteKeychain.shared.delete("AllDebrid.ApiKey")
UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey")
public func deleteTokens() {
keychain.delete("AllDebrid.ApiKey")
}
// MARK: - Common request
// Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = getToken() else {
throw DebridError.InvalidToken
guard let token = keychain.get("AllDebrid.ApiKey") else {
throw ADError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -152,22 +93,23 @@ class AllDebrid: PollingDebridSource, ObservableObject {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw DebridError.FailedRequest(description: "No HTTP response given")
throw ADError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
deleteTokens()
throw ADError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
} else {
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Builds a URL for further requests
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: urlString) else {
throw DebridError.InvalidUrl
throw ADError.InvalidUrl
}
components.queryItems = [
@ -177,104 +119,17 @@ class AllDebrid: PollingDebridSource, ObservableObject {
if let url = components.url {
return url
} else {
throw DebridError.InvalidUrl
}
}
// MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
let sendMagnets = magnets.filter { magnet in
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
// Fetch the user magnets to the latest version
try await getUserMagnets()
for cloudMagnet in cloudMagnets {
if cachedStatus.contains(cloudMagnet.status),
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
{
IAValues.append(
DebridIA(
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: []
)
)
}
}
}
// MARK: - Downloading
// Wrapper function to fetch a download link from the API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
let selectedMagnetId: String
if let existingMagnet = cloudMagnets.first(where: {
$0.hash == magnet.hash && cachedStatus.contains($0.status)
}) {
selectedMagnetId = existingMagnet.id
} else {
let magnetId = try await addMagnet(magnet: magnet)
selectedMagnetId = String(magnetId)
}
let rawResponse = try await fetchMagnetStatus(
magnetId: selectedMagnetId,
selectedIndex: iaFile?.id ?? 0
)
guard let magnets = rawResponse.magnets[safe: 0] else {
throw DebridError.EmptyUserMagnets
}
// Batches require an unrestrict from the user
if magnets.links.count > 1, iaFile == nil {
var copiedIA = ia
copiedIA?.files = magnets.links.enumerated().compactMap { index, file in
DebridIAFile(
id: index,
name: file.filename,
streamUrlString: file.link
)
}
return (nil, copiedIA)
}
if let cloudMagnetFile = magnets.links[safe: iaFile?.id ?? 0] {
let restrictedFile = DebridIAFile(
id: 0,
name: cloudMagnetFile.filename,
streamUrlString: cloudMagnetFile.link
)
return (restrictedFile, nil)
} else {
throw DebridError.EmptyUserMagnets
throw ADError.InvalidUrl
}
}
// Adds a magnet link to the user's AD account
func addMagnet(magnet: Magnet) async throws -> Int {
public func addMagnet(magnet: Magnet) async throws -> Int {
guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid")
throw ADError.FailedRequest(description: "The magnet link is invalid")
}
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -289,108 +144,84 @@ class AllDebrid: PollingDebridSource, ObservableObject {
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
if let magnet = rawResponse.magnets[safe: 0] {
if !magnet.ready {
throw DebridError.IsCaching
}
return magnet.id
} else {
throw DebridError.InvalidResponse
throw ADError.InvalidResponse
}
}
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String {
let queryItems = [
URLQueryItem(name: "id", value: magnetId)
URLQueryItem(name: "id", value: String(magnetId))
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
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
return rawResponse
// 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
}
}
// Known as unlockLink in AD's API
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
let queryItems = [
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
public func userMagnets() async throws -> [MagnetStatusData] {
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
let data = try await performRequest(request: &request, requestName: "unlockLink")
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
}
func saveLink(link: String) async throws {
let queryItems = [
URLQueryItem(name: "links[]", value: link)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
// MARK: - Cloud methods
func getUserMagnets() async throws {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
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<MagnetStatusResponse>.self, from: data).data
let rawResponse = try jsonDecoder.decode(ADResponse<InstantAvailabilityResponse>.self, from: data).data
cloudMagnets = rawResponse.magnets.map { magnetResponse in
DebridCloudMagnet(
id: String(magnetResponse.id),
fileName: magnetResponse.filename,
status: magnetResponse.status,
hash: magnetResponse.hash,
links: magnetResponse.links.map(\.link)
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
)
}
}
func deleteUserMagnet(cloudMagnetId: String?) async throws {
guard let cloudMagnetId else {
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid")
}
let queryItems = [
URLQueryItem(name: "id", value: cloudMagnetId)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
func getUserDownloads() async throws {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
// The link is also the ID
cloudDownloads = rawResponse.links.map { link in
DebridCloudDownload(
id: link.link, fileName: link.filename, link: link.link
)
}
}
// Not used
func checkUserDownloads(link: String) -> String? {
link
}
// The downloadId is actually the download link
func deleteUserDownload(downloadId: String) async throws {
let queryItems = [
URLQueryItem(name: "link", value: downloadId)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
return availableHashes
}
}

View file

@ -7,9 +7,9 @@
import Foundation
class Github {
func fetchLatestRelease() async throws -> Release? {
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")!
public class Github {
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)
@ -17,8 +17,8 @@ class Github {
return rawResponse
}
func fetchReleases() async throws -> [Release]? {
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")!
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)

View file

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

View file

@ -1,277 +0,0 @@
//
// OffCloudWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 6/12/24.
//
import Foundation
class OffCloud: DebridSource, ObservableObject {
let id = "OffCloud"
let abbreviation = "OC"
let website = "https://offcloud.com"
let description: String? = "OffCloud is a debrid service that is used for downloads and media playback. " +
"You must pay to access this service. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
let cachedStatus: [String] = ["downloaded"]
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "OffCloud.UseManualKey") {
return getToken()
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseApiUrl = "https://offcloud.com/api"
private let jsonDecoder = JSONDecoder()
private let jsonEncoder = JSONEncoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserMagnets()
}
}
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "OffCloud.ApiKey")
UserDefaults.standard.set(true, forKey: "OffCloud.UseManualKey")
}
func logout() async {
FerriteKeychain.shared.delete("OffCloud.ApiKey")
UserDefaults.standard.removeObject(forKey: "OffCloud.UseManualKey")
}
private func getToken() -> String? {
FerriteKeychain.shared.get("OffCloud.ApiKey")
}
// Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw DebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.")
} else {
print(response)
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Builds a URL for further requests
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: urlString) else {
throw DebridError.InvalidUrl
}
guard let token = getToken() else {
throw DebridError.InvalidToken
}
components.queryItems = [
URLQueryItem(name: "key", value: token)
] + queryItems
if let url = components.url {
return url
} else {
throw DebridError.InvalidUrl
}
}
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
let sendMagnets = magnets.filter { magnet in
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if sendMagnets.isEmpty {
return
}
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cache"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = InstantAvailabilityRequest(hashes: sendMagnets.compactMap(\.hash))
request.httpBody = try jsonEncoder.encode(body)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(InstantAvailabilityResponse.self, from: data)
let availableHashes = rawResponse.cachedItems.map {
DebridIA(
magnet: Magnet(hash: $0, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: []
)
}
IAValues += availableHashes
}
// Cloud in OffCloud's API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
let selectedCloudMagnet: DebridCloudMagnet
// Don't queue a new job if the magnet already exists in the user's account
if let existingCloudMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && cachedStatus.contains($0.status) }) {
selectedCloudMagnet = existingCloudMagnet
} else {
let cloudDownloadResponse = try await offcloudDownload(magnet: magnet)
guard cachedStatus.contains(cloudDownloadResponse.status) else {
throw DebridError.IsCaching
}
selectedCloudMagnet = DebridCloudMagnet(
id: cloudDownloadResponse.requestId,
fileName: cloudDownloadResponse.fileName,
status: cloudDownloadResponse.status,
hash: "",
links: [cloudDownloadResponse.url]
)
}
let cloudExploreResponse = try await cloudExplore(requestId: selectedCloudMagnet.id)
// Request will error if the file isn't a batch
if case let .links(cloudExploreLinks) = cloudExploreResponse {
var copiedIA = ia
copiedIA?.files = cloudExploreLinks.enumerated().compactMap { index, exploreLink in
guard let exploreURL = URL(string: exploreLink) else {
return nil
}
return DebridIAFile(
id: index,
name: exploreURL.lastPathComponent,
streamUrlString: exploreLink.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
)
}
return (nil, copiedIA)
} else if case let .error(cloudExploreError) = cloudExploreResponse,
cloudExploreError.error.lowercased() == "bad archive"
{
guard let selectedCloudLink = selectedCloudMagnet.links[safe: 0] else {
throw DebridError.EmptyUserMagnets
}
let restrictedFile = DebridIAFile(
id: 0,
name: selectedCloudMagnet.fileName,
streamUrlString: "\(selectedCloudLink)/\(selectedCloudMagnet.fileName)"
)
return (restrictedFile, nil)
} else {
return (nil, nil)
}
}
// Called as "cloud" in offcloud's API
private func offcloudDownload(magnet: Magnet) async throws -> CloudDownloadResponse {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let magnetLink = magnet.link else {
throw DebridError.EmptyData
}
let body = CloudDownloadRequest(url: magnetLink)
request.httpBody = try jsonEncoder.encode(body)
let data = try await performRequest(request: &request, requestName: "cloud")
let rawResponse = try jsonDecoder.decode(CloudDownloadResponse.self, from: data)
return rawResponse
}
private func cloudExplore(requestId: String) async throws -> CloudExploreResponse {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud/explore/\(requestId)"))
let data = try await performRequest(request: &request, requestName: "cloudExplore")
let rawResponse = try jsonDecoder.decode(CloudExploreResponse.self, from: data)
return rawResponse
}
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
guard let streamUrlString = restrictedFile.streamUrlString else {
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the OffCloud API")
}
return streamUrlString
}
func getUserDownloads() {}
func checkUserDownloads(link: String) -> String? {
link
}
func deleteUserDownload(downloadId: String) {}
func getUserMagnets() async throws {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud/history"))
let data = try await performRequest(request: &request, requestName: "cloudHistory")
let rawResponse = try jsonDecoder.decode([CloudHistoryResponse].self, from: data)
cloudMagnets = rawResponse.compactMap { cloudHistory in
guard let magnetHash = Magnet(hash: nil, link: cloudHistory.originalLink).hash else {
return nil
}
return DebridCloudMagnet(
id: cloudHistory.requestId,
fileName: cloudHistory.fileName,
status: cloudHistory.status,
hash: magnetHash,
links: [cloudHistory.originalLink]
)
}
}
// Uses the base website because this isn't present in the API path but still works like the API?
func deleteUserMagnet(cloudMagnetId: String?) async throws {
guard let cloudMagnetId else {
throw DebridError.InvalidPostBody
}
var request = try URLRequest(url: buildRequestURL(urlString: "\(website)/cloud/remove/\(cloudMagnetId)"))
try await performRequest(request: &request, requestName: "cloudRemove")
}
}

View file

@ -6,48 +6,17 @@
//
import Foundation
import KeychainSwift
class Premiumize: OAuthDebridSource, ObservableObject {
let id = "Premiumize"
let abbreviation = "PM"
let website = "https://premiumize.me"
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
"You must pay to access the service."
public class Premiumize {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
let baseAuthUrl = "https://www.premiumize.me/authorize"
let baseApiUrl = "https://www.premiumize.me/api"
let clientId = "791565696"
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
return getToken()
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseAuthUrl = "https://www.premiumize.me/authorize"
private let baseApiUrl = "https://www.premiumize.me/api"
private let clientId = "791565696"
private let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
}
}
// MARK: - Auth
func getAuthUrl() throws -> URL {
public func buildAuthUrl() throws -> URL {
var urlComponents = URLComponents(string: baseAuthUrl)!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
@ -58,185 +27,59 @@ class Premiumize: OAuthDebridSource, ObservableObject {
if let url = urlComponents.url {
return url
} else {
throw DebridError.InvalidUrl
throw PMError.InvalidUrl
}
}
func handleAuthCallback(url: URL) throws {
public func handleAuthCallback(url: URL) throws {
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let callbackFragment = callbackComponents?.fragment else {
throw DebridError.InvalidResponse
throw PMError.InvalidResponse
}
var fragmentComponents = URLComponents()
fragmentComponents.query = callbackFragment
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
throw DebridError.InvalidToken
throw PMError.InvalidToken
}
FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken")
}
// Adds a manual API key instead of web auth
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
}
func getToken() -> String? {
FerriteKeychain.shared.get("Premiumize.AccessToken")
keychain.set(accessToken, forKey: "Premiumize.AccessToken")
}
// Clears tokens. No endpoint to deregister a device
func logout() {
FerriteKeychain.shared.delete("Premiumize.AccessToken")
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
public func deleteTokens() {
keychain.delete("Premiumize.AccessToken")
}
// MARK: - Common request
// Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = getToken() else {
throw DebridError.InvalidToken
guard let token = keychain.get("Premiumize.AccessToken") else {
throw PMError.InvalidToken
}
// Use the API query parameter if a manual API key is present
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
guard
let requestUrl = request.url,
var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false)
else {
throw DebridError.InvalidUrl
}
let apiTokenItem = URLQueryItem(name: "apikey", value: token)
if components.queryItems == nil {
components.queryItems = [apiTokenItem]
} else {
components.queryItems?.append(apiTokenItem)
}
request.url = components.url
} else {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw DebridError.FailedRequest(description: "No HTTP response given")
throw PMError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
deleteTokens()
throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
} else {
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
// Remove magnets that don't have an associated link for PM along with existing TTL logic
let sendMagnets = magnets.filter { magnet in
if magnet.link == nil {
return false
}
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if sendMagnets.isEmpty {
return
}
let availableMagnets = try await divideCacheRequests(magnets: sendMagnets)
// Split DDL requests into chunks of 10
for chunk in availableMagnets.chunked(into: 10) {
let tempIA = try await divideDDLRequests(magnetChunk: chunk)
IAValues += tempIA
}
}
// Function to divide and execute DDL endpoint requests in parallel
// Calls this for 10 requests at a time to not overwhelm API servers
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
for magnet in magnetChunk {
group.addTask {
try await self.fetchDDL(magnet: magnet)
}
}
var chunkedIA: [DebridIA] = []
for try await ia in group {
chunkedIA.append(ia)
}
return chunkedIA
}
return tempIA
}
// Grabs DDL links
private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
if magnet.hash == nil {
throw DebridError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
let content = rawResponse.content ?? []
if !content.isEmpty {
let files = content.map { file in
DebridIAFile(
id: 0,
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
streamUrlString: file.link
)
}
return DebridIA(
magnet: magnet,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
} else {
throw DebridError.EmptyData
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
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
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 {
@ -256,11 +99,11 @@ class Premiumize: OAuthDebridSource, ObservableObject {
}
// Parent function for initial checking of the cache
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
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 DebridError.InvalidUrl
throw PMError.InvalidUrl
}
var request = URLRequest(url: url)
@ -269,7 +112,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
if rawResponse.response.isEmpty {
throw DebridError.EmptyData
throw PMError.EmptyData
} else {
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
if rawResponse.response[safe: index] == true {
@ -283,32 +126,65 @@ class Premiumize: OAuthDebridSource, ObservableObject {
}
}
// MARK: - Downloading
// 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)
}
}
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
// Store the item in PM cloud for later use
try await createTransfer(magnet: magnet)
var chunkedIA: [Premiumize.IA] = []
for try await ia in group {
chunkedIA.append(ia)
}
return chunkedIA
}
if let iaFile {
return (iaFile, nil)
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
return (firstFile, nil)
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 DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
throw PMError.EmptyData
}
}
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
guard let streamUrlString = restrictedFile.streamUrlString else {
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
}
return streamUrlString
}
private func createTransfer(magnet: Magnet) async throws {
func createTransfer(magnet: Magnet) async throws {
guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid")
throw PMError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
@ -323,29 +199,24 @@ class Premiumize: OAuthDebridSource, ObservableObject {
try await performRequest(request: &request, requestName: #function)
}
// MARK: - Cloud methods
func getUserDownloads() async throws {
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 DebridError.EmptyData
throw PMError.EmptyData
}
// The "link" is the ID for Premiumize
cloudDownloads = rawResponse.files.map { file in
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
}
return rawResponse.files
}
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
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 DebridError.InvalidUrl
throw PMError.InvalidUrl
}
var request = URLRequest(url: url)
@ -356,26 +227,16 @@ class Premiumize: OAuthDebridSource, ObservableObject {
return rawResponse
}
func checkUserDownloads(link: String) async throws -> String? {
// Link is the cloud item ID
try await itemDetails(itemID: link).link
}
func deleteUserDownload(downloadId: String) async throws {
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: downloadId)]
bodyComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
try await performRequest(request: &request, requestName: #function)
}
// No user magnets for Premiumize
func getUserMagnets() {}
func deleteUserMagnet(cloudMagnetId: String?) {}
}

View file

@ -6,69 +6,30 @@
//
import Foundation
import KeychainSwift
class RealDebrid: PollingDebridSource, ObservableObject {
let id = "RealDebrid"
let abbreviation = "RD"
let website = "https://real-debrid.com"
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
"You must pay to access this service. \n\n" +
"It is not recommended to use this service since media cache checks are not possible via the API. " +
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
public class RealDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
let openSourceClientId = "X245A4XAIBGVM"
let cachedStatus: [String] = ["downloaded"]
var authTask: Task<Void, Error>?
@Published var authProcessing: Bool = false
// Check the manual token since getTokens() is async
var isLoggedIn: Bool {
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
}
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
private let openSourceClientId = "X245A4XAIBGVM"
private let jsonDecoder = JSONDecoder()
@MainActor
private func setUserDefaultsValue(_ value: Any, forKey: String) {
func setUserDefaultsValue(_ value: Any, forKey: String) {
UserDefaults.standard.set(value, forKey: forKey)
}
@MainActor
private func removeUserDefaultsValue(forKey: String) {
func removeUserDefaultsValue(forKey: String) {
UserDefaults.standard.removeObject(forKey: forKey)
}
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth
// Fetches the device code from RD
func getAuthUrl() async throws -> URL {
public func getVerificationInfo() async throws -> DeviceCodeResponse {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
@ -76,33 +37,23 @@ class RealDebrid: PollingDebridSource, ObservableObject {
]
guard let url = urlComponents.url else {
throw DebridError.InvalidUrl
throw RDError.InvalidUrl
}
let request = URLRequest(url: url)
do {
let (data, _) = try await URLSession.shared.data(for: request)
// Validate the URL before doing anything else
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else {
throw DebridError.AuthQuery(description: "The verification URL is invalid")
}
// Spawn the polling task separately
authTask = Task {
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
}
return directVerificationUrl
return rawResponse
} catch {
print("Couldn't get the new client creds!")
throw DebridError.AuthQuery(description: error.localizedDescription)
throw RDError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches the user's client ID and secret
func getDeviceCredentials(deviceCode: String) async throws {
public func getDeviceCredentials(deviceCode: String) async throws {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
@ -110,49 +61,55 @@ class RealDebrid: PollingDebridSource, ObservableObject {
]
guard let url = urlComponents.url else {
throw DebridError.InvalidUrl
throw RDError.InvalidUrl
}
let request = URLRequest(url: url)
// Timer to poll RD API for credentials
var count = 0
authTask = Task {
var count = 0
while count < 12 {
if Task.isCancelled {
throw DebridError.AuthQuery(description: "Token request cancelled.")
while count < 12 {
if Task.isCancelled {
throw RDError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getApiTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
if case let .failure(error) = await authTask?.result {
throw error
}
}
// Fetch all tokens for the user and store in FerriteKeychain.shared
func getApiTokens(deviceCode: String) async throws {
// 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 DebridError.EmptyData
throw RDError.EmptyData
}
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
throw DebridError.EmptyData
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
throw RDError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
@ -173,20 +130,20 @@ class RealDebrid: PollingDebridSource, ObservableObject {
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
}
func getToken() async -> String? {
public func fetchToken() async -> String? {
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
if Date().timeIntervalSince1970 > accessTokenStamp {
do {
if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
try await getApiTokens(deviceCode: refreshToken)
if let refreshToken = keychain.get("RealDebrid.RefreshToken") {
try await getTokens(deviceCode: refreshToken)
}
} catch {
print(error)
@ -194,43 +151,29 @@ class RealDebrid: PollingDebridSource, ObservableObject {
}
}
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
return keychain.get("RealDebrid.AccessToken")
}
// Adds a manual API key instead of web auth
// Clear out existing refresh tokens and timestamps
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey")
}
// Deletes tokens from device and RD's servers
func logout() async {
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
public func deleteTokens() async throws {
keychain.delete("RealDebrid.RefreshToken")
keychain.delete("RealDebrid.ClientSecret")
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
// Run the request, doesn't matter if it fails
if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") {
if let token = keychain.get("RealDebrid.AccessToken") {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = try? await URLSession.shared.data(for: request)
FerriteKeychain.shared.delete("RealDebrid.AccessToken")
await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey")
keychain.delete("RealDebrid.AccessToken")
}
}
// MARK: - Common request
// Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await getToken() else {
throw DebridError.InvalidToken
guard let token = await fetchToken() else {
throw RDError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -238,116 +181,99 @@ class RealDebrid: PollingDebridSource, ObservableObject {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw DebridError.FailedRequest(description: "No HTTP response given")
throw RDError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
try await deleteTokens()
throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
} else {
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// MARK: - Instant availability
// Checks if the magnet is streamable on RD
// Currently does not work for batch links
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] {
var availableHashes: [RealDebrid.IA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.compactMap(\.hash).joined(separator: "/"))")!)
// Post-API changes
// Use user magnets to check for IA instead
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
let data = try await performRequest(request: &request, requestName: #function)
let sendMagnets = magnets.filter { magnet in
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
// Does not account for torrent packs at the moment
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
for (hash, response) in rawResponseDict {
guard let data = response.data else {
continue
}
}
// Fetch the user magnets to the latest version
try await getUserMagnets()
if data.rd.isEmpty {
continue
}
for cloudMagnet in cloudMagnets {
if cachedStatus.contains(cloudMagnet.status),
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
{
IAValues.append(
DebridIA(
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
// Is this a batch
if data.rd.count > 1 || data.rd[0].count > 1 {
// Batch array
let batches = data.rd.map { fileDict in
let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
return RealDebrid.IABatch(files: batchFiles)
}
// RD files array
// Possibly sort this in the future, but not sure how at the moment
var files: [RealDebrid.IAFile] = []
for index in batches.indices {
let batchFiles = batches[index].files
for batchFileIndex in batchFiles.indices {
let batchFile = batchFiles[batchFileIndex]
if !files.contains(where: { $0.name == batchFile.fileName }) {
files.append(
RealDebrid.IAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
)
)
}
}
}
// TTL: 5 minutes
availableHashes.append(
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: []
files: files,
batches: batches
)
)
} else {
availableHashes.append(
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300
)
)
}
}
}
// MARK: - Downloading
// Wrapper function to fetch a download link from the API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
var selectedMagnetId = ""
do {
// Don't queue a new job if the magnet already exists in the user's library
if let existingCloudMagnet = cloudMagnets.first(where: {
$0.hash == magnet.hash && cachedStatus.contains($0.status)
}) {
selectedMagnetId = existingCloudMagnet.id
} else {
selectedMagnetId = try await addMagnet(magnet: magnet)
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
}
let response = try await torrentInfo(debridID: selectedMagnetId)
let filteredFiles = response.files.filter { $0.selected == 1 }
// Need to return this to the user
if filteredFiles.count > 1, iaFile == nil {
var copiedIA = ia
copiedIA?.files = response.files.enumerated().compactMap { index, file in
DebridIAFile(
id: index,
name: file.path,
streamUrlString: response.links[safe: index]
)
}
return (nil, copiedIA)
}
// RealDebrid has 1 as the first ID for a file
let selectedFileId = iaFile?.id ?? 1
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
throw DebridError.EmptyUserMagnets
}
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
return (restrictedFile, nil)
} catch {
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
}
// Re-raise the error to the calling function
throw error
}
return availableHashes
}
// Adds a magnet link to the user's RD account
func addMagnet(magnet: Magnet) async throws -> String {
public func addMagnet(magnet: Magnet) async throws -> String {
guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid")
throw RDError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
@ -366,7 +292,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
}
// Queues the magnet link for downloading
func selectFiles(debridID: String, fileIds: [Int]) async throws {
public func selectFiles(debridID: String, fileIds: [Int]) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -386,31 +312,48 @@ class RealDebrid: PollingDebridSource, ObservableObject {
}
// Gets the info of a torrent from a given ID
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse {
public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
// Let the user know if a magnet is downloading
switch rawResponse.status {
case "downloaded":
return rawResponse
case "downloading", "queued":
throw DebridError.IsCaching
default:
throw DebridError.EmptyUserMagnets
// Let the user know if a torrent is downloading
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
return torrentLink
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
throw RDError.EmptyTorrents
} else {
throw RDError.EmptyData
}
}
// Gets the user's torrent library
public func userTorrents() async throws -> [UserTorrentsResponse] {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
return rawResponse
}
// Deletes a torrent download from RD
public func deleteTorrent(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
// Downloads link from selectFiles for playback
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)]
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
@ -420,66 +363,18 @@ class RealDebrid: PollingDebridSource, ObservableObject {
return rawResponse.download
}
// MARK: - Cloud methods
// Gets the user's cloud magnet library
func getUserMagnets() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
cloudMagnets = rawResponse.map { response in
DebridCloudMagnet(
id: response.id,
fileName: response.filename,
status: response.status,
hash: response.hash,
links: [response.id]
)
}
}
// Deletes a magnet download from RD
func deleteUserMagnet(cloudMagnetId: String?) async throws {
let deleteId: String
if let cloudMagnetId {
deleteId = cloudMagnetId
} else {
// Refresh the user magnet list
// The first file is the currently caching one
let _ = try await getUserMagnets()
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
throw DebridError.EmptyUserMagnets
}
deleteId = firstCloudMagnet.id
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
// Gets the user's downloads
func getUserDownloads() async throws {
public func userDownloads() async throws -> [UserDownloadsResponse] {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
cloudDownloads = rawResponse.map { response in
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
}
return rawResponse
}
// Not used
func checkUserDownloads(link: String) -> String? {
link
}
func deleteUserDownload(downloadId: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
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

@ -1,270 +0,0 @@
//
// TorBoxWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 6/11/24.
//
import Foundation
class TorBox: DebridSource, ObservableObject {
let id = "TorBox"
let abbreviation = "TB"
let website = "https://torbox.app"
let description: String? = "TorBox is a debrid service that is used for downloads and media playback with seeding. " +
"Both free and paid plans are available."
let cachedStatus: [String] = ["cached", "completed"]
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "TorBox.UseManualKey") {
return getToken()
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseApiUrl = "https://api.torbox.app/v1/api"
private let jsonDecoder = JSONDecoder()
private let jsonEncoder = JSONEncoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserMagnets()
}
}
// MARK: - Auth
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "TorBox.ApiKey")
UserDefaults.standard.set(true, forKey: "TorBox.UseManualKey")
}
func logout() async {
FerriteKeychain.shared.delete("TorBox.ApiKey")
UserDefaults.standard.removeObject(forKey: "TorBox.UseManualKey")
}
private func getToken() -> String? {
FerriteKeychain.shared.get("TorBox.ApiKey")
}
// MARK: - Common request
// Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = getToken() else {
throw DebridError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw DebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.")
} else {
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
let sendMagnets = magnets.filter { magnet in
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if sendMagnets.isEmpty {
return
}
var components = URLComponents(string: "\(baseApiUrl)/torrents/checkcached")!
components.queryItems = sendMagnets.map { URLQueryItem(name: "hash", value: $0.hash) }
components.queryItems?.append(URLQueryItem(name: "format", value: "list"))
components.queryItems?.append(URLQueryItem(name: "list_files", value: "true"))
guard let url = components.url else {
throw DebridError.InvalidUrl
}
var request = URLRequest(url: url)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TBResponse<InstantAvailabilityData>.self, from: data)
// If the data is a failure, return
guard case let .links(iaObjects) = rawResponse.data else {
return
}
let availableHashes = iaObjects.map { iaObject in
DebridIA(
magnet: Magnet(hash: iaObject.hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: iaObject.files.enumerated().compactMap { index, iaFile in
guard let fileName = iaFile.name.split(separator: "/").last else {
return nil
}
return DebridIAFile(
id: index,
name: String(fileName)
)
}
)
}
IAValues += availableHashes
}
// MARK: - Downloading
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
let cloudMagnetId = try await createTorrent(magnet: magnet)
let cloudMagnetList = try await myTorrentList()
guard let filteredCloudMagnet = cloudMagnetList.first(where: { $0.id == cloudMagnetId }) else {
throw DebridError.FailedRequest(description: "Could not find a cached magnet. Are you sure it's cached?")
}
// If the user magnet isn't saved, it's considered as caching
guard cachedStatus.contains(filteredCloudMagnet.downloadState) else {
throw DebridError.IsCaching
}
guard let cloudMagnetFile = filteredCloudMagnet.files[safe: iaFile?.id ?? 0] else {
throw DebridError.EmptyUserMagnets
}
let restrictedFile = DebridIAFile(id: cloudMagnetFile.id, name: cloudMagnetFile.name, streamUrlString: String(cloudMagnetId))
return (restrictedFile, nil)
}
private func createTorrent(magnet: Magnet) async throws -> Int {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/createtorrent")!)
request.httpMethod = "POST"
guard let magnetLink = magnet.link else {
throw DebridError.EmptyData
}
let formData = FormDataBody(params: ["magnet": magnetLink])
request.setValue("multipart/form-data; boundary=\(formData.boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = formData.body
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TBResponse<CreateTorrentResponse>.self, from: data)
guard let torrentId = rawResponse.data?.torrentId else {
throw DebridError.EmptyData
}
return torrentId
}
private func myTorrentList() async throws -> [MyTorrentListResponse] {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/mylist")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TBResponse<[MyTorrentListResponse]>.self, from: data)
guard let torrentList = rawResponse.data else {
throw DebridError.EmptyData
}
return torrentList
}
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
var components = URLComponents(string: "\(baseApiUrl)/torrents/requestdl")!
components.queryItems = [
URLQueryItem(name: "token", value: getToken()),
URLQueryItem(name: "torrent_id", value: restrictedFile.streamUrlString),
URLQueryItem(name: "file_id", value: String(restrictedFile.id))
]
guard let url = components.url else {
throw DebridError.InvalidUrl
}
var request = URLRequest(url: url)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TBResponse<RequestDLResponse>.self, from: data)
guard let unrestrictedLink = rawResponse.data else {
throw DebridError.FailedRequest(description: "Could not get an unrestricted URL from TorBox.")
}
return unrestrictedLink
}
// MARK: - Cloud methods
// Unused
func getUserDownloads() {}
func checkUserDownloads(link: String) -> String? {
link
}
func deleteUserDownload(downloadId: String) {}
func getUserMagnets() async throws {
let cloudMagnetList = try await myTorrentList()
cloudMagnets = cloudMagnetList.map { cloudMagnet in
// Only need one link to force a green badge
DebridCloudMagnet(
id: String(cloudMagnet.id),
fileName: cloudMagnet.name,
status: cloudMagnet.downloadState,
hash: cloudMagnet.hash,
links: cloudMagnet.files.map { String($0.id) }
)
}
}
func deleteUserMagnet(cloudMagnetId: String?) async throws {
guard let cloudMagnetId else {
throw DebridError.InvalidPostBody
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/controltorrent")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ControlTorrentRequest(torrentId: cloudMagnetId, operation: "Delete")
request.httpBody = try jsonEncoder.encode(body)
try await performRequest(request: &request, requestName: "controltorrent")
}
}

View file

@ -9,21 +9,9 @@
import Foundation
class Application {
public class Application {
static let shared = Application()
// OS name for Plugins to read. Lowercase for ease of use
let os = "ios"
// Minimum OS version that Ferrite runs on
var minVersion: String {
Bundle.main.object(forInfoDictionaryKey: "MinimumOSVersion") as? String ?? "0.0"
}
// Grabs the current user's OS version
let osVersion: OperatingSystemVersion = ProcessInfo().operatingSystemVersion
// Application version and build variables
var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}
@ -44,4 +32,6 @@ class Application {
}
#endif
}
let osVersion: OperatingSystemVersion = ProcessInfo().operatingSystemVersion
}

View file

@ -6,8 +6,8 @@
//
//
import CoreData
import Foundation
import CoreData
@objc(Action)
public class Action: NSManagedObject, Plugin {}

View file

@ -6,63 +6,66 @@
//
//
import CoreData
import Foundation
import CoreData
public extension Action {
@nonobjc class func fetchRequest() -> NSFetchRequest<Action> {
NSFetchRequest<Action>(entityName: "Action")
extension Action {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Action> {
return NSFetchRequest<Action>(entityName: "Action")
}
@NSManaged var id: UUID
@NSManaged var listId: UUID?
@NSManaged var name: String
@NSManaged var deeplink: String?
@NSManaged var version: Int16
@NSManaged var about: String?
@NSManaged var website: String?
@NSManaged var requires: [String]
@NSManaged var author: String
@NSManaged var enabled: Bool
@NSManaged var tags: NSOrderedSet?
@NSManaged public var id: UUID
@NSManaged public var listId: UUID?
@NSManaged public var name: String
@NSManaged public var deeplink: String?
@NSManaged public var version: Int16
@NSManaged public var requires: [String]
@NSManaged public var author: String
@NSManaged public var enabled: Bool
@NSManaged public var tags: NSOrderedSet?
func getTags() -> [PluginTagJson] {
requires.map { PluginTagJson(name: $0, colorHex: nil) } + tagArray.map { $0.toJson() }
public func getTags() -> [PluginTagJson] {
return requires.map { PluginTagJson(name: $0, colorHex: nil) } + tagArray.map { $0.toJson() }
}
}
// MARK: Generated accessors for tags
extension Action {
public extension Action {
@objc(insertObject:inTagsAtIndex:)
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
@NSManaged public func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged func removeFromTags(at idx: Int)
@NSManaged public func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@NSManaged public func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged func removeFromTags(at indexes: NSIndexSet)
@NSManaged public func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
@NSManaged public func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@NSManaged public func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged func addToTags(_ value: PluginTag)
@NSManaged public func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged func removeFromTags(_ value: PluginTag)
@NSManaged public func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged func addToTags(_ values: NSOrderedSet)
@NSManaged public func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged func removeFromTags(_ values: NSOrderedSet)
@NSManaged public func removeFromTags(_ values: NSOrderedSet)
}
extension Action: Identifiable {}
extension Action : Identifiable {
}

View file

@ -10,4 +10,4 @@ import CoreData
import Foundation
@objc(Bookmark)
class Bookmark: NSManagedObject {}
public class Bookmark: NSManagedObject {}

View file

@ -9,7 +9,7 @@
import CoreData
import Foundation
extension Bookmark {
public extension Bookmark {
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
NSFetchRequest<Bookmark>(entityName: "Bookmark")
}

View file

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

View file

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

View file

@ -6,8 +6,10 @@
//
//
import CoreData
import Foundation
import CoreData
@objc(PluginList)
public class PluginList: NSManagedObject {}
public class PluginList: NSManagedObject {
}

View file

@ -6,18 +6,23 @@
//
//
import CoreData
import Foundation
import CoreData
public extension PluginList {
@nonobjc class func fetchRequest() -> NSFetchRequest<PluginList> {
NSFetchRequest<PluginList>(entityName: "PluginList")
extension PluginList {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PluginList> {
return NSFetchRequest<PluginList>(entityName: "PluginList")
}
@NSManaged var author: String
@NSManaged var id: UUID
@NSManaged var name: String
@NSManaged var urlString: String
@NSManaged public var author: String
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var urlString: String
}
extension PluginList: Identifiable {}
extension PluginList : Identifiable {
}

View file

@ -6,8 +6,9 @@
//
//
import CoreData
import Foundation
import CoreData
@objc(PluginTag)
public class PluginTag: NSManagedObject {}
public class PluginTag: NSManagedObject {
}

View file

@ -6,22 +6,26 @@
//
//
import CoreData
import Foundation
import CoreData
public extension PluginTag {
@nonobjc class func fetchRequest() -> NSFetchRequest<PluginTag> {
NSFetchRequest<PluginTag>(entityName: "PluginTag")
extension PluginTag {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PluginTag> {
return NSFetchRequest<PluginTag>(entityName: "PluginTag")
}
@NSManaged var colorHex: String?
@NSManaged var name: String
@NSManaged var parentAction: Action?
@NSManaged var parentSource: Source?
@NSManaged public var colorHex: String?
@NSManaged public var name: String
@NSManaged public var parentAction: Action?
@NSManaged public var parentSource: Source?
internal func toJson() -> PluginTagJson {
PluginTagJson(name: name, colorHex: colorHex)
func toJson() -> PluginTagJson {
return PluginTagJson(name: name, colorHex: colorHex)
}
}
extension PluginTag: Identifiable {}
extension PluginTag : Identifiable {
}

View file

@ -6,69 +6,73 @@
//
//
import CoreData
import Foundation
import CoreData
public extension Source {
@nonobjc class func fetchRequest() -> NSFetchRequest<Source> {
NSFetchRequest<Source>(entityName: "Source")
extension Source {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Source> {
return NSFetchRequest<Source>(entityName: "Source")
}
@NSManaged var id: UUID
@NSManaged var about: String?
@NSManaged var website: String?
@NSManaged var dynamicWebsite: Bool
@NSManaged var fallbackUrls: [String]?
@NSManaged var enabled: Bool
@NSManaged var name: String
@NSManaged var author: String
@NSManaged var listId: UUID?
@NSManaged var preferredParser: Int16
@NSManaged var version: Int16
@NSManaged var htmlParser: SourceHtmlParser?
@NSManaged var rssParser: SourceRssParser?
@NSManaged var jsonParser: SourceJsonParser?
@NSManaged var api: SourceApi?
@NSManaged var trackers: [String]?
@NSManaged var tags: NSOrderedSet?
@NSManaged public var id: UUID
@NSManaged public var baseUrl: String?
@NSManaged public var fallbackUrls: [String]?
@NSManaged public var dynamicBaseUrl: Bool
@NSManaged public var enabled: Bool
@NSManaged public var name: String
@NSManaged public var author: String
@NSManaged public var listId: UUID?
@NSManaged public var preferredParser: Int16
@NSManaged public var version: Int16
@NSManaged public var htmlParser: SourceHtmlParser?
@NSManaged public var rssParser: SourceRssParser?
@NSManaged public var jsonParser: SourceJsonParser?
@NSManaged public var api: SourceApi?
@NSManaged public var trackers: [String]?
@NSManaged public var tags: NSOrderedSet?
func getTags() -> [PluginTagJson] {
tagArray.map { $0.toJson() }
public func getTags() -> [PluginTagJson] {
return tagArray.map { $0.toJson() }
}
}
// MARK: Generated accessors for tags
extension Source {
public extension Source {
@objc(insertObject:inTagsAtIndex:)
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
@NSManaged public func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged func removeFromTags(at idx: Int)
@NSManaged public func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@NSManaged public func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged func removeFromTags(at indexes: NSIndexSet)
@NSManaged public func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
@NSManaged public func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@NSManaged public func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged func addToTags(_ value: PluginTag)
@NSManaged public func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged func removeFromTags(_ value: PluginTag)
@NSManaged public func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged func addToTags(_ values: NSOrderedSet)
@NSManaged public func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged func removeFromTags(_ values: NSOrderedSet)
@NSManaged public func removeFromTags(_ values: NSOrderedSet)
}
extension Source: Identifiable {}
extension Source : Identifiable {
}

View file

@ -15,8 +15,7 @@ public extension SourceHtmlParser {
}
@NSManaged var rows: String
@NSManaged var searchUrl: String?
@NSManaged var request: SourceRequest?
@NSManaged var searchUrl: String
@NSManaged var magnetHash: SourceMagnetHash?
@NSManaged var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source?

View file

@ -17,7 +17,6 @@ public extension SourceJsonParser {
@NSManaged var results: String?
@NSManaged var subResults: String?
@NSManaged var searchUrl: String
@NSManaged var request: SourceRequest?
@NSManaged var magnetHash: SourceMagnetHash?
@NSManaged var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source?

View file

@ -1,13 +0,0 @@
//
// SourceRequest+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 6/10/24.
//
//
import CoreData
import Foundation
@objc(SourceRequest)
public class SourceRequest: NSManagedObject {}

View file

@ -1,25 +0,0 @@
//
// SourceRequest+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 6/10/24.
//
//
import CoreData
import Foundation
public extension SourceRequest {
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceRequest> {
NSFetchRequest<SourceRequest>(entityName: "SourceRequest")
}
@NSManaged var method: String?
@NSManaged var headers: [String: String]?
@NSManaged var body: String?
@NSManaged var parentHtmlParser: SourceHtmlParser?
@NSManaged var parentRssParser: SourceRssParser?
@NSManaged var parentJsonParser: SourceJsonParser?
}
extension SourceRequest: Identifiable {}

View file

@ -17,7 +17,6 @@ public extension SourceRssParser {
@NSManaged var items: String
@NSManaged var rssUrl: String?
@NSManaged var searchUrl: String
@NSManaged var request: SourceRequest?
@NSManaged var magnetHash: SourceMagnetHash?
@NSManaged var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source?

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Action" representedClassName="Action" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="deeplink" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -10,7 +9,6 @@
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/>
</entity>
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
@ -36,12 +34,6 @@
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
</entity>
<entity name="KodiServer" representedClassName="KodiServer" syncable="YES">
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="password" optional="YES" attributeType="String"/>
<attribute name="urlString" attributeType="String" defaultValueString=""/>
<attribute name="username" optional="YES" attributeType="String"/>
</entity>
<entity name="PluginList" representedClassName="PluginList" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
@ -55,9 +47,9 @@
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
</entity>
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="dynamicWebsite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="baseUrl" optional="YES" attributeType="String"/>
<attribute name="dynamicBaseUrl" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
@ -66,7 +58,6 @@
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
@ -102,11 +93,10 @@
</entity>
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
<attribute name="rows" attributeType="String" defaultValueString=""/>
<attribute name="searchUrl" optional="YES" attributeType="String"/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentHtmlParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
@ -119,7 +109,6 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentJsonParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
@ -136,14 +125,6 @@
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRequest" representedClassName="SourceRequest" syncable="YES">
<attribute name="body" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData"/>
<attribute name="headers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String: String]"/>
<attribute name="method" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="request" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="request" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="request" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/>
@ -151,7 +132,6 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentRssParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>

View file

@ -1,35 +0,0 @@
//
// Color.swift
// Ferrite
//
// Created by Brian Dashore on 3/28/23.
//
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View file

@ -7,7 +7,6 @@
import Foundation
// A static DateFormatter is better than initializing new ones
extension DateFormatter {
static let historyDateFormatter: DateFormatter = {
let df = DateFormatter()

View file

@ -1,14 +0,0 @@
//
// OperatingSystemVersion.swift
// Ferrite
//
// Created by Brian Dashore on 3/23/23.
//
import Foundation
extension OperatingSystemVersion {
func toString() -> String {
"\(majorVersion).\(minorVersion).\(patchVersion)"
}
}

View file

@ -1,5 +1,5 @@
//
// Set.swift
// Array.swift
// Ferrite
//
// Created by Brian Dashore on 11/26/22.

View file

@ -1,15 +0,0 @@
//
// UIApplication.swift
// Ferrite
//
// Created by Brian Dashore on 3/27/23.
//
import UIKit
extension UIApplication {
// From https://stackoverflow.com/questions/69650504/how-to-get-rid-of-message-windows-was-deprecated-in-ios-15-0-use-uiwindowsc
var currentUIWindow: UIWindow? {
UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow }
}
}

View file

@ -5,10 +5,14 @@
// Created by Brian Dashore on 2/16/23.
//
import UIKit
import SwiftUI
extension UIDevice {
var hasNotch: Bool {
UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
if #available(iOS 11.0, *) {
let keyWindow = UIApplication.shared.windows.filter(\.isKeyWindow).first
return keyWindow?.safeAreaInsets.bottom ?? 0 > 0
}
return false
}
}

View file

@ -5,19 +5,21 @@
// Created by Brian Dashore on 8/15/22.
//
import Introspect
import SwiftUI
extension View {
// Modifies properties of a view. Works the same way as a ViewModifier
// From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
var result = self
body(&result)
// MARK: Modifiers
return result
func conditionalContextMenu(id: some Hashable,
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
{
modifier(ConditionalContextMenuModifier(internalContent, id: id))
}
// MARK: Modifiers
func conditionalId(_ id: some Hashable) -> some View {
modifier(ConditionalIdModifier(id: id))
}
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
@ -30,4 +32,16 @@ extension View {
func inlinedList(inset: CGFloat) -> some View {
modifier(InlinedListModifier(inset: inset))
}
func viewDidAppear(_ callback: @escaping () -> Void) -> some View {
modifier(ViewDidAppearModifier(callback: callback))
}
func customScopeBar<Content: View>(_ content: Content) -> some View {
modifier(CustomScopeBarModifier(hostingContent: content))
}
func customScopeBar<Content: View>(_ content: @escaping () -> Content) -> some View {
modifier(CustomScopeBarModifier(hostingContent: content()))
}
}

View file

@ -12,7 +12,7 @@ struct FerriteApp: App {
let persistenceController = PersistenceController.shared
@StateObject var scrapingModel: ScrapingViewModel = .init()
@StateObject var logManager: LoggingManager = .init()
@StateObject var toastModel: ToastViewModel = .init()
@StateObject var debridManager: DebridManager = .init()
@StateObject var navModel: NavigationViewModel = .init()
@StateObject var pluginManager: PluginManager = .init()
@ -21,16 +21,16 @@ struct FerriteApp: App {
var body: some Scene {
WindowGroup {
MainView()
.onAppear {
scrapingModel.logManager = logManager
debridManager.logManager = logManager
pluginManager.logManager = logManager
backupManager.logManager = logManager
navModel.logManager = logManager
.backport.onAppear {
scrapingModel.toastModel = toastModel
debridManager.toastModel = toastModel
pluginManager.toastModel = toastModel
backupManager.toastModel = toastModel
navModel.toastModel = toastModel
}
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(logManager)
.environmentObject(toastModel)
.environmentObject(navModel)
.environmentObject(pluginManager)
.environmentObject(backupManager)

View file

@ -24,7 +24,7 @@
<string>Ferrite</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ferrite</string>
<string>ferrite://</string>
</array>
</dict>
</array>

View file

@ -7,100 +7,26 @@
import Foundation
struct ActionJson: Codable, Hashable, PluginJson {
let name: String
let version: Int16
public struct ActionJson: Codable, Hashable, PluginJson {
public let name: String
public let version: Int16
let minVersion: String?
let about: String?
let website: String?
let requires: [ActionRequirement]
let deeplink: [DeeplinkActionJson]?
let author: String?
let listId: UUID?
let listName: String?
let tags: [PluginTagJson]?
init(name: String,
version: Int16,
minVersion: String?,
about: String?,
website: String?,
requires: [ActionRequirement],
deeplink: [DeeplinkActionJson]?,
author: String?,
listId: UUID?,
listName: String?,
tags: [PluginTagJson]?)
{
self.name = name
self.version = version
self.minVersion = minVersion
self.about = about
self.website = website
self.requires = requires
self.deeplink = deeplink
self.author = author
self.listId = listId
self.listName = listName
self.tags = tags
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
version = try container.decode(Int16.self, forKey: .version)
minVersion = try container.decodeIfPresent(String.self, forKey: .minVersion)
about = try container.decodeIfPresent(String.self, forKey: .about)
website = try container.decodeIfPresent(String.self, forKey: .website)
requires = try container.decode([ActionRequirement].self, forKey: .requires)
author = try container.decodeIfPresent(String.self, forKey: .author)
listId = nil
listName = nil
tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags)
if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) {
deeplink = [DeeplinkActionJson(os: [], scheme: deeplinkString)]
} else if let deeplinkAction = try? container.decode([DeeplinkActionJson].self, forKey: .deeplink) {
deeplink = deeplinkAction
} else {
deeplink = nil
}
}
}
struct DeeplinkActionJson: Codable, Hashable {
let os: [String]
let scheme: String
init(os: [String], scheme: String) {
self.os = os
self.scheme = scheme
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let os = try? container.decode(String.self, forKey: .os) {
self.os = [os]
} else if let os = try? container.decode([String].self, forKey: .os) {
self.os = os
} else {
os = []
}
scheme = try container.decode(String.self, forKey: .scheme)
}
let deeplink: String?
public var author: String?
public var listId: UUID?
public var tags: [PluginTagJson]?
}
extension ActionJson {
// Fetches all tags without optional requirement
// Avoids the need for extra tag additions in DB
func getTags() -> [PluginTagJson] {
requires.map { PluginTagJson(name: $0.rawValue, colorHex: nil) } + (tags.map { $0 } ?? [])
public func getTags() -> [PluginTagJson] {
return requires.map { PluginTagJson(name: $0.rawValue, colorHex: nil) } + (tags.map { $0 } ?? [])
}
}
enum ActionRequirement: String, Codable {
public enum ActionRequirement: String, Codable {
case magnet
case debrid
}

View file

@ -7,7 +7,21 @@
import Foundation
extension AllDebrid {
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
@ -53,7 +67,7 @@ extension AllDebrid {
// MARK: - AddMagnetData
struct AddMagnetData: Codable {
internal struct AddMagnetData: Codable {
let magnet, hash, name, filenameOriginal: String
let size: Int
let ready: Bool
@ -71,7 +85,7 @@ extension AllDebrid {
struct MagnetStatusResponse: Codable {
let magnets: [MagnetStatusData]
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
@ -103,7 +117,7 @@ extension AllDebrid {
// MARK: - MagnetStatusLink
// Abridged for required parameters
struct MagnetStatusLink: Codable {
internal struct MagnetStatusLink: Codable {
let link: String
let filename: String
let size: Int
@ -116,19 +130,6 @@ extension AllDebrid {
let link: String
}
// MARK: - SavedLinksResponse
struct SavedLinksResponse: Codable {
let links: [SavedLink]
}
struct SavedLink: Codable, Hashable {
let link: String
let date: Int
let filename: String
let size: Int
}
// MARK: - InstantAvailabilityResponse
struct InstantAvailabilityResponse: Codable {
@ -137,7 +138,7 @@ extension AllDebrid {
// MARK: - IAMagnetResponse
struct InstantAvailabilityMagnet: Codable {
internal struct InstantAvailabilityMagnet: Codable {
let magnet, hash: String
let instant: Bool
let files: [InstantAvailabilityFile]?
@ -145,11 +146,24 @@ extension AllDebrid {
// MARK: - IAFileResponse
struct InstantAvailabilityFile: Codable {
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,7 +8,7 @@
import Foundation
// Version is optional until v1 is phased out
struct Backup: Codable {
public struct Backup: Codable {
let version: Int?
var bookmarks: [BookmarkJson]?
var history: [HistoryJson]?
@ -17,7 +17,6 @@ struct Backup: Codable {
var pluginListUrls: [String]?
// MARK: Remove once v1 backups are unsupported
var sourceLists: [PluginListBackupJson]?
}

View file

@ -10,15 +10,15 @@ import Foundation
// MARK: - Universal IA enum (IA = InstantAvailability)
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
case full = "Cached"
case partial = "Batch"
case none = "Uncached"
public enum IAStatus: Codable, Hashable, Sendable {
case full
case partial
case none
}
// MARK: - Enum for debrid differentiation. 0 is nil
enum DebridType: Int, Codable, Hashable, CaseIterable {
public enum DebridType: Int, Codable, Hashable, CaseIterable {
case realDebrid = 1
case allDebrid = 2
case premiumize = 3
@ -33,21 +33,10 @@ enum DebridType: Int, Codable, Hashable, CaseIterable {
return abbreviated ? "PM" : "Premiumize"
}
}
func website() -> String {
switch self {
case .realDebrid:
return "https://real-debrid.com"
case .allDebrid:
return "https://alldebrid.com"
case .premiumize:
return "https://premiumize.me"
}
}
}
// Wrapper struct for magnet links to contain both the link and hash for easy access
struct Magnet: Codable, Hashable, Sendable {
public struct Magnet: Codable, Hashable, Sendable {
var hash: String?
var link: String?
@ -56,13 +45,11 @@ struct Magnet: Codable, Hashable, Sendable {
self.hash = parseHash(hash)
self.link = generateLink(hash: hash, title: title, trackers: trackers)
} else if let link, hash == nil {
let (link, hash) = parseLink(link)
self.link = link
self.hash = hash
self.hash = parseHash(extractHash(link: link))
} else {
self.hash = parseHash(hash)
self.link = parseLink(link).link
self.link = link
}
}
@ -109,36 +96,4 @@ struct Magnet: Codable, Hashable, Sendable {
return String(magnetHash).lowercased()
}
}
func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) {
let separator = "magnet:?xt=urn:btih:"
// Remove percent encoding from the link and ensure it's a magnet
guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else {
return (nil, nil)
}
// Isolate the magnet link if it's bundled with another protocol
let isolatedLink: String?
if decodedLink.starts(with: separator) {
isolatedLink = decodedLink
} else {
let splitLink = decodedLink.components(separatedBy: separator)
isolatedLink = splitLink.last.map { separator + $0 }
}
guard let isolatedLink else {
return (nil, nil)
}
// If the hash can be extracted, decrypt it if necessary and return the revised link + hash
if let originalHash = extractHash(link: isolatedLink),
let parsedHash = parseHash(originalHash)
{
let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash)
return (replacedLink, parsedHash)
} else {
return (decodedLink, nil)
}
}
}

View file

@ -1,55 +0,0 @@
//
// DebridModels.swift
// Ferrite
//
// Created by Brian Dashore on 6/2/24.
//
import Foundation
struct DebridIA: Hashable, Sendable {
let magnet: Magnet
let expiryTimeStamp: Double
var files: [DebridIAFile]
}
struct DebridIAFile: Hashable, Sendable {
let id: Int
let name: String
let streamUrlString: String?
let batchIds: [Int]
init(id: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) {
self.id = id
self.name = name
self.streamUrlString = streamUrlString
self.batchIds = batchIds
}
}
struct DebridCloudDownload: Hashable, Sendable {
let id: String
let fileName: String
let link: String
}
struct DebridCloudMagnet: Hashable, Sendable {
let id: String
let fileName: String
let status: String
let hash: String
let links: [String]
}
enum DebridError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyUserMagnets
case IsCaching
case FailedRequest(description: String)
case AuthQuery(description: String)
case NotImplemented
}

View file

@ -1,20 +0,0 @@
//
// FilterModels.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import Foundation
enum FilterType {
case source
case IA
case sort
}
enum SortFilter: String, Hashable, CaseIterable {
case seeders = "Seeders"
case leechers = "Leechers"
case size = "Size"
}

View file

@ -7,7 +7,7 @@
import Foundation
extension Github {
public extension Github {
struct Release: Codable, Hashable, Sendable {
let htmlUrl: String
let tagName: String

View file

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

View file

@ -1,70 +0,0 @@
//
// OffCloudModels.swift
// Ferrite
//
// Created by Brian Dashore on 6/12/24.
//
import Foundation
extension OffCloud {
struct ErrorResponse: Codable, Sendable {
let error: String
}
struct InstantAvailabilityRequest: Codable, Sendable {
let hashes: [String]
}
struct InstantAvailabilityResponse: Codable, Sendable {
let cachedItems: [String]
}
struct CloudDownloadRequest: Codable, Sendable {
let url: String
}
struct CloudDownloadResponse: Codable, Sendable {
let requestId: String
let fileName: String
let status: String
let originalLink: String
let url: String
}
enum CloudExploreResponse: Codable {
case links([String])
case error(ErrorResponse)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// Only continue if the data is a List which indicates a success
if let linkArray = try? container.decode([String].self) {
self = .links(linkArray)
} else {
let value = try container.decode(ErrorResponse.self)
self = .error(value)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .links(array):
try container.encode(array)
case let .error(value):
try container.encode(value)
}
}
}
struct CloudHistoryResponse: Codable, Sendable {
let requestId: String
let fileName: String
let status: String
let originalLink: String
let isDirectory: Bool
let server: String
}
}

View file

@ -7,7 +7,7 @@
import Foundation
struct PluginListJson: Codable {
public struct PluginListJson: Codable {
let name: String
let author: String
var sources: [SourceJson]?
@ -16,8 +16,8 @@ struct PluginListJson: Codable {
// Color: Hex value
public struct PluginTagJson: Codable, Hashable, Sendable {
let name: String
let colorHex: String?
public let name: String
public let colorHex: String?
enum CodingKeys: String, CodingKey {
case name
@ -28,12 +28,5 @@ public struct PluginTagJson: Codable, Hashable, Sendable {
extension PluginManager {
enum PluginManagerError: Error {
case ListAddition(description: String)
case ActionAddition(description: String)
case PluginFetch(description: String)
}
struct AvailablePlugins {
let availableSources: [SourceJson]
let availableActions: [ActionJson]
}
}

View file

@ -7,7 +7,21 @@
import Foundation
extension Premiumize {
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 {
@ -19,7 +33,8 @@ extension Premiumize {
struct DDLResponse: Codable {
let status: String
let content: [DDLData]?
let content: [DDLData]
let location: String
let filename: String
let filesize: Int
}
@ -30,12 +45,27 @@ extension Premiumize {
let path: String
let size: Int
let link: String
let streamLink: String
enum CodingKeys: String, CodingKey {
case path, size, link
case streamLink = "stream_link"
}
}
// MARK: - InstantAvailability client side structures
struct IA: Codable, Hashable {
let magnet: Magnet
let expiryTimeStamp: Double
let files: [IAFile]
}
struct IAFile: Codable, Hashable {
let name: String
let streamUrlString: String
}
// MARK: - AllItemsResponse (listall endpoint)
struct AllItemsResponse: Codable {

View file

@ -8,7 +8,21 @@
import Foundation
extension RealDebrid {
public extension RealDebrid {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum RDError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
// MARK: - device code endpoint
struct DeviceCodeResponse: Codable, Sendable {
@ -58,7 +72,7 @@ extension RealDebrid {
struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) {
@ -67,16 +81,23 @@ extension RealDebrid {
}
}
struct InstantAvailabilityData: Codable, Sendable {
internal struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
struct InstantAvailabilityInfo: Codable, Sendable {
internal struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String
var filesize: Int
}
// MARK: - Instant Availability batch structures (used for client-side conversion)
// 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]
@ -87,6 +108,12 @@ extension RealDebrid {
let fileName: String
}
struct IAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
// MARK: - addMagnet endpoint
struct AddMagnetResponse: Codable, Sendable {
@ -96,7 +123,7 @@ extension RealDebrid {
// MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable, Sendable {
internal struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
@ -117,7 +144,7 @@ extension RealDebrid {
}
}
struct TorrentInfoFile: Codable, Sendable {
internal struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
@ -136,7 +163,7 @@ extension RealDebrid {
// MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable, Sendable {
internal struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int

View file

@ -7,56 +7,11 @@
import Foundation
// A raw search result structure displayed on the UI
struct SearchResult: Codable, Hashable, Sendable {
public struct SearchResult: Codable, Hashable, Sendable {
let title: String?
let source: String
let size: String?
let magnet: Magnet
let seeders: String?
let leechers: String?
// Converts size to a double
func rawSize() -> Double? {
guard let size else {
return nil
}
let splitSize = size.split(separator: " ")
guard
let bytesString = splitSize.first,
let multipliedBytes = Double(bytesString),
let units = splitSize.last
else {
return nil
}
switch units.lowercased() {
case "gb":
return multipliedBytes * 1e9
case "gib":
return multipliedBytes * pow(1024, 3)
case "mb":
return multipliedBytes * 1e6
case "mib":
return multipliedBytes * pow(1024, 2)
case "kb":
return multipliedBytes * 1e3
case "kib":
return multipliedBytes * 1024
default:
return nil
}
}
}
extension ScrapingViewModel {
// Contains both search results and magnet links for scalability
struct SearchRequestResult: Sendable {
let results: [SearchResult]
let magnets: [Magnet]
}
struct ScrapingError: Error {}
}

View file

@ -1,19 +0,0 @@
//
// SettingsModels.swift
// Ferrite
//
// Created by Brian Dashore on 3/20/23.
//
import Foundation
enum DefaultAction: Codable, CaseIterable, Hashable {
static var allCases: [DefaultAction] {
[.none, .share, .kodi, .custom(name: "", listId: "")]
}
case none
case share
case kodi
case custom(name: String, listId: String)
}

View file

@ -7,51 +7,49 @@
import Foundation
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case json
case text
}
struct SourceJson: Codable, Hashable, Sendable, PluginJson {
let name: String
let version: Int16
public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
public let name: String
public let version: Int16
let minVersion: String?
let about: String?
let website: String?
let dynamicWebsite: Bool?
let baseUrl: String?
let fallbackUrls: [String]?
let dynamicBaseUrl: Bool?
let trackers: [String]?
let api: SourceApiJson?
let jsonParser: SourceJsonParserJson?
let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson?
let author: String?
let listId: UUID?
let listName: String?
let tags: [PluginTagJson]?
public var author: String?
public var listId: UUID?
public var tags: [PluginTagJson]?
}
extension SourceJson {
// Fetches all tags without optional requirement
func getTags() -> [PluginTagJson] {
tags ?? []
public func getTags() -> [PluginTagJson] {
return tags ?? []
}
}
enum SourcePreferredParser: Int16, CaseIterable, Sendable {
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
// case none = 0
case scraping = 1
case rss = 2
case siteApi = 3
}
struct SourceApiJson: Codable, Hashable, Sendable {
public struct SourceApiJson: Codable, Hashable, Sendable {
let apiUrl: String?
let clientId: SourceApiCredentialJson?
let clientSecret: SourceApiCredentialJson?
}
struct SourceApiCredentialJson: Codable, Hashable, Sendable {
public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let query: String?
let value: String?
let dynamic: Bool?
@ -60,58 +58,55 @@ struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let expiryLength: Double?
}
struct SourceJsonParserJson: Codable, Hashable, Sendable {
public struct SourceJsonParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let request: SourceRequestJson?
let results: String?
let subResults: String?
let title: SourceComplexQueryJson
let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
struct SourceRssParserJson: Codable, Hashable, Sendable {
public struct SourceRssParserJson: Codable, Hashable, Sendable {
let rssUrl: String?
let searchUrl: String
let request: SourceRequestJson?
let items: String
let title: SourceComplexQueryJson
let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String?
let request: SourceRequestJson?
public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let rows: String
let title: SourceComplexQueryJson
let magnet: SourceMagnetJson
let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
struct SourceComplexQueryJson: Codable, Hashable, Sendable {
public struct SourceComplexQueryJson: Codable, Hashable, Sendable {
let query: String
let discriminator: String?
let attribute: String?
let regex: String?
}
struct SourceMagnetJson: Codable, Hashable, Sendable {
public struct SourceMagnetJson: Codable, Hashable, Sendable {
let query: String
let attribute: String
let regex: String?
let externalLinkQuery: String?
}
struct SourceSLJson: Codable, Hashable, Sendable {
public struct SourceSLJson: Codable, Hashable, Sendable {
let seeders: String?
let leechers: String?
let combined: String?
@ -120,9 +115,3 @@ struct SourceSLJson: Codable, Hashable, Sendable {
let seederRegex: String?
let leecherRegex: String?
}
struct SourceRequestJson: Codable, Hashable, Sendable {
let method: String?
let headers: [String: String]?
let body: String?
}

View file

@ -1,110 +0,0 @@
//
// TorBoxModels.swift
// Ferrite
//
// Created by Brian Dashore on 6/11/24.
//
import Foundation
extension TorBox {
struct TBResponse<TBData: Codable>: Codable {
let success: Bool
let detail: String
let data: TBData?
}
// MARK: - InstantAvailability
enum InstantAvailabilityData: Codable {
case links([InstantAvailabilityDataObject])
case failure(InstantAvailabilityDataFailure)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// Only continue if the data is a List which indicates a success
if let linkArray = try? container.decode([InstantAvailabilityDataObject].self) {
self = .links(linkArray)
} else {
let value = try container.decode(InstantAvailabilityDataFailure.self)
self = .failure(value)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .links(array):
try container.encode(array)
case let .failure(value):
try container.encode(value)
}
}
}
struct InstantAvailabilityDataObject: Codable, Sendable {
let name: String
let size: Int
let hash: String
let files: [InstantAvailabilityFile]
}
struct InstantAvailabilityFile: Codable, Sendable {
let name: String
let size: Int
}
struct InstantAvailabilityDataFailure: Codable, Sendable {
let data: Bool
}
struct CreateTorrentResponse: Codable, Sendable {
let hash: String
let torrentId: Int
let authId: String
enum CodingKeys: String, CodingKey {
case hash
case torrentId = "torrent_id"
case authId = "auth_id"
}
}
struct MyTorrentListResponse: Codable, Sendable {
let id: Int
let hash: String
let name: String
let downloadState: String
let files: [MyTorrentListFile]
enum CodingKeys: String, CodingKey {
case id, hash, name, files
case downloadState = "download_state"
}
}
struct MyTorrentListFile: Codable, Sendable {
let id: Int
let hash: String
let name: String
let shortName: String
enum CodingKeys: String, CodingKey {
case id, hash, name
case shortName = "short_name"
}
}
typealias RequestDLResponse = String
struct ControlTorrentRequest: Codable, Sendable {
let torrentId: String
let operation: String
enum CodingKeys: String, CodingKey {
case operation
case torrentId = "torrent_id"
}
}
}

View file

@ -1,83 +0,0 @@
//
// Debrid.swift
// Ferrite
//
// Created by Brian Dashore on 6/1/24.
//
import Foundation
protocol DebridSource: AnyObservableObject {
// ID of the service
// var id: DebridInfo { get }
var id: String { get }
var abbreviation: String { get }
var website: String { get }
var description: String? { get }
var cachedStatus: [String] { get }
// Auth variables
var authProcessing: Bool { get set }
var isLoggedIn: Bool { get }
// Manual API key
var manualToken: String? { get }
// Instant availability variables
var IAValues: [DebridIA] { get set }
// Cloud variables
var cloudDownloads: [DebridCloudDownload] { get set }
var cloudMagnets: [DebridCloudMagnet] { get set }
var cloudTTL: Double { get set }
// Common authentication functions
func setApiKey(_ key: String)
func logout() async
// Instant availability functions
func instantAvailability(magnets: [Magnet]) async throws
// Fetches a download link from a source
// Include the instant availability information with the args
// Cloud magnets also checked here
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?)
// Unrestricts a locked file
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String
// User downloads functions
func getUserDownloads() async throws
func checkUserDownloads(link: String) async throws -> String?
func deleteUserDownload(downloadId: String) async throws
// User magnet functions
func getUserMagnets() async throws
func deleteUserMagnet(cloudMagnetId: String?) async throws
}
extension DebridSource {
var description: String? {
nil
}
var cachedStatus: [String] {
[]
}
}
protocol PollingDebridSource: DebridSource {
// Task reference for polling
var authTask: Task<Void, Error>? { get set }
// Fetches the Auth URL
func getAuthUrl() async throws -> URL
}
protocol OAuthDebridSource: DebridSource {
// Fetches the auth URL
func getAuthUrl() throws -> URL
// Handles an OAuth callback
func handleAuthCallback(url: URL) throws
}

View file

@ -8,14 +8,12 @@
import CoreData
import Foundation
protocol Plugin: ObservableObject, NSManagedObject {
public protocol Plugin: ObservableObject, NSManagedObject {
var id: UUID { get set }
var listId: UUID? { get set }
var name: String { get set }
var version: Int16 { get set }
var author: String { get set }
var about: String? { get set }
var website: String? { get set }
var enabled: Bool { get set }
var tags: NSOrderedSet? { get set }
func getTags() -> [PluginTagJson]
@ -23,16 +21,15 @@ protocol Plugin: ObservableObject, NSManagedObject {
extension Plugin {
var tagArray: [PluginTag] {
tags?.array as? [PluginTag] ?? []
return self.tags?.array as? [PluginTag] ?? []
}
}
protocol PluginJson: Hashable {
public protocol PluginJson: Hashable {
var name: String { get }
var version: Int16 { get }
var author: String? { get }
var listId: UUID? { get }
var listName: String? { get }
var tags: [PluginTagJson]? { get }
var author: String? { get set }
var listId: UUID? { get set }
var tags: [PluginTagJson]? { get set }
func getTags() -> [PluginTagJson]
}

View file

@ -1,36 +0,0 @@
//
// CodableWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 3/20/23.
//
// From https://forums.swift.org/t/rawrepresentable-conformance-leads-to-crash/51912/4
// Prevents recursion when using Codable with RawRepresentable without needing manual conformance
import Foundation
struct CodableWrapper<Value: Codable> {
var value: Value
}
extension CodableWrapper: RawRepresentable {
var rawValue: String {
guard
let data = try? JSONEncoder().encode(value),
let string = String(data: data, encoding: .utf8)
else {
return ""
}
return string
}
init?(rawValue: String) {
guard
let data = rawValue.data(using: .utf8),
let decoded = try? JSONDecoder().decode(Value.self, from: data)
else {
return nil
}
value = decoded
}
}

View file

@ -1,13 +0,0 @@
//
// FerriteKeychain.swift
// Ferrite
//
// Created by Brian Dashore on 4/30/23.
//
import Foundation
import KeychainSwift
class FerriteKeychain {
static let shared = KeychainSwift()
}

View file

@ -1,27 +0,0 @@
//
// FormDataBody.swift
// Ferrite
//
// Created by Brian Dashore on 6/12/24.
//
import Foundation
struct FormDataBody {
let boundary: String = UUID().uuidString
let body: Data
init(params: [String: String]) {
var body = Data()
for (key, value) in params {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
self.body = body
}
}

View file

@ -1,147 +0,0 @@
//
// Store.swift
// Ferrite
//
//
// Originally created by William Baker on 09/06/2022.
// https://github.com/Tiny-Home-Consulting/Dependiject/blob/master/Dependiject/Store.swift
// Copyright (c) 2022 Tiny Home Consulting LLC. All rights reserved.
//
// Combined together by Brian Dashore
//
// TODO: Replace with Observable when minVersion >= iOS 17
//
import Combine
import SwiftUI
class ErasedObservableObject: ObservableObject {
let objectWillChange: AnyPublisher<Void, Never>
init(objectWillChange: AnyPublisher<Void, Never>) {
self.objectWillChange = objectWillChange
}
static func empty() -> ErasedObservableObject {
.init(objectWillChange: Empty().eraseToAnyPublisher())
}
}
protocol AnyObservableObject: AnyObject {
var objectWillChange: ObservableObjectPublisher { get }
}
// The generic type names were chosen to match the SwiftUI equivalents:
// - ObjectType from StateObject<ObjectType> and ObservedObject<ObjectType>
// - Subject from ObservedObject.Wrapper.subscript<Subject>(dynamicMember:)
// - S from Publisher.receive<S>(on:options:)
/// A property wrapper used to wrap injected observable objects.
///
/// This is similar to SwiftUI's
/// [`StateObject`](https://developer.apple.com/documentation/swiftui/stateobject), but without
/// compile-time type restrictions. The lack of compile-time restrictions means that `ObjectType`
/// may be a protocol rather than a class.
///
/// - Important: At runtime, the wrapped value must conform to ``AnyObservableObject``.
///
/// To pass properties of the observable object down the view hierarchy as bindings, use the
/// projected value:
/// ```swift
/// struct ExampleView: View {
/// @Store var viewModel = Factory.shared.resolve(ViewModelProtocol.self)
///
/// var body: some View {
/// TextField("username", text: $viewModel.username)
/// }
/// }
/// ```
/// Not all injected objects need this property wrapper. See the example projects for examples each
/// way.
@propertyWrapper
struct Store<ObjectType> {
/// The underlying object being stored.
let wrappedValue: ObjectType
// See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38
fileprivate var _observableObject: ObservedObject<ErasedObservableObject>
@MainActor var observableObject: ErasedObservableObject {
_observableObject.wrappedValue
}
/// A projected value which has the same properties as the wrapped value, but presented as
/// bindings.
///
/// Use this to pass bindings down the view hierarchy:
/// ```swift
/// struct ExampleView: View {
/// @Store var viewModel = Factory.shared.resolve(ViewModelProtocol.self)
///
/// var body: some View {
/// TextField("username", text: $viewModel.username)
/// }
/// }
/// ```
var projectedValue: Wrapper {
Wrapper(self)
}
/// Create a stored value on a custom scheduler.
///
/// Use this init to schedule updates on a specific scheduler other than `DispatchQueue.main`.
init<S: Scheduler>(wrappedValue: ObjectType,
on scheduler: S,
schedulerOptions: S.SchedulerOptions? = nil)
{
self.wrappedValue = wrappedValue
if let observable = wrappedValue as? AnyObservableObject {
let objectWillChange = observable.objectWillChange
.receive(on: scheduler, options: schedulerOptions)
.eraseToAnyPublisher()
_observableObject = .init(initialValue: .init(objectWillChange: objectWillChange))
} else {
assertionFailure(
"Only use the Store property wrapper with objects conforming to AnyObservableObject."
)
_observableObject = .init(initialValue: .empty())
}
}
/// Create a stored value which publishes on the main thread.
///
/// To control when updates are published, see ``init(wrappedValue:on:schedulerOptions:)``.
init(wrappedValue: ObjectType) {
self.init(wrappedValue: wrappedValue, on: DispatchQueue.main)
}
/// An equivalent to SwiftUI's
/// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper)
/// type.
@dynamicMemberLookup
struct Wrapper {
private var store: Store
init(_ store: Store<ObjectType>) {
self.store = store
}
/// Returns a binding to the resulting value of a given key path.
subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>
) -> Binding<Subject> {
Binding {
self.store.wrappedValue[keyPath: keyPath]
} set: {
self.store.wrappedValue[keyPath: keyPath] = $0
}
}
}
}
extension Store: DynamicProperty {
nonisolated mutating func update() {
_observableObject.update()
}
}

View file

@ -7,11 +7,11 @@
import Foundation
class BackupManager: ObservableObject {
public class BackupManager: ObservableObject {
// Constant variable for backup versions
private let latestBackupVersion: Int = 2
let latestBackupVersion: Int = 2
var logManager: LoggingManager?
var toastModel: ToastViewModel?
@Published var showRestoreAlert = false
@Published var showRestoreCompletedAlert = false
@ -21,17 +21,17 @@ class BackupManager: ObservableObject {
@Published var selectedBackupUrl: URL?
@MainActor
private func updateRestoreCompletedMessage(newString: String) {
func updateRestoreCompletedMessage(newString: String) {
restoreCompletedMessage.append(newString)
}
@MainActor
private func toggleRestoreCompletedAlert() {
func toggleRestoreCompletedAlert() {
showRestoreCompletedAlert.toggle()
}
@MainActor
private func updateBackupUrls(newUrl: URL) {
func updateBackupUrls(newUrl: URL) {
backupUrls.append(newUrl)
}
@ -110,18 +110,17 @@ class BackupManager: ObservableObject {
await updateBackupUrls(newUrl: writeUrl)
} catch {
await logManager?.error("Backup: \(error)")
await toastModel?.updateToastDescription("Backup error: \(error)")
print("Backup error: \(error)")
}
}
// Backup is in local documents directory, so no need to restore it from the shared URL
// Pass the pluginManager reference since it's not used throughout the class like logManager
// Pass the pluginManager reference since it's not used throughout the class like toastModel
func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async {
guard let backupUrl = selectedBackupUrl else {
await logManager?.error(
"Backup restore: Could not find backup in app directory.",
description: "Could not find the selected backup in the local directory."
)
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
print("Backup restore error: Could not find backup in app directory.")
return
}
@ -186,12 +185,17 @@ class BackupManager: ObservableObject {
PersistenceController.shared.save(backgroundContext)
await toggleRestoreCompletedAlert()
// if iOS 14 is available, sleep to prevent any issues with alerts
if #available(iOS 15, *) {
await toggleRestoreCompletedAlert()
} else {
try? await Task.sleep(seconds: 0.1)
await toggleRestoreCompletedAlert()
}
} catch {
await logManager?.error(
"Backup restore: \(error)",
description: "A backup restore error was logged"
)
await toastModel?.updateToastDescription("Backup restore error: \(error)")
print("Backup restore error: \(error)")
}
}
@ -208,8 +212,8 @@ class BackupManager: ObservableObject {
}
} catch {
Task {
await logManager?.error("Backup removal: \(error)")
print("Backup removal: \(error)")
await toastModel?.updateToastDescription("Backup removal error: \(error)")
print("Backup removal error: \(error)")
}
}
}
@ -238,7 +242,7 @@ class BackupManager: ObservableObject {
selectedBackupUrl = localBackupPath
} catch {
Task {
await logManager?.error("Backup copy: \(error)")
await toastModel?.updateToastDescription("Backup copy: \(error)")
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,181 +0,0 @@
//
// LoggingManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/19/22.
//
import SwiftUI
@MainActor
class LoggingManager: ObservableObject {
let logFormatter = DateFormatter()
struct Log: Hashable {
let level: LogLevel
let message: String
let timeStamp: Date = .init()
var isExpanded: Bool = false
func toMessage() -> String {
"[\(level.rawValue)]: \(message)"
}
}
enum LogLevel: String, Identifiable {
var id: Int {
hashValue
}
case info = "INFO"
case warn = "WARN"
case error = "ERROR"
}
@Published var messageArray: [Log] = []
@Published var showLogExportedAlert = false
// Toast variables
@Published var toastDescription: String? = nil {
didSet {
Task {
try? await Task.sleep(seconds: 0.1)
showToast = true
try? await Task.sleep(seconds: 3)
showToast = false
toastType = .error
}
}
}
@Published var showToast: Bool = false
// Default the toast type to error since the majority of toasts are errors
@Published var toastType: LogLevel = .error
var showErrorToasts: Bool {
UserDefaults.standard.bool(forKey: "Debug.ShowErrorToasts")
}
@Published var indeterminateToastDescription: String? = nil
@Published var indeterminateCancelAction: (() -> Void)? = nil
@Published var showIndeterminateToast: Bool = false
init() {
logFormatter.dateStyle = .short
logFormatter.timeStyle = .long
}
// MARK: - Logging functions
// TODO: Maybe append to a constant logfile?
func info(_ message: String,
description: String? = nil)
{
let log = Log(
level: .info,
message: message
)
if let description {
toastType = .info
toastDescription = description
}
messageArray.append(log)
print("LOG: \(log.toMessage())")
}
func warn(_ message: String,
description: String? = nil)
{
let log = Log(
level: .warn,
message: message
)
if let description {
toastType = .warn
toastDescription = description
}
messageArray.append(log)
print("LOG: \(log.toMessage())")
}
func error(_ message: String,
description: String? = nil,
showToast: Bool = true)
{
let log = Log(
level: .error,
message: message
)
// If a task is run in parallel, don't show a toast on error
// Only gate generic error toasts behind the settings option
if showToast {
if let description {
toastDescription = description
} else if showErrorToasts {
toastDescription = "An error was logged. Please look at logs in Settings."
}
}
messageArray.append(log)
print("LOG: \(log.toMessage())")
}
// MARK: - Indeterminate functions
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
indeterminateToastDescription = description
if let cancelAction {
indeterminateCancelAction = cancelAction
}
if !showIndeterminateToast {
showIndeterminateToast = true
}
}
func hideIndeterminateToast() {
showIndeterminateToast = false
indeterminateToastDescription = ""
indeterminateCancelAction = nil
}
func exportLogs() {
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")
let logPath = logFolderPath.appendingPathComponent(logFileName)
logFormatter.dateStyle = .short
logFormatter.timeStyle = .long
let joinedMessages = messageArray.map { "\(logFormatter.string(from: $0.timeStamp)): \($0.toMessage())" }.joined(separator: "\n")
do {
if FileManager.default.fileExists(atPath: logPath.path) {
try FileManager.default.removeItem(at: logPath)
} else if !FileManager.default.fileExists(atPath: logFolderPath.path) {
try FileManager.default.createDirectory(atPath: logFolderPath.path, withIntermediateDirectories: true, attributes: nil)
}
try joinedMessages.write(to: logPath, atomically: true, encoding: .utf8)
info("Log \(logFileName) was written to path \(logPath.description)")
showLogExportedAlert.toggle()
} catch {
self.error(
"Log export for file \(logFileName): \(error)",
description: "Exporting your log file failed. Please check the logs page."
)
}
}
}

View file

@ -8,12 +8,12 @@
import SwiftUI
@MainActor
class NavigationViewModel: ObservableObject {
var logManager: LoggingManager?
public class NavigationViewModel: ObservableObject {
var toastModel: ToastViewModel?
// Used between SearchResultsView and MagnetChoiceView
enum ChoiceSheetType: Identifiable {
var id: Int {
public enum ChoiceSheetType: Identifiable {
public var id: Int {
hashValue
}
@ -48,37 +48,7 @@ class NavigationViewModel: ObservableObject {
@Published var selectedTitle: String = ""
@Published var selectedBatchTitle: String = ""
// For filters
@Published var enabledFilters: Set<FilterType> = []
@Published var currentSortFilter: SortFilter?
@Published var currentSortOrder: SortOrder = .forward
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
switch currentSortFilter {
case .leechers:
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
return false
}
return currentSortOrder == .forward ? lhsLeechers > rhsLeechers : lhsLeechers < rhsLeechers
case .seeders:
guard let lhsSeeders = lhs.seeders, let rhsSeeders = rhs.seeders else {
return false
}
return currentSortOrder == .forward ? lhsSeeders > rhsSeeders : lhsSeeders < rhsSeeders
case .size:
guard let lhsSize = lhs.rawSize(), let rhsSize = rhs.rawSize() else {
return false
}
return currentSortOrder == .forward ? lhsSize > rhsSize : lhsSize < rhsSize
case .none:
return false
}
}
@Published var kodiExpanded: Bool = false
@Published var hideNavigationBar = false
@Published var currentChoiceSheet: ChoiceSheetType?
var activityItems: [Any] = []
@ -88,37 +58,12 @@ class NavigationViewModel: ObservableObject {
@Published var selectedTab: ViewTab = .search
// Used between service views and editor views in Settings
@Published var selectedPluginList: PluginList?
@Published var selectedKodiServer: KodiServer?
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
var selectedSource: Source?
@Published var showSourceListEditor: Bool = false
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
@Published var pluginPickerSelection: PluginPickerSegment = .sources
@Published var searchPrompt: String = "Search"
@Published var lastSearchPromptIndex: Int = -1
private let searchBarTextArray: [String] = [
"What's on your mind?",
"Discover something interesting",
"Find an engaging show",
"Feeling adventurous?",
"Look for something new",
"The classics are a good idea"
]
func getSearchPrompt() {
if UserDefaults.standard.bool(forKey: "Behavior.UsesRandomSearchText") {
let num = Int.random(in: 0 ..< searchBarTextArray.count - 1)
if num == lastSearchPromptIndex {
lastSearchPromptIndex = num + 1
searchPrompt = searchBarTextArray[safe: num + 1] ?? "Search"
} else {
lastSearchPromptIndex = num
searchPrompt = searchBarTextArray[safe: num] ?? "Search"
}
} else {
lastSearchPromptIndex = -1
searchPrompt = "Search"
}
}
}

View file

@ -1,5 +1,5 @@
//
// PluginManager.swift
// SourceManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/25/22.
@ -7,208 +7,105 @@
import Foundation
import SwiftUI
import Yams
class PluginManager: ObservableObject {
var logManager: LoggingManager?
let kodi: Kodi = .init()
public class PluginManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var availableSources: [SourceJson] = []
@Published var availableActions: [ActionJson] = []
@Published var filteredInstalledSources: Set<Source> = []
@Published var showActionErrorAlert = false
@Published var actionErrorAlertMessage: String = ""
@Published var showActionSuccessAlert = false
@Published var actionSuccessAlertMessage: String = ""
@Published var showBrokenDefaultActionAlert = false
@MainActor
private func cleanAvailablePlugins() {
availableSources = []
availableActions = []
}
@MainActor
private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
availableSources += newPlugins.availableSources
availableActions += newPlugins.availableActions
}
func fetchPluginsFromUrl() async {
public func fetchPluginsFromUrl() async {
let pluginListRequest = PluginList.fetchRequest()
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
await logManager?.error("PluginManager: No plugin lists found")
return
}
do {
let pluginLists = try PersistenceController.shared.backgroundContext.fetch(pluginListRequest)
// Clean availablePlugin arrays for repopulation
await cleanAvailablePlugins()
// Clean availablePlugin arrays for repopulation
availableSources = []
availableActions = []
await logManager?.info("Starting fetch of plugin lists")
await withTaskGroup(of: (AvailablePlugins?, String).self) { group in
for pluginList in pluginLists {
guard let url = URL(string: pluginList.urlString) else {
return
}
group.addTask {
var availablePlugins: AvailablePlugins?
// Always get the up-to-date source list
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
do {
availablePlugins = try await self.fetchPluginList(pluginList: pluginList, url: url)
} catch {
let error = error as NSError
let (data, _) = try await URLSession.shared.data(for: request)
let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
switch error.code {
case -999:
await self.logManager?.info("PluginManager: \(pluginList.name): List fetch cancelled")
case -1009:
await self.logManager?.info("PluginManager: \(pluginList.name): The connection is offline")
default:
await self.logManager?.error("Plugin fetch: \(pluginList.name): \(error)", showToast: false)
if let sources = pluginResponse.sources {
// Faster and more performant to map instead of a for loop
availableSources += sources.compactMap { inputJson in
if checkAppVersion(minVersion: inputJson.minVersion) {
return SourceJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
baseUrl: inputJson.baseUrl,
fallbackUrls: inputJson.fallbackUrls,
dynamicBaseUrl: inputJson.dynamicBaseUrl,
trackers: inputJson.trackers,
api: inputJson.api,
jsonParser: inputJson.jsonParser,
rssParser: inputJson.rssParser,
htmlParser: inputJson.htmlParser,
author: pluginList.author,
listId: pluginList.id,
tags: inputJson.tags
)
} else {
return nil
}
}
}
return (availablePlugins, pluginList.name)
if let actions = pluginResponse.actions {
availableActions += actions.compactMap { inputJson in
if checkAppVersion(minVersion: inputJson.minVersion) {
return ActionJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
requires: inputJson.requires,
deeplink: inputJson.deeplink,
author: pluginList.author,
listId: pluginList.id,
tags: inputJson.tags
)
} else {
return nil
}
}
}
}
var failedLists: [String] = []
for await (availablePlugins, pluginListName) in group {
if let availablePlugins {
await updateAvailablePlugins(availablePlugins)
} else {
failedLists.append(pluginListName)
}
} catch {
let error = error as NSError
if error.code != -999 {
toastModel?.updateToastDescription("Plugin fetch error: \(error)")
}
if !failedLists.isEmpty {
let joinedLists = failedLists.joined(separator: ", ")
await logManager?.info(
"Plugins: Errors in plugin lists \(joinedLists). See above.",
description: "There were errors in plugin lists \(joinedLists). Check the logs for more details."
)
}
}
await logManager?.info("Plugin list fetch finished")
}
private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
var tempSources: [SourceJson] = []
var tempActions: [ActionJson] = []
// Always get the up-to-date source list
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let (data, _) = try await URLSession.shared.data(for: request)
let pluginResponse: PluginListJson?
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
pluginResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
} else {
pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
}
guard let pluginResponse else {
throw PluginManagerError.PluginFetch(description: "Could not decode plugin list data")
}
if let sources = pluginResponse.sources {
// Faster and more performant to map instead of a for loop
tempSources += sources.compactMap { inputJson in
if checkAppVersion(minVersion: inputJson.minVersion) {
return SourceJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
about: inputJson.about,
website: inputJson.website,
dynamicWebsite: inputJson.dynamicWebsite,
fallbackUrls: inputJson.fallbackUrls,
trackers: inputJson.trackers,
api: inputJson.api,
jsonParser: inputJson.jsonParser,
rssParser: inputJson.rssParser,
htmlParser: inputJson.htmlParser,
author: pluginList.author,
listId: pluginList.id,
listName: pluginList.name,
tags: inputJson.tags
)
} else {
return nil
}
}
}
if let actions = pluginResponse.actions {
tempActions += actions.compactMap { inputJson in
if
let deeplink = inputJson.deeplink,
checkAppVersion(minVersion: inputJson.minVersion),
let filteredDeeplinks = getFilteredDeeplinks(deeplink)
{
return ActionJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
about: inputJson.about,
website: inputJson.website,
requires: inputJson.requires,
deeplink: filteredDeeplinks,
author: pluginList.author,
listId: pluginList.id,
listName: pluginList.name,
tags: inputJson.tags
)
} else {
return nil
}
}
}
return AvailablePlugins(availableSources: tempSources, availableActions: tempActions)
}
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
let osArray = deeplinks.filter { deeplink in
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
}
if osArray.count == 1 {
return osArray
} else {
let universalArray = deeplinks.filter { deeplink in
deeplink.os.isEmpty
}
if universalArray.count == 1 {
return universalArray
} else {
return nil
}
print("Plugin fetch error: \(error)")
}
}
// forType required to guide generic inferences
func fetchFilteredPlugins<PJ: PluginJson>(forType: PJ.Type,
installedPlugins: FetchedResults<some Plugin>,
searchText: String) -> [PJ]
{
func fetchFilteredPlugins<P: Plugin, PJ: PluginJson>(
forType: PJ.Type,
installedPlugins: FetchedResults<P>,
searchText: String
) -> [PJ] {
let availablePlugins: [PJ] = fetchCastedPlugins(forType)
return availablePlugins
.filter { availablePlugin in
let pluginExists = installedPlugins.contains(where: {
availablePlugin.name == $0.name &&
availablePlugin.listId == $0.listId &&
availablePlugin.author == $0.author
availablePlugin.listId == $0.listId &&
availablePlugin.author == $0.author
})
if searchText.isEmpty {
@ -219,18 +116,19 @@ class PluginManager: ObservableObject {
}
}
func fetchUpdatedPlugins<PJ: PluginJson>(forType: PJ.Type,
installedPlugins: FetchedResults<some Plugin>,
searchText: String) -> [PJ]
{
func fetchUpdatedPlugins<P: Plugin, PJ: PluginJson>(
forType: PJ.Type,
installedPlugins: FetchedResults<P>,
searchText: String
) -> [PJ] {
var updatedPlugins: [PJ] = []
let availablePlugins: [PJ] = fetchCastedPlugins(forType)
for plugin in installedPlugins {
if let availablePlugin = availablePlugins.first(where: {
plugin.listId == $0.listId &&
plugin.name == $0.name &&
plugin.author == $0.author
plugin.name == $0.name &&
plugin.author == $0.author
}),
availablePlugin.version > plugin.version
{
@ -244,7 +142,7 @@ class PluginManager: ObservableObject {
}
}
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
switch String(describing: PJ.self) {
case "SourceJson":
return availableSources as? [PJ] ?? []
@ -256,7 +154,7 @@ class PluginManager: ObservableObject {
}
// Checks if the current app version is supported by the source
private func checkAppVersion(minVersion: String?) -> Bool {
func checkAppVersion(minVersion: String?) -> Bool {
// If there's no min version, assume that every version is supported
guard let minVersion else {
return true
@ -266,12 +164,10 @@ class PluginManager: ObservableObject {
}
// Fetches sources using the background context
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
public func fetchInstalledSources() -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
return Array(filteredInstalledSources)
} else if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
return sources.compactMap { $0 }
} else {
return []
@ -279,65 +175,63 @@ class PluginManager: ObservableObject {
}
@MainActor
func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
public func runDebridAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) {
let context = PersistenceController.shared.backgroundContext
guard let urlString else {
logManager?.error("Default action: Could not run because the URL is invalid")
return
}
let defaultsKey: String
// Assume this is a magnet link
if urlString.starts(with: "magnet") {
defaultsKey = "Actions.DefaultMagnet"
} else {
defaultsKey = "Actions.DefaultDebrid"
}
if
let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let defaultAction = CodableWrapper<DefaultAction>(rawValue: rawValue)?.value
let defaultDebridActionName = UserDefaults.standard.string(forKey: "Actions.DefaultDebridName"),
let defaultDebridActionList = UserDefaults.standard.string(forKey: "Actions.DefaultDebridList")
{
switch defaultAction {
case .none:
navModel.currentChoiceSheet = .action
case .share:
navModel.activityItems = [urlString]
navModel.currentChoiceSheet = .activity
case .kodi:
navModel.kodiExpanded = true
navModel.currentChoiceSheet = .action
case let .custom(name, listId):
let actionFetchRequest = Action.fetchRequest()
actionFetchRequest.fetchLimit = 1
actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", name, listId)
let actionFetchRequest = Action.fetchRequest()
actionFetchRequest.fetchLimit = 1
actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", defaultDebridActionName, defaultDebridActionList)
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
runDeeplinkAction(fetchedAction, urlString: urlString)
} else {
navModel.currentChoiceSheet = .action
UserDefaults.standard.set(CodableWrapper<DefaultAction>(value: .none).rawValue, forKey: "Actions.DefaultDebrid")
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
runDeeplinkAction(fetchedAction, urlString: urlString)
} else {
currentChoiceSheet = .action
UserDefaults.standard.set(nil, forKey: "Actions.DefaultDebridName")
UserDefaults.standard.set(nil, forKey: "Action.DefaultDebridList")
actionErrorAlertMessage =
"The default action could not be run. The action choice sheet has been opened. \n\n" +
"Please check your default actions in Settings"
showActionErrorAlert.toggle()
}
showBrokenDefaultActionAlert.toggle()
}
} else {
navModel.currentChoiceSheet = .action
currentChoiceSheet = .action
}
}
@MainActor
public func runMagnetAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) {
let context = PersistenceController.shared.backgroundContext
if
let defaultMagnetActionName = UserDefaults.standard.string(forKey: "Actions.DefaultMagnetName"),
let defaultMagnetActionList = UserDefaults.standard.string(forKey: "Actions.DefaultMagnetList")
{
let actionFetchRequest = Action.fetchRequest()
actionFetchRequest.fetchLimit = 1
actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", defaultMagnetActionName, defaultMagnetActionList)
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
runDeeplinkAction(fetchedAction, urlString: urlString)
} else {
currentChoiceSheet = .action
UserDefaults.standard.set(nil, forKey: "Actions.DefaultMagnetName")
UserDefaults.standard.set(nil, forKey: "Actions.DefaultMagnetList")
showBrokenDefaultActionAlert.toggle()
}
} else {
currentChoiceSheet = .action
}
}
// The iOS version of Ferrite only runs deeplink actions
@MainActor
func runDeeplinkAction(_ action: Action, urlString: String?) {
public func runDeeplinkAction(_ action: Action, urlString: String?) {
guard let deeplink = action.deeplink, let urlString else {
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
showActionErrorAlert.toggle()
logManager?.error("Could not run action: \(action.name) since there is no deeplink to execute.")
toastModel?.updateToastDescription("Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!")
print("Could not run action: \(action.name) since there is no deeplink to execute.")
return
}
@ -347,54 +241,30 @@ class PluginManager: ObservableObject {
if let playbackUrl {
UIApplication.shared.open(playbackUrl)
} else {
actionErrorAlertMessage = "Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!"
showActionErrorAlert.toggle()
logManager?.error("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
toastModel?.updateToastDescription("Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!")
print("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
}
}
@MainActor
func sendToKodi(urlString: String?, server: KodiServer) async {
guard let urlString else {
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
showActionErrorAlert.toggle()
logManager?.error("Kodi action: Could not send URL to Kodi since there is no playback URL to send")
return
}
do {
try await kodi.sendVideoUrl(urlString: urlString, server: server)
actionSuccessAlertMessage = "Your URL should be playing on Kodi"
showActionSuccessAlert.toggle()
logManager?.info("URL \(urlString) is playing on Kodi")
} catch {
actionErrorAlertMessage = "Kodi Error: \(error)"
showActionErrorAlert.toggle()
logManager?.error("Kodi action: \(error)")
}
}
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else {
await logManager?.error("Action addition: No action present. Contact the app dev!")
await toastModel?.updateToastDescription("Action addition error: No action present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
if actionJson.requires.count < 1 {
await logManager?.error("Action addition: actions must require an input. Please contact the action dev!")
await toastModel?.updateToastDescription("Action addition error: actions must require an input. Please contact the action dev!")
print("Action name \(actionJson.name) does not have a requires parameter")
return
}
guard let deeplinks = actionJson.deeplink else {
await logManager?.error("Action addition: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
guard let deeplink = actionJson.deeplink else {
await toastModel?.updateToastDescription("Action addition error: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
print("Action name \(actionJson.name) did not have a deeplink")
return
}
@ -406,7 +276,8 @@ class PluginManager: ObservableObject {
if doUpsert {
PersistenceController.shared.delete(existingAction, context: backgroundContext)
} else {
await logManager?.error("Action addition: Could not install action with name \(actionJson.name) because it is already installed")
await toastModel?.updateToastDescription("Could not install action with name \(actionJson.name) because it is already installed")
print("Action name \(actionJson.name) already exists in user's DB")
return
}
@ -416,11 +287,9 @@ class PluginManager: ObservableObject {
newAction.id = UUID()
newAction.name = actionJson.name
newAction.version = actionJson.version
newAction.website = actionJson.website
newAction.about = actionJson.about
newAction.author = actionJson.author ?? "Unknown"
newAction.listId = actionJson.listId
newAction.requires = actionJson.requires.map(\.rawValue)
newAction.requires = actionJson.requires.map { $0.rawValue }
newAction.enabled = true
if let jsonTags = actionJson.tags {
@ -433,33 +302,30 @@ class PluginManager: ObservableObject {
}
}
// Only one deeplink is left in this action JSON because of the previous filtering logic
guard let deeplinkJson = deeplinks.first else {
await logManager?.error("Action addition: No deeplink was present in action with name \(actionJson.name). Contact the action dev!")
return
}
newAction.deeplink = deeplinkJson.scheme
newAction.deeplink = deeplink
do {
try backgroundContext.save()
} catch {
await logManager?.error("Action addition: \(error)")
await toastModel?.updateToastDescription("Action addition error: \(error)")
print("Action addition error: \(error)")
}
}
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
guard let sourceJson else {
await logManager?.error("Source addition: No source present. Contact the app dev!")
await toastModel?.updateToastDescription("Source addition error: No source present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
let dynamicWebsite = sourceJson.dynamicWebsite ?? false
if !dynamicWebsite, sourceJson.website == nil {
await logManager?.error("Not adding this source because website parameters are malformed. Please contact the source dev.")
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
print("Not adding source \(sourceJson.name) because base URL parameters are malformed")
return
}
@ -472,7 +338,9 @@ class PluginManager: ObservableObject {
if doUpsert {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
await logManager?.error("Source addition: Could not install source with name \(sourceJson.name) because it is already installed.")
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
print("Source name \(sourceJson.name) already exists")
return
}
}
@ -481,10 +349,9 @@ class PluginManager: ObservableObject {
newSource.id = UUID()
newSource.name = sourceJson.name
newSource.version = sourceJson.version
newSource.about = sourceJson.about
newSource.website = sourceJson.website
newSource.dynamicWebsite = dynamicWebsite
newSource.fallbackUrls = dynamicWebsite ? nil : sourceJson.fallbackUrls
newSource.dynamicBaseUrl = dynamicBaseUrl
newSource.baseUrl = sourceJson.baseUrl
newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls
newSource.author = sourceJson.author ?? "Unknown"
newSource.listId = sourceJson.listId
newSource.trackers = sourceJson.trackers
@ -531,11 +398,12 @@ class PluginManager: ObservableObject {
do {
try backgroundContext.save()
} catch {
await logManager?.error("Source addition error: \(error)")
await toastModel?.updateToastDescription("Source addition error: \(error)")
print("Source addition error: \(error)")
}
}
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext)
@ -570,8 +438,7 @@ class PluginManager: ObservableObject {
newSource.api = newSourceApi
}
// TODO: Migrate parser addition to a common protocol
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
@ -579,13 +446,6 @@ class PluginManager: ObservableObject {
newSourceJsonParser.results = jsonParserJson.results
newSourceJsonParser.subResults = jsonParserJson.subResults
if let requestJson = newSourceJsonParser.request {
let newParserRequest = SourceRequest(context: backgroundContext)
newParserRequest.method = requestJson.method
newParserRequest.headers = requestJson.headers
newParserRequest.body = requestJson.body
}
// Tune these complex queries to the final JSON parser format
if let magnetLinkJson = jsonParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
@ -614,12 +474,14 @@ class PluginManager: ObservableObject {
newSourceJsonParser.subName = newSourceSubName
}
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = jsonParserJson.title.query
newSourceTitle.attribute = jsonParserJson.title.attribute ?? "text"
newSourceTitle.discriminator = jsonParserJson.title.discriminator
if let titleJson = jsonParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.discriminator = titleJson.discriminator
newSourceJsonParser.title = newSourceTitle
newSourceJsonParser.title = newSourceTitle
}
if let sizeJson = jsonParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
@ -646,7 +508,7 @@ class PluginManager: ObservableObject {
newSource.jsonParser = newSourceJsonParser
}
private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext)
@ -654,13 +516,6 @@ class PluginManager: ObservableObject {
newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items
if let requestJson = newSourceRssParser.request {
let newParserRequest = SourceRequest(context: backgroundContext)
newParserRequest.method = requestJson.method
newParserRequest.headers = requestJson.headers
newParserRequest.body = requestJson.body
}
if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
@ -691,13 +546,15 @@ class PluginManager: ObservableObject {
newSourceRssParser.subName = newSourceSubName
}
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = rssParserJson.title.query
newSourceTitle.attribute = rssParserJson.title.attribute ?? "text"
newSourceTitle.discriminator = rssParserJson.title.discriminator
newSourceTitle.regex = rssParserJson.title.regex
if let titleJson = rssParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.discriminator = titleJson.discriminator
newSourceTitle.regex = titleJson.regex
newSourceRssParser.title = newSourceTitle
newSourceRssParser.title = newSourceTitle
}
if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
@ -725,7 +582,7 @@ class PluginManager: ObservableObject {
newSource.rssParser = newSourceRssParser
}
private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
@ -741,24 +598,16 @@ class PluginManager: ObservableObject {
newSourceHtmlParser.subName = newSourceSubName
}
if let requestJson = htmlParserJson.request {
print(requestJson)
let newParserRequest = SourceRequest(context: backgroundContext)
newParserRequest.method = requestJson.method
newParserRequest.headers = requestJson.headers
newParserRequest.body = requestJson.body
// Adds a title complex query if present
if let titleJson = htmlParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.regex = titleJson.regex
newSourceHtmlParser.request = newParserRequest
newSourceHtmlParser.title = newSourceTitle
}
// Adds a title complex query
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = htmlParserJson.title.query
newSourceTitle.attribute = htmlParserJson.title.attribute ?? "text"
newSourceTitle.regex = htmlParserJson.title.regex
newSourceHtmlParser.title = newSourceTitle
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
@ -795,41 +644,25 @@ class PluginManager: ObservableObject {
// Adds a plugin list
// Can move this to PersistenceController if needed
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
public func addPluginList(_ url: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
if url.isEmpty || URL(string: url) == nil {
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
}
guard let url = URL(string: urlString) else {
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
}
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
let rawResponse: PluginListJson?
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
rawResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
} else {
rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
}
guard let rawResponse else {
throw PluginManagerError.ListAddition(description: "Could not decode the plugin list from URL \(urlString)")
}
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: url)!))
let rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
if let existingPluginList {
existingPluginList.urlString = urlString
existingPluginList.urlString = url
existingPluginList.name = rawResponse.name
existingPluginList.author = rawResponse.author
try PersistenceController.shared.container.viewContext.save()
} else {
let pluginListRequest = PluginList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
let urlPredicate = NSPredicate(format: "urlString == %@", url)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
pluginListRequest.fetchLimit = 1
@ -842,7 +675,7 @@ class PluginManager: ObservableObject {
let newPluginList = PluginList(context: backgroundContext)
newPluginList.id = UUID()
newPluginList.urlString = urlString
newPluginList.urlString = url
newPluginList.name = rawResponse.name
newPluginList.author = rawResponse.author

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
//
// ToastViewModel.swift
// Ferrite
//
// Created by Brian Dashore on 7/19/22.
//
import SwiftUI
@MainActor
class ToastViewModel: ObservableObject {
enum ToastType: Identifiable {
var id: Int {
hashValue
}
case info
case error
}
// Toast variables
@Published var toastDescription: String? = nil {
didSet {
Task {
try? await Task.sleep(seconds: 0.1)
showToast = true
try? await Task.sleep(seconds: 5)
showToast = false
toastType = .error
}
}
}
@Published var showToast: Bool = false
@Published var indeterminateToastDescription: String? = nil
@Published var indeterminateCancelAction: (() -> ())? = nil
@Published var showIndeterminateToast: Bool = false
public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) {
if let newToastType {
toastType = newToastType
}
toastDescription = description
}
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> ())?) {
indeterminateToastDescription = description
if let cancelAction {
indeterminateCancelAction = cancelAction
}
if !showIndeterminateToast {
showIndeterminateToast = true
}
}
public func hideIndeterminateToast() {
showIndeterminateToast = false
indeterminateToastDescription = ""
indeterminateCancelAction = nil
}
// Default the toast type to error since the majority of toasts are errors
@Published var toastType: ToastType = .error
}

View file

@ -31,7 +31,7 @@ struct AboutView: View {
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
.textCase(.none)
.foregroundColor(.init(uiColor: .label))
.foregroundColor(.label)
.font(.body)
.padding(.top, 8)
.padding(.bottom, 20)

View file

@ -0,0 +1,71 @@
//
// AlertButton.swift
// Ferrite
//
// Created by Brian Dashore on 9/8/22.
//
// Universal alert button for dynamic alert views
//
import SwiftUI
struct AlertButton: Identifiable {
enum Role {
case destructive
case cancel
}
let id: UUID
let label: String
let action: () -> Void
let role: Role?
// Used for all buttons
init(_ label: String, role: Role? = nil, action: @escaping () -> Void) {
id = UUID()
self.label = label
self.action = action
self.role = role
}
// Used for buttons with no action
init(_ label: String? = nil, role: Role? = nil) {
id = UUID()
self.label = label ?? (role == .cancel ? "Cancel" : "OK")
action = {}
self.role = role
}
func toActionButton() -> Alert.Button {
if let role {
switch role {
case .cancel:
return .cancel(Text(label))
case .destructive:
return .destructive(Text(label), action: action)
}
} else {
return .default(Text(label), action: action)
}
}
@available(iOS 15.0, *)
@ViewBuilder
func toButtonView() -> some View {
Button(label, role: toButtonRole(role), action: action)
}
@available(iOS 15.0, *)
func toButtonRole(_ role: Role?) -> ButtonRole? {
if let role {
switch role {
case .destructive:
return .destructive
case .cancel:
return .cancel
}
} else {
return nil
}
}
}

View file

@ -0,0 +1,140 @@
//
// Backport.swift
// Ferrite
//
// Created by Brian Dashore on 9/29/22.
//
import SwiftUI
import Introspect
public struct Backport<Content> {
public let content: Content
public init(_ content: Content) {
self.content = content
}
}
extension View {
var backport: Backport<Self> { Backport(self) }
}
extension Backport where Content: View {
@ViewBuilder func alert(
isPresented: Binding<Bool>,
title: String,
message: String?,
buttons: [AlertButton] = []
) -> some View {
if #available(iOS 15, *) {
content
.alert(
title,
isPresented: isPresented,
actions: {
ForEach(buttons) { button in
button.toButtonView()
}
},
message: {
if let message {
Text(message)
}
}
)
} else {
content
.background {
Color.clear
.alert(isPresented: isPresented) {
if let primaryButton = buttons[safe: 0],
let secondaryButton = buttons[safe: 1]
{
return Alert(
title: Text(title),
message: message.map { Text($0) } ?? nil,
primaryButton: primaryButton.toActionButton(),
secondaryButton: secondaryButton.toActionButton()
)
} else {
return Alert(
title: Text(title),
message: message.map { Text($0) } ?? nil,
dismissButton: buttons[safe: 0].map { $0.toActionButton() } ?? .cancel()
)
}
}
}
}
}
@ViewBuilder func confirmationDialog(
isPresented: Binding<Bool>,
title: String, message: String?,
buttons: [AlertButton]
) -> some View {
if #available(iOS 15, *) {
content
.confirmationDialog(
title,
isPresented: isPresented,
titleVisibility: .visible
) {
ForEach(buttons) { button in
button.toButtonView()
}
} message: {
if let message {
Text(message)
}
}
} else {
content
.actionSheet(isPresented: isPresented) {
ActionSheet(
title: Text(title),
message: message.map { Text($0) } ?? nil,
buttons: [buttons.map { $0.toActionButton() }, [.cancel()]].flatMap { $0 }
)
}
}
}
@ViewBuilder func tint(_ color: Color) -> some View {
if #available(iOS 15, *) {
content
.tint(color)
} else {
content
.accentColor(color)
}
}
@ViewBuilder func onAppear(callback: @escaping () -> Void) -> some View {
if #available(iOS 15, *) {
content
.onAppear {
callback()
}
} else {
content
.viewDidAppear {
callback()
}
}
}
@ViewBuilder func introspectSearchController(customize: @escaping (UISearchController) -> ()) -> some View {
if #available(iOS 15, *) {
content.introspectSearchController(customize: customize)
} else {
content.introspectNavigationController { navigationController in
let navigationBar = navigationController.navigationBar
if let searchController = navigationBar.topItem?.searchController {
customize(searchController)
}
}
}
}
}

View file

@ -0,0 +1,30 @@
//
// DynamicFetchRequest.swift
// Ferrite
//
// Created by Brian Dashore on 9/6/22.
//
// Used for FetchRequests with a dynamic predicate
// iOS 14 compatible view
//
import CoreData
import SwiftUI
struct DynamicFetchRequest<T: NSManagedObject, Content: View>: View {
@FetchRequest var fetchRequest: FetchedResults<T>
let content: (FetchedResults<T>) -> Content
var body: some View {
content(fetchRequest)
}
init(predicate: NSPredicate?,
sortDescriptors: [NSSortDescriptor] = [],
@ViewBuilder content: @escaping (FetchedResults<T>) -> Content)
{
_fetchRequest = FetchRequest<T>(entity: T.entity(), sortDescriptors: sortDescriptors, predicate: predicate)
self.content = content
}
}

View file

@ -20,7 +20,7 @@ struct EmptyInstructionView: View {
.padding(.horizontal, 50)
}
.multilineTextAlignment(.center)
.foregroundColor(.init(uiColor: .secondaryLabel))
.foregroundColor(.secondaryLabel)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}

View file

@ -0,0 +1,27 @@
//
// FilterLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 2/12/23.
//
import SwiftUI
struct FilterLabelView: View {
var name: String
var body: some View {
HStack(spacing: 4) {
Text(name)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
.font(.caption, weight: .medium)
.background(Capsule().foregroundColor(.secondarySystemFill))
}
}

View file

@ -1,62 +0,0 @@
//
// HybridSecureField.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import SwiftUI
struct HybridSecureField: View {
enum Field: Hashable {
case plain
case secure
}
@Binding var text: String
var onCommit: () -> Void = {}
@State private var showPassword = false
@FocusState private var focusedField: Field?
private var isFieldDisabled: Bool = false
init(text: Binding<String>, onCommit: (() -> Void)? = nil, showPassword: Bool = false) {
_text = text
if let onCommit {
self.onCommit = onCommit
}
self.showPassword = showPassword
}
var body: some View {
HStack {
Group {
if showPassword {
TextField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .plain)
} else {
SecureField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .secure)
}
}
.autocorrectionDisabled(true)
.autocapitalization(.none)
.disabledAppearance(isFieldDisabled)
Button {
showPassword.toggle()
focusedField = showPassword ? .plain : .secure
} label: {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.borderless)
}
}
}
extension HybridSecureField {
func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
}
}

View file

@ -21,12 +21,13 @@ struct IndeterminateProgressView: View {
.foregroundColor(Color.accentColor)
.frame(width: reader.size.width * 0.26, height: 6)
.clipShape(Capsule())
.offset(x: -reader.size.width * 0.6, y: 0)
.offset(x: reader.size.width * 1.2 * offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: offset)
.onAppear {
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: self.offset)
.backport.onAppear {
withAnimation {
offset = 1
self.offset = 1
}
}
)

View file

@ -0,0 +1,29 @@
//
// InlineHeader.swift
// Ferrite
//
// Created by Brian Dashore on 9/5/22.
//
// For iOS 15's weird defaults regarding sectioned list padding
//
import SwiftUI
struct InlineHeader: View {
let title: String
init(_ title: String) {
self.title = title
}
var body: some View {
if #available(iOS 16, *) {
Text(title)
} else if #available(iOS 15, *) {
Text(title)
.listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 0))
} else {
Text(title)
}
}
}

View file

@ -11,5 +11,7 @@ struct LibraryHeaderView: View {
@EnvironmentObject var debridManager: DebridManager
@Binding var selectedSegment: LibraryPickerSegment
var body: some View {}
var body: some View {
}
}

View file

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

View file

@ -0,0 +1,39 @@
//
// ConditionalContextMenu.swift
// Ferrite
//
// Created by Brian Dashore on 9/3/22.
//
// Used as a workaround for iOS 15 not updating context views with conditional variables
// A stateful ID is required for the contextMenu to update itself.
//
import SwiftUI
struct ConditionalContextMenuModifier<InternalContent: View, ID: Hashable>: ViewModifier {
let internalContent: () -> InternalContent
let id: ID
init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) {
self.internalContent = internalContent
self.id = id
}
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
.contextMenu {
internalContent()
}
} else {
content
.background {
Color.clear
.contextMenu {
internalContent()
}
.id(id)
}
}
}
}

View file

@ -0,0 +1,24 @@
//
// ConditionalId.swift
// Ferrite
//
// Created by Brian Dashore on 9/4/22.
//
// Applies an ID below iOS 16
// This is due to ID workarounds making iOS 16 apps crash
//
import SwiftUI
struct ConditionalIdModifier<ID: Hashable>: ViewModifier {
let id: ID
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
} else {
content
.id(id)
}
}
}

View file

@ -0,0 +1,68 @@
//
// SearchAppearance.swift
// Ferrite
//
// Created by Brian Dashore on 2/14/23.
//
import SwiftUI
import Introspect
struct CustomScopeBarModifier<V: View>: ViewModifier {
let hostingContent: V
@State private var hostingController: UIHostingController<V>?
// Don't use AppStorage since it causes a view update
var autocorrectSearch: Bool {
UserDefaults.standard.bool(forKey: "Behavior.AutocorrectSearch")
}
func body(content: Content) -> some View {
if #available(iOS 15, *) {
content
.backport.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
// MARK: One-time setup
guard hostingController == nil else { return }
searchController.hidesNavigationBarDuringPresentation = true
searchController.searchBar.showsScopeBar = true
searchController.searchBar.scopeButtonTitles = [""]
(searchController.searchBar.value(forKey: "_scopeBar") as? UIView)?.isHidden = true
let hostingController = UIHostingController(rootView: hostingContent)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
guard let containerView = searchController.searchBar.value(forKey: "_scopeBarContainerView") as? UIView else {
return
}
containerView.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.widthAnchor.constraint(equalTo: containerView.widthAnchor),
hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
hostingController.view.heightAnchor.constraint(equalTo: containerView.heightAnchor)
])
self.hostingController = hostingController
}
.introspectNavigationController { navigationController in
navigationController.navigationBar.prefersLargeTitles = true
navigationController.navigationBar.sizeToFit()
}
} else {
VStack {
hostingContent
content
Spacer()
}
.backport.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
}
}
}

View file

@ -5,18 +5,26 @@
// Created by Brian Dashore on 9/4/22.
//
// Removes the top padding on unsectioned lists
// If a list is sectioned, see InlineHeader
//
import Introspect
import SwiftUI
import SwiftUIIntrospect
struct InlinedListModifier: ViewModifier {
let inset: CGFloat
func body(content: Content) -> some View {
content
.introspect(.list, on: .iOS(.v16, .v17, .v18)) { collectionView in
collectionView.contentInset.top = inset
}
if #available(iOS 16, *) {
content
.introspectCollectionView { collectionView in
collectionView.contentInset.top = inset
}
} else {
content
.introspectTableView { tableView in
tableView.contentInset.top = inset
}
}
}
}

View file

@ -0,0 +1,17 @@
//
// ViewDidAppearModifier.swift
// Ferrite
//
// Created by Brian Dashore on 2/8/23.
//
import SwiftUI
struct ViewDidAppearModifier: ViewModifier {
let callback: () -> Void
func body(content: Content) -> some View {
content
.background(ViewDidAppearHandler(callback: callback))
}
}

View file

@ -0,0 +1,28 @@
//
// NavView.swift
// Ferrite
//
// Created by Brian Dashore on 7/4/22.
// Contributed by Mantton
//
// A wrapper that switches between NavigationStack and the legacy NavigationView
//
import SwiftUI
struct NavView<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
if #available(iOS 16, *) {
NavigationStack {
content
}
} else {
NavigationView {
content
}
.navigationViewStyle(.stack)
}
}
}

View file

@ -28,7 +28,7 @@ struct SearchableContent<Content: View>: View {
lastHeight = newHeight
}
.transaction {
if geom.size.height != lastHeight, searching {
if geom.size.height != lastHeight && searching {
$0.animation = .default.speed(2)
}
}

View file

@ -21,7 +21,7 @@ struct Tag: View {
.padding(.vertical, verticalPadding)
.background(
RoundedRectangle(cornerRadius: 5)
.foregroundColor(color.map { $0 } ?? .init(uiColor: .tertiaryLabel))
.foregroundColor(color.map { $0 } ?? .tertiaryLabel)
.opacity(0.3)
)
}

View file

@ -24,7 +24,7 @@ struct TestHostingView: View {
Text(textName)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
@ -49,7 +49,7 @@ struct TestHostingView: View {
Text(secondTextName)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}

View file

@ -8,34 +8,38 @@
import SwiftUI
struct DebridLabelView: View {
@Store var debridSource: DebridSource
@EnvironmentObject var debridManager: DebridManager
@State var cloudLinks: [String] = []
@State var tagColor: Color = .red
var magnet: Magnet?
var body: some View {
Tag(
name: debridSource.abbreviation,
color: getTagColor(),
horizontalPadding: 5,
verticalPadding: 3
)
if let selectedDebridType = debridManager.selectedDebridType {
Tag(
name: selectedDebridType.toString(abbreviated: true),
color: getTagColor(),
horizontalPadding: 5,
verticalPadding: 3
)
}
}
func getTagColor() -> Color {
if let magnet, cloudLinks.isEmpty {
guard let match = debridSource.IAValues.first(where: { magnet.hash == $0.magnet.hash }) else {
return .red
switch debridManager.matchMagnetHash(magnet) {
case .full:
return Color.green
case .partial:
return Color.orange
case .none:
return Color.red
}
return match.files.count > 1 ? .orange : .green
} else if cloudLinks.count == 1 {
return .green
return Color.green
} else if cloudLinks.count > 1 {
return .orange
return Color.orange
} else {
return .red
return Color.red
}
}
}

View file

@ -0,0 +1,32 @@
//
// DebridChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 11/26/22.
//
import SwiftUI
struct DebridPickerView<Content: View>: View {
@EnvironmentObject var debridManager: DebridManager
@ViewBuilder var label: Content
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: {
label
}
}
}

View file

@ -1,24 +0,0 @@
//
// FilterAmountLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 4/11/23.
//
import SwiftUI
struct FilterAmountLabelView: View {
@Environment(\.colorScheme) var colorScheme
var amount: Int
var body: some View {
Text(String(amount))
.padding(5)
.foregroundColor(colorScheme == .light ? .white : .accentColor)
.background {
Circle()
.foregroundColor(colorScheme == .light ? .accentColor : .white)
}
}
}

View file

@ -1,44 +0,0 @@
//
// FilterLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 2/12/23.
//
import SwiftUI
struct FilterLabelView: View {
@Environment(\.colorScheme) var colorScheme
var name: String?
var fallbackName: String
var count: Int?
var body: some View {
HStack(spacing: 4) {
if let count, count > 1 {
FilterAmountLabelView(amount: count)
}
Text(count ?? 1 == 1 ? name ?? fallbackName : fallbackName)
.opacity(count ?? 0 > 0 ? 1 : 0.6)
.foregroundColor(count ?? 0 > 0 && colorScheme == .light ? .accentColor : .primary)
Image(systemName: "chevron.down")
.foregroundColor(count ?? 0 > 0 ? (colorScheme == .light ? .accentColor : .primary) : .init(uiColor: .tertiaryLabel))
}
.padding(.horizontal, 9)
.padding(.vertical, count ?? 1 > 1 ? 2 : 7)
.font(
.caption
.weight(.medium)
)
.background(
Capsule()
.foregroundColor(
count ?? 0 > 0 ? .accentColor : .init(uiColor: .secondarySystemFill)
)
.opacity(count ?? 0 > 0 && colorScheme == .light ? 0.1 : 1)
)
}
}

View file

@ -1,67 +0,0 @@
//
// IAFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import SwiftUI
// TODO: Make this use multiple selections
struct IAFilterView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
var body: some View {
Menu {
Button {
debridManager.filteredIAStatus = []
} label: {
Text("Any")
if debridManager.filteredIAStatus.isEmpty {
Image(systemName: "checkmark")
}
}
ForEach(IAStatus.allCases, id: \.self) { status in
let containsIAStatus = debridManager.filteredIAStatus.contains(status)
Button {
if containsIAStatus {
debridManager.filteredIAStatus.remove(status)
} else {
debridManager.filteredIAStatus.insert(status)
}
} label: {
Text(status.rawValue)
if containsIAStatus {
Image(systemName: "checkmark")
}
}
}
} label: {
FilterLabelView(
name: debridManager.filteredIAStatus.first?.rawValue,
fallbackName: "Cache Status",
count: debridManager.filteredIAStatus.count
)
}
.id(debridManager.filteredIAStatus)
.onChange(of: debridManager.filteredIAStatus) { newSources in
if newSources.isEmpty {
navModel.enabledFilters.remove(.IA)
} else {
navModel.enabledFilters.insert(.IA)
}
}
.onChange(of: navModel.enabledFilters) { newFilters in
if newFilters.isEmpty {
Task {
try? await Task.sleep(seconds: 0.25)
debridManager.filteredIAStatus = []
}
}
}
}
}

View file

@ -1,44 +0,0 @@
//
// SelectedDebridFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import SwiftUI
struct SelectedDebridFilterView<Content: View>: View {
@EnvironmentObject var debridManager: DebridManager
@ViewBuilder var label: Content
var body: some View {
Menu {
Button {
debridManager.selectedDebridSource = nil
} label: {
Text("None")
if debridManager.selectedDebridSource == nil {
Image(systemName: "checkmark")
}
}
ForEach(debridManager.debridSources, id: \.id) { debridSource in
if debridSource.isLoggedIn {
Button {
debridManager.selectedDebridSource = debridSource
} label: {
Text(debridSource.id)
if debridManager.selectedDebridSource?.id == debridSource.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
label
}
}
}

View file

@ -1,73 +0,0 @@
//
// SortFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/14/23.
//
import SwiftUI
struct SortFilterView: View {
@EnvironmentObject var navModel: NavigationViewModel
var body: some View {
Menu {
Button {
navModel.currentSortFilter = nil
navModel.currentSortOrder = .forward
} label: {
HStack {
Text("None")
if navModel.currentSortFilter == nil {
Image(systemName: "checkmark")
}
}
}
ForEach(SortFilter.allCases, id: \.self) { sortFilter in
Button {
navModel.currentSortFilter = sortFilter
navModel.currentSortOrder = navModel.currentSortOrder == .forward ? .reverse : .forward
} label: {
HStack {
Text(sortFilter.rawValue)
if navModel.currentSortFilter == sortFilter {
Image(systemName: navModel.currentSortOrder == .forward ? "chevron.down" : "chevron.up")
}
}
}
}
} label: {
FilterLabelView(
name: "Sort\(navModel.currentSortFilter.map { ": \($0.rawValue)" } ?? "")",
fallbackName: "Sort",
count: navModel.currentSortFilter == nil ? 0 : 1
)
}
.id(navModel.currentSortFilter)
.onChange(of: navModel.currentSortFilter) { newFilter in
navModel.currentSortOrder = .forward
if newFilter == nil {
navModel.enabledFilters.remove(.sort)
} else {
navModel.enabledFilters.insert(.sort)
}
}
.onChange(of: navModel.enabledFilters) { newFilters in
if newFilters.isEmpty {
Task {
try? await Task.sleep(seconds: 0.25)
navModel.currentSortFilter = nil
}
}
}
}
}
struct SortFilterView_Previews: PreviewProvider {
static var previews: some View {
SortFilterView()
}
}

View file

@ -1,74 +0,0 @@
//
// SourceFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import SwiftUI
// TODO: Make this use multiple selections
struct SourceFilterView: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
var body: some View {
Menu {
Button {
pluginManager.filteredInstalledSources = []
} label: {
Text("All")
if pluginManager.filteredInstalledSources.isEmpty {
Image(systemName: "checkmark")
}
}
ForEach(sources, id: \.self) { source in
let containsSource = pluginManager.filteredInstalledSources.contains(source)
if source.enabled {
Button {
if containsSource {
pluginManager.filteredInstalledSources.remove(source)
} else {
pluginManager.filteredInstalledSources.insert(source)
}
} label: {
Text(source.name)
if containsSource {
Image(systemName: "checkmark")
}
}
}
}
} label: {
FilterLabelView(
name: pluginManager.filteredInstalledSources.first?.name,
fallbackName: "Source",
count: pluginManager.filteredInstalledSources.count
)
}
.id(pluginManager.filteredInstalledSources)
.onChange(of: pluginManager.filteredInstalledSources) { newSources in
if newSources.isEmpty {
navModel.enabledFilters.remove(.source)
} else {
navModel.enabledFilters.insert(.source)
}
}
.onChange(of: navModel.enabledFilters) { newFilters in
if newFilters.isEmpty {
Task {
try? await Task.sleep(seconds: 0.25)
pluginManager.filteredInstalledSources = []
}
}
}
}
}

View file

@ -8,6 +8,8 @@
import SwiftUI
struct BookmarksView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@ -15,68 +17,69 @@ struct BookmarksView: View {
@Binding var searchText: String
var bookmarks: FetchedResults<Bookmark>
@State private var viewTask: Task<Void, Never>?
@State private var bookmarkPredicate: NSPredicate?
var body: some View {
List {
if !bookmarks.isEmpty {
ForEach(bookmarks, id: \.self) { bookmark in
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
}
.onDelete { offsets in
for index in offsets {
if let bookmark = bookmarks[safe: index] {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
DynamicFetchRequest(
predicate: bookmarkPredicate,
sortDescriptors: [NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)]
) { (bookmarks: FetchedResults<Bookmark>) in
List {
if !bookmarks.isEmpty {
ForEach(bookmarks, id: \.self) { bookmark in
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
}
.onDelete { offsets in
for index in offsets {
if let bookmark = bookmarks[safe: index] {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
}
}
}
.onMove { source, destination in
var changedBookmarks = bookmarks.map { $0 }
changedBookmarks.move(fromOffsets: source, toOffset: destination)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
}
PersistenceController.shared.save()
}
}
}
.onMove { source, destination in
var changedBookmarks = bookmarks.map { $0 }
changedBookmarks.move(fromOffsets: source, toOffset: destination)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
}
.listStyle(.insetGrouped)
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 15 : -25)
.backport.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)
}
PersistenceController.shared.save()
}
}
.onDisappear {
viewTask?.cancel()
}
}
.onAppear {
fetchPredicate()
.backport.onAppear {
applyPredicate()
}
.onChange(of: searchText) { _ in
fetchPredicate()
}
.listStyle(.insetGrouped)
.safeAreaInset(edge: .top, spacing: 0) {
Spacer()
.frame(height: 15)
}
.task {
await matchAgainstIA()
}
.refreshable {
await matchAgainstIA()
applyPredicate()
}
}
func fetchPredicate() {
bookmarks.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
}
func matchAgainstIA() async {
if !debridManager.enabledDebrids.isEmpty {
let magnets = bookmarks.compactMap {
if let magnetHash = $0.magnetHash {
return Magnet(hash: magnetHash, link: $0.magnetLink)
} else {
return nil
}
}
await debridManager.populateDebridIA(magnets)
}
func applyPredicate() {
bookmarkPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
}
}

View file

@ -0,0 +1,96 @@
//
// AllDebridCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 1/5/23.
//
import SwiftUI
struct AllDebridCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@Binding var searchText: String
@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, performSave: true)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
}
} 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)
}
}
}
}
}
.backport.onAppear {
viewTask = Task {
await debridManager.fetchAdCloud()
}
}
.onDisappear {
viewTask?.cancel()
}
}
}

View file

@ -1,60 +0,0 @@
//
// CloudDownloadView.swift
// Ferrite
//
// Created by Brian Dashore on 6/6/24.
//
import SwiftUI
struct CloudDownloadView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
@Store var debridSource: DebridSource
@Binding var searchText: String
var body: some View {
DisclosureGroup("Downloads") {
ForEach(debridSource.cloudDownloads.filter {
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
}, id: \.self) { cloudDownload in
Button(cloudDownload.fileName) {
navModel.resultFromCloud = true
navModel.selectedTitle = cloudDownload.fileName
var historyEntry = HistoryEntryJson(
name: cloudDownload.fileName,
source: debridSource.id
)
debridManager.currentDebridTask = Task {
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link)
if !debridManager.downloadUrl.isEmpty {
historyEntry.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyEntry, performSave: true)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
}
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let cloudDownload = debridSource.cloudDownloads[safe: index] {
Task {
await debridManager.deleteCloudDownload(cloudDownload)
}
}
}
}
}
}
}

View file

@ -1,97 +0,0 @@
//
// CloudMagnetView.swift
// Ferrite
//
// Created by Brian Dashore on 6/6/24.
//
import SwiftUI
struct CloudMagnetView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
@Store var debridSource: DebridSource
@Binding var searchText: String
var body: some View {
DisclosureGroup("Magnets") {
ForEach(debridSource.cloudMagnets.filter {
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
}, id: \.self) { cloudMagnet in
Button {
if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty {
navModel.resultFromCloud = true
navModel.selectedTitle = cloudMagnet.fileName
var historyInfo = HistoryEntryJson(
name: cloudMagnet.fileName,
source: debridSource.id
)
Task {
let magnet = Magnet(hash: cloudMagnet.hash, link: nil)
await debridManager.populateDebridIA([magnet])
if debridManager.selectDebridResult(magnet: magnet) {
// Is this a batch?
if cloudMagnet.links.count == 1 {
await debridManager.fetchDebridDownload(magnet: magnet)
// Bump to batch
if debridManager.requiresUnrestrict {
navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch
return
}
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo, performSave: true)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
}
} else {
navModel.selectedMagnet = magnet
navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch
}
}
}
}
} label: {
VStack(alignment: .leading, spacing: 10) {
Text(cloudMagnet.fileName)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(4)
HStack {
Text(cloudMagnet.status.capitalizingFirstLetter())
Spacer()
DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links)
}
.font(.caption)
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let cloudMagnet = debridSource.cloudMagnets[safe: index] {
Task {
await debridManager.deleteUserMagnet(cloudMagnet)
}
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more