v0.6.1 #22

Merged
kingbri1 merged 18 commits from next into default 2023-03-02 17:01:20 +00:00
91 changed files with 3071 additions and 1629 deletions

View file

@ -8,7 +8,7 @@ jobs:
build:
runs-on: macos-12
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
@ -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@v2
uses: actions/upload-artifact@v3
with:
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa

36
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Build and upload release ipa
on:
release:
types:
- created
jobs:
build:
runs-on: macos-12
steps:
- uses: actions/checkout@v3
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
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:
IS_NIGHTLY: NO
- name: Get app version
run: |
echo "app_version=$(/usr/libexec/plistbuddy -c Print:CFBundleShortVersionString: build/Ferrite.xcarchive/Products/Applications/Ferrite.app/Info.plist)" >> $GITHUB_ENV
- name: Package ipa
run: |
mkdir Payload
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_v${{ env.app_version }}.ipa Payload
- name: Create ipa zip
run: |
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
- name: Upload release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip

View file

@ -8,35 +8,49 @@
/* Begin PBXBuildFile section */
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0167DB29293FA900B65783 /* RealDebridModels.swift */; };
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */; };
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 */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.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 */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.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 */; };
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 */; };
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; };
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; };
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */; };
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 */; };
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */ = {isa = PBXBuildFile; productRef = 0C4CFC452897030D00AD9FAD /* Regex */; };
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */; };
0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */; };
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5005512992B6750064606A /* PluginTagsView.swift */; };
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */; };
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */; };
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 */; };
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 */; };
@ -48,14 +62,12 @@
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 */; };
0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7376EF28A97D1400D60918 /* SwiftUIX */; };
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 */; };
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C78041C28BFB3EA001E8CA3 /* String.swift */; };
0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */; };
0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */; };
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */; };
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */; };
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */; };
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 */; };
@ -68,16 +80,14 @@
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 */; };
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */; };
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */; };
0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */; };
0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */; };
0CA05457288EE58200850554 /* SettingsSourceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsSourceListView.swift */; };
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* SourceManager.swift */; };
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */; };
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.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 */; };
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */; };
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* MagnetChoiceView.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 */; };
@ -91,7 +101,6 @@
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 */; };
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; };
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; };
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23328C2658700616D3A /* LibraryView.swift */; };
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23628C2660700616D3A /* HistoryView.swift */; };
@ -104,41 +113,54 @@
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.swift */; };
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 0CB6516728C5A5EC00DCA721 /* Introspect */; };
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 */; };
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 */; };
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 */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0C0167DB29293FA900B65783 /* RealDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = "<group>"; };
0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataClass.swift"; sourceTree = "<group>"; };
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>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = "<group>"; };
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>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB_v2.xcdatamodel; sourceTree = "<group>"; };
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = "<group>"; };
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; };
0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = "<group>"; };
0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridChoiceView.swift; sourceTree = "<group>"; };
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>"; };
@ -146,8 +168,14 @@
0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = "<group>"; };
0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = "<group>"; };
0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C5005512992B6750064606A /* PluginTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginTagsView.swift; sourceTree = "<group>"; };
0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginTag+CoreDataClass.swift"; sourceTree = "<group>"; };
0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginTag+CoreDataProperties.swift"; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
@ -160,9 +188,8 @@
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C78041C28BFB3EA001E8CA3 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceUpdateButtonView.swift; sourceTree = "<group>"; };
0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceButtonView.swift; sourceTree = "<group>"; };
0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogButtonView.swift; sourceTree = "<group>"; };
0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledPluginButtonView.swift; sourceTree = "<group>"; };
0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginCatalogButtonView.swift; sourceTree = "<group>"; };
0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataClass.swift"; sourceTree = "<group>"; };
0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceSeedLeech+CoreDataProperties.swift"; sourceTree = "<group>"; };
@ -175,16 +202,14 @@
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>"; };
0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceList+CoreDataClass.swift"; sourceTree = "<group>"; };
0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceList+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionsPickerViews.swift; sourceTree = "<group>"; };
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
0CA05456288EE58200850554 /* SettingsSourceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSourceListView.swift; sourceTree = "<group>"; };
0CA05458288EE9E600850554 /* SourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManager.swift; sourceTree = "<group>"; };
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceListEditorView.swift; sourceTree = "<group>"; };
0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.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>"; };
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListEditorView.swift; sourceTree = "<group>"; };
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 /* MagnetChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagnetChoiceView.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>"; };
@ -198,7 +223,6 @@
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>"; };
0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
0CA3B23328C2658700616D3A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
0CA3B23628C2660700616D3A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
@ -216,12 +240,19 @@
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>"; };
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>"; };
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -230,10 +261,10 @@
buildActionMask = 2147483647;
files = (
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */,
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */,
0C64A4B4288903680079976D /* Base32 in Frameworks */,
0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */,
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */,
0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */,
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
@ -246,7 +277,7 @@
0C0755C22934241F00ECA142 /* SheetViews */ = {
isa = PBXGroup;
children = (
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */,
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
);
path = SheetViews;
@ -255,11 +286,11 @@
0C0755C32934244500ECA142 /* ComponentViews */ = {
isa = PBXGroup;
children = (
0C3E00D4296F560800ECECB2 /* Plugin */,
0C0755C42934245800ECA142 /* Debrid */,
0CA3B23528C265FD00616D3A /* Library */,
0C44E2AB28D4E126007711AE /* SearchResult */,
0CA0545C288F7CB200850554 /* Settings */,
0C794B65289DAC9F00DD1CC8 /* Source */,
);
path = ComponentViews;
sourceTree = "<group>";
@ -267,7 +298,7 @@
0C0755C42934245800ECA142 /* Debrid */ = {
isa = PBXGroup;
children = (
0C42B5952932F2D5008057A0 /* DebridChoiceView.swift */,
0C42B5952932F2D5008057A0 /* DebridPickerView.swift */,
0C0755C5293424A200ECA142 /* DebridLabelView.swift */,
);
path = Debrid;
@ -276,6 +307,12 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
0C5005582992BA6A0064606A /* PluginTag+CoreDataClass.swift */,
0C5005592992BA6A0064606A /* PluginTag+CoreDataProperties.swift */,
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */,
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */,
0C03EB6F296F619900162E9A /* PluginList+CoreDataClass.swift */,
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */,
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */,
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */,
0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */,
@ -292,8 +329,6 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
0C84F47E2895BFED0074B7C9 /* SourceList+CoreDataClass.swift */,
0C84F47F2895BFED0074B7C9 /* SourceList+CoreDataProperties.swift */,
);
path = Classes;
sourceTree = "<group>";
@ -301,6 +336,7 @@
0C0D50E3288DFE6E0035ECC8 /* Models */ = {
isa = PBXGroup;
children = (
0C3E00CF296F4DB200ECECB2 /* ActionModels.swift */,
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
@ -308,8 +344,8 @@
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
);
path = Models;
sourceTree = "<group>";
@ -324,6 +360,18 @@
path = Cloud;
sourceTree = "<group>";
};
0C3E00D4296F560800ECECB2 /* Plugin */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C794B65289DAC9F00DD1CC8 /* Source */,
0C0D50E6288DFF850035ECC8 /* PluginListView.swift */,
0C5005512992B6750064606A /* PluginTagsView.swift */,
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */,
);
path = Plugin;
sourceTree = "<group>";
};
0C44E2A628D4DDC6007711AE /* Classes */ = {
isa = PBXGroup;
children = (
@ -340,6 +388,8 @@
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */,
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */,
);
path = Modifiers;
sourceTree = "<group>";
@ -347,9 +397,8 @@
0C44E2AA28D4E09B007711AE /* Buttons */ = {
isa = PBXGroup;
children = (
0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */,
0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */,
0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */,
0C794B68289DACC800DD1CC8 /* InstalledPluginButtonView.swift */,
0C794B6A289DACF100DD1CC8 /* PluginCatalogButtonView.swift */,
);
path = Buttons;
sourceTree = "<group>";
@ -359,14 +408,22 @@
children = (
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */,
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */,
);
path = SearchResult;
sourceTree = "<group>";
};
0C5005552992B9C20064606A /* Protocols */ = {
isa = PBXGroup;
children = (
0CE1C4172981E8D700418F20 /* Plugin.swift */,
);
path = Protocols;
sourceTree = "<group>";
};
0C794B65289DAC9F00DD1CC8 /* Source */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
);
path = Source;
@ -376,9 +433,9 @@
isa = PBXGroup;
children = (
0C44E2AE28D52E8A007711AE /* BackupsView.swift */,
0CA05456288EE58200850554 /* SettingsSourceListView.swift */,
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */,
0CA05456288EE58200850554 /* SettingsPluginListView.swift */,
0CA0545A288EEA4E00850554 /* PluginListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */,
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
);
path = Settings;
@ -394,6 +451,7 @@
0CA148EF2889061600DE2211 /* ViewModels */,
0CA148EE2889061200DE2211 /* Views */,
0C44E2A628D4DDC6007711AE /* Classes */,
0C5005552992B9C20064606A /* Protocols */,
0CA148C8288903F000DE2211 /* Extensions */,
0CA148C5288903F000DE2211 /* Preview Content */,
0CA148C7288903F000DE2211 /* FerriteApp.swift */,
@ -411,10 +469,12 @@
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 */,
);
path = CommonViews;
sourceTree = "<group>";
@ -442,6 +502,7 @@
0C7ED14228D65518009E29AD /* FileManager.swift */,
0C42B5972932F6DD008057A0 /* Set.swift */,
0C7C128528DAA3CD00381CD1 /* URL.swift */,
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -455,9 +516,8 @@
0C0755C22934241F00ECA142 /* SheetViews */,
0CA148D1288903F000DE2211 /* MainView.swift */,
0CA148D4288903F000DE2211 /* ContentView.swift */,
0CA148D3288903F000DE2211 /* SearchResultsView.swift */,
0CA3B23328C2658700616D3A /* LibraryView.swift */,
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */,
0C3E00D1296F4FD200ECECB2 /* PluginsView.swift */,
0CA148BB288903F000DE2211 /* SettingsView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */,
0CA148BC288903F000DE2211 /* LoginWebView.swift */,
@ -472,7 +532,7 @@
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */,
0CA148CF288903F000DE2211 /* ToastViewModel.swift */,
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */,
0CA05458288EE9E600850554 /* SourceManager.swift */,
0CA05458288EE9E600850554 /* PluginManager.swift */,
0C44E2AC28D51C63007711AE /* BackupManager.swift */,
);
path = ViewModels;
@ -482,6 +542,7 @@
isa = PBXGroup;
children = (
0CA148CE288903F000DE2211 /* WebView.swift */,
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */,
);
path = RepresentableViews;
sourceTree = "<group>";
@ -506,6 +567,7 @@
0CA3B23628C2660700616D3A /* HistoryView.swift */,
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */,
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */,
0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */,
);
path = Library;
sourceTree = "<group>";
@ -558,10 +620,10 @@
0C64A4B3288903680079976D /* Base32 */,
0C64A4B6288903880079976D /* KeychainSwift */,
0C4CFC452897030D00AD9FAD /* Regex */,
0C7376EF28A97D1400D60918 /* SwiftUIX */,
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
0CB6516728C5A5EC00DCA721 /* Introspect */,
0CDDDE042935235E006810B1 /* BetterSafariView */,
0C448BE829A135F100F4E266 /* Introspect-Static */,
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */,
);
productName = Torrenter;
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
@ -596,10 +658,10 @@
0C64A4B2288903680079976D /* XCRemoteSwiftPackageReference "Base32" */,
0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */,
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */,
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */,
);
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
projectDirPath = "";
@ -652,46 +714,57 @@
buildActionMask = 2147483647;
files = (
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
0CB6516528C5A5D700DCA721 /* InlinedList.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 */,
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */,
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */,
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* SourceListEditorView.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 */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridChoiceView.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */,
0C50B7D0299DF63C00A9FA3C /* UIDevice.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 */,
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */,
0CC389532970AD900066D06F /* Action+CoreDataClass.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 /* InstalledSourceButtonView.swift in Sources */,
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */,
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */,
@ -699,11 +772,9 @@
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */,
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */,
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */,
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */,
0CA148E3288903F000DE2211 /* Task.swift in Sources */,
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */,
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
@ -712,7 +783,6 @@
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */,
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */,
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */,
0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */,
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
0CA3B23D28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift in Sources */,
0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */,
@ -722,31 +792,38 @@
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */,
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */,
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */,
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */,
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsSourceListView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsPluginListView.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 */,
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */,
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.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 */,
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */,
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */,
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */,
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */,
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */,
0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */,
);
@ -894,7 +971,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.6.0;
MARKETING_VERSION = 0.6.1;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -929,7 +1006,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.6.0;
MARKETING_VERSION = 0.6.1;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -963,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";
@ -987,14 +1080,6 @@
kind = branch;
};
};
0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftUIX/SwiftUIX";
requirement = {
branch = master;
kind = branch;
};
};
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON";
@ -1011,14 +1096,6 @@
minimumVersion = 2.0.0;
};
};
0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
requirement = {
branch = master;
kind = branch;
};
};
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/stleamist/BetterSafariView";
@ -1030,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" */;
@ -1045,11 +1132,6 @@
package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
0C7376EF28A97D1400D60918 /* SwiftUIX */ = {
isa = XCSwiftPackageProductDependency;
package = 0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */;
productName = SwiftUIX;
};
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = {
isa = XCSwiftPackageProductDependency;
package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
@ -1060,11 +1142,6 @@
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
0CB6516728C5A5EC00DCA721 /* Introspect */ = {
isa = XCSwiftPackageProductDependency;
package = 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = Introspect;
};
0CDDDE042935235E006810B1 /* BetterSafariView */ = {
isa = XCSwiftPackageProductDependency;
package = 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */;
@ -1076,9 +1153,10 @@
0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */ = {
isa = XCVersionGroup;
children = (
0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */,
0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */,
);
currentVersion = 0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */;
currentVersion = 0C3E00D9296F5E4F00ECECB2 /* FerriteDB_v2.xcdatamodel */;
path = FerriteDB.xcdatamodeld;
sourceTree = "<group>";
versionGroupType = wrapper.xcdatamodel;

View file

@ -18,6 +18,16 @@ public class RealDebrid {
var authTask: Task<Void, Error>?
@MainActor
func setUserDefaultsValue(_ value: Any, forKey: String) {
UserDefaults.standard.set(value, forKey: forKey)
}
@MainActor
func removeUserDefaultsValue(forKey: String) {
UserDefaults.standard.removeObject(forKey: forKey)
}
// Fetches the device code from RD
public func getVerificationInfo() async throws -> DeviceCodeResponse {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
@ -72,7 +82,7 @@ public class RealDebrid {
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId")
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
@ -124,7 +134,7 @@ public class RealDebrid {
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
}
public func fetchToken() async -> String? {
@ -147,8 +157,8 @@ public class RealDebrid {
public func deleteTokens() async throws {
keychain.delete("RealDebrid.RefreshToken")
keychain.delete("RealDebrid.ClientSecret")
UserDefaults.standard.removeObject(forKey: "RealDebrid.ClientId")
UserDefaults.standard.removeObject(forKey: "RealDebrid.AccessTokenStamp")
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
// Run the request, doesn't matter if it fails
if let token = keychain.get("RealDebrid.AccessToken") {

View file

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

View file

@ -0,0 +1,71 @@
//
// Action+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 2/6/23.
//
//
import Foundation
import CoreData
extension Action {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Action> {
return NSFetchRequest<Action>(entityName: "Action")
}
@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?
public func getTags() -> [PluginTagJson] {
return requires.map { PluginTagJson(name: $0, colorHex: nil) } + tagArray.map { $0.toJson() }
}
}
// MARK: Generated accessors for tags
extension Action {
@objc(insertObject:inTagsAtIndex:)
@NSManaged public func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged public func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged public func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged public func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged public func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged public func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged public func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged public func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged public func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged public func removeFromTags(_ values: NSOrderedSet)
}
extension Action : Identifiable {
}

View file

@ -10,15 +10,4 @@ import CoreData
import Foundation
@objc(Bookmark)
public class Bookmark: NSManagedObject {
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnet: Magnet(hash: magnetHash, link: magnetLink),
seeders: seeders,
leechers: leechers
)
}
}
public class Bookmark: NSManagedObject {}

View file

@ -22,6 +22,17 @@ public extension Bookmark {
@NSManaged var source: String
@NSManaged var title: String?
@NSManaged var orderNum: Int16
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnet: Magnet(hash: magnetHash, link: magnetLink),
seeders: seeders,
leechers: leechers
)
}
}
extension Bookmark: Identifiable {}

View file

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

View file

@ -0,0 +1,28 @@
//
// PluginList+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
//
import Foundation
import CoreData
extension PluginList {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PluginList> {
return NSFetchRequest<PluginList>(entityName: "PluginList")
}
@NSManaged public var author: String
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var urlString: String
}
extension PluginList : Identifiable {
}

View file

@ -0,0 +1,14 @@
//
// PluginTag+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
//
import Foundation
import CoreData
@objc(PluginTag)
public class PluginTag: NSManagedObject {
}

View file

@ -0,0 +1,31 @@
//
// PluginTag+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
//
import Foundation
import CoreData
extension PluginTag {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PluginTag> {
return NSFetchRequest<PluginTag>(entityName: "PluginTag")
}
@NSManaged public var colorHex: String?
@NSManaged public var name: String
@NSManaged public var parentAction: Action?
@NSManaged public var parentSource: Source?
func toJson() -> PluginTagJson {
return PluginTagJson(name: name, colorHex: colorHex)
}
}
extension PluginTag : Identifiable {
}

View file

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

View file

@ -2,33 +2,77 @@
// Source+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
// Created by Brian Dashore on 2/6/23.
//
//
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 baseUrl: String?
@NSManaged var fallbackUrls: [String]?
@NSManaged var dynamicBaseUrl: Bool
@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 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?
public func getTags() -> [PluginTagJson] {
return tagArray.map { $0.toJson() }
}
}
extension Source: Identifiable {}
// MARK: Generated accessors for tags
extension Source {
@objc(insertObject:inTagsAtIndex:)
@NSManaged public func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged public func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged public func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged public func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged public func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged public func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged public func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged public func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged public func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged public func removeFromTags(_ values: NSOrderedSet)
}
extension Source : Identifiable {
}

View file

@ -22,6 +22,7 @@ public extension SourceHtmlParser {
@NSManaged var seedLeech: SourceSeedLeech?
@NSManaged var size: SourceSize?
@NSManaged var title: SourceTitle?
@NSManaged var subName: SourceSubName?
}
extension SourceHtmlParser: Identifiable {}

View file

@ -23,6 +23,7 @@ public extension SourceJsonParser {
@NSManaged var seedLeech: SourceSeedLeech?
@NSManaged var size: SourceSize?
@NSManaged var title: SourceTitle?
@NSManaged var subName: SourceSubName?
}
extension SourceJsonParser: Identifiable {}

View file

@ -1,13 +0,0 @@
//
// SourceList+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 7/30/22.
//
//
import CoreData
import Foundation
@objc(SourceList)
public class SourceList: NSManagedObject {}

View file

@ -1,23 +0,0 @@
//
// SourceList+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 7/30/22.
//
//
import CoreData
import Foundation
public extension SourceList {
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceList> {
NSFetchRequest<SourceList>(entityName: "SourceList")
}
@NSManaged var id: UUID
@NSManaged var author: String
@NSManaged var name: String
@NSManaged var urlString: String
}
extension SourceList: Identifiable {}

View file

@ -23,6 +23,7 @@ public extension SourceRssParser {
@NSManaged var seedLeech: SourceSeedLeech?
@NSManaged var size: SourceSize?
@NSManaged var title: SourceTitle?
@NSManaged var subName: SourceSubName?
}
extension SourceRssParser: Identifiable {}

View file

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>FerriteDB.xcdatamodel</string>
<string>FerriteDB_v2.xcdatamodel</string>
</dict>
</plist>

View file

@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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="author" attributeType="String" defaultValueString=""/>
<attribute name="deeplink" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<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"/>
<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">
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="magnetHash" optional="YES" attributeType="String"/>
<attribute name="magnetLink" optional="YES" attributeType="String"/>
<attribute name="orderNum" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<attribute name="size" optional="YES" attributeType="String"/>
<attribute name="source" attributeType="String" defaultValueString=""/>
<attribute name="title" optional="YES" attributeType="String"/>
</entity>
<entity name="History" representedClassName="History" syncable="YES">
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="dateString" optional="YES" attributeType="String"/>
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HistoryEntry" inverseName="parentHistory" inverseEntity="HistoryEntry"/>
</entity>
<entity name="HistoryEntry" representedClassName="HistoryEntry" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="source" optional="YES" attributeType="String"/>
<attribute name="subName" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<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="PluginList" representedClassName="PluginList" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="urlString" attributeType="String" defaultValueString=""/>
</entity>
<entity name="PluginTag" representedClassName="PluginTag" syncable="YES">
<attribute name="colorHex" optional="YES" attributeType="String"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="parentAction" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Action" inverseName="tags" inverseEntity="Action"/>
<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="author" attributeType="String" defaultValueString=""/>
<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"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<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"/>
<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"/>
<relationship name="rssParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentSource" inverseEntity="PluginTag"/>
</entity>
<entity name="SourceApi" representedClassName="SourceApi" syncable="YES" codeGenerationType="class">
<attribute name="apiUrl" optional="YES" attributeType="String"/>
<relationship name="clientId" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientId" inverseName="parentApi" inverseEntity="SourceApiClientId"/>
<relationship name="clientSecret" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientSecret" inverseName="parentApi" inverseEntity="SourceApiClientSecret"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="api" inverseEntity="Source"/>
</entity>
<entity name="SourceApiClientId" representedClassName="SourceApiClientId" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientId" inverseEntity="SourceApi"/>
</entity>
<entity name="SourceApiClientSecret" representedClassName="SourceApiClientSecret" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientSecret" inverseEntity="SourceApi"/>
</entity>
<entity name="SourceApiCredential" representedClassName="SourceApiCredential" isAbstract="YES" syncable="YES" codeGenerationType="class">
<attribute name="dynamic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="expiryLength" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="query" optional="YES" attributeType="String"/>
<attribute name="responseType" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="urlString" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="String"/>
</entity>
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString="text"/>
<attribute name="discriminator" optional="YES" attributeType="String"/>
<attribute name="query" attributeType="String" defaultValueString=""/>
<attribute name="regex" optional="YES" attributeType="String"/>
</entity>
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
<attribute name="rows" attributeType="String" defaultValueString=""/>
<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="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"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentHtmlParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceJsonParser" representedClassName="SourceJsonParser" syncable="YES">
<attribute name="results" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="searchUrl" optional="YES" attributeType="String"/>
<attribute name="subResults" optional="YES" attributeType="String"/>
<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="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"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentJsonParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceMagnetHash" representedClassName="SourceMagnetHash" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetHash" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetHash" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetHash" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceMagnetLink" representedClassName="SourceMagnetLink" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<attribute name="externalLinkQuery" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetLink" inverseEntity="SourceHtmlParser"/>
<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="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<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="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"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentRssParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString=""/>
<attribute name="combined" optional="YES" attributeType="String"/>
<attribute name="discriminator" optional="YES" attributeType="String"/>
<attribute name="leecherRegex" optional="YES" attributeType="String"/>
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="seederRegex" optional="YES" attributeType="String"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="seedLeech" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="seedLeech" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="size" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="size" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceSubName" representedClassName="SourceSubName" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="subName" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="subName" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="subName" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="title" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="title" inverseEntity="SourceRssParser"/>
</entity>
</model>

View file

@ -91,7 +91,7 @@ struct PersistenceController {
save()
}
func createBookmark(_ bookmarkJson: BookmarkJson) {
func createBookmark(_ bookmarkJson: BookmarkJson, performSave: Bool) {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "source == %@ AND title == %@ AND magnetLink == %@",
@ -113,32 +113,31 @@ struct PersistenceController {
newBookmark.seeders = bookmarkJson.seeders
newBookmark.leechers = bookmarkJson.leechers
save(backgroundContext)
if performSave {
save(backgroundContext)
}
}
func createHistory(_ entryJson: HistoryEntryJson, date: Double? = nil) {
func createHistory(_ entryJson: HistoryEntryJson, performSave: Bool, isBackup: Bool = false, date: Double? = nil) {
let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date()
let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate)
let newHistoryEntry = HistoryEntry(context: backgroundContext)
newHistoryEntry.source = entryJson.source
newHistoryEntry.name = entryJson.name
newHistoryEntry.url = entryJson.url
newHistoryEntry.subName = entryJson.subName
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
let historyRequest = History.fetchRequest()
historyRequest.predicate = NSPredicate(format: "dateString = %@", historyDateString)
var existingHistory: History?
// Safely add entries to a parent history if it exists
if var histories = try? backgroundContext.fetch(historyRequest) {
for (i, history) in histories.enumerated() {
let existingEntries = history.entryArray.filter { $0.url == newHistoryEntry.url && $0.name == newHistoryEntry.name }
let existingEntries = history.entryArray.filter { $0.url == entryJson.url && $0.name == entryJson.name }
// Maybe add !isBackup here
if !existingEntries.isEmpty {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
if isBackup {
continue
} else {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
}
}
}
@ -148,15 +147,24 @@ struct PersistenceController {
}
}
newHistoryEntry.parentHistory = histories.first ?? History(context: backgroundContext)
} else {
newHistoryEntry.parentHistory = History(context: backgroundContext)
existingHistory = histories.first
}
let newHistoryEntry = HistoryEntry(context: backgroundContext)
newHistoryEntry.source = entryJson.source
newHistoryEntry.name = entryJson.name
newHistoryEntry.url = entryJson.url
newHistoryEntry.subName = entryJson.subName
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
newHistoryEntry.parentHistory = existingHistory ?? History(context: backgroundContext)
newHistoryEntry.parentHistory?.dateString = historyDateString
newHistoryEntry.parentHistory?.date = historyDate
save(backgroundContext)
if performSave {
save(backgroundContext)
}
}
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {
@ -200,8 +208,7 @@ struct PersistenceController {
return predicate
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
// Wrapper to batch delete history objects
func batchDeleteHistory(range: HistoryDeleteRange) throws {
let predicate = getHistoryPredicate(range: range)
@ -213,6 +220,13 @@ struct PersistenceController {
throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?")
}
try batchDelete("History", predicate: predicate)
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
func batchDelete(_ entity: String, predicate: NSPredicate? = nil) throws {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeObjectIDs

View file

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

View file

@ -9,51 +9,39 @@ import Introspect
import SwiftUI
extension View {
// MARK: Custom introspect functions
func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View {
inject(UIKitIntrospectionView(
selector: { introspectionView in
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
return nil
}
return Introspect.previousSibling(containing: UICollectionView.self, from: viewHost)
},
customize: customize
))
}
// From https://github.com/siteline/SwiftUI-Introspect/pull/129
public func introspectSearchController(customize: @escaping (UISearchController) -> Void) -> some View {
introspectNavigationController { navigationController in
let navigationBar = navigationController.navigationBar
if let searchController = navigationBar.topItem?.searchController {
customize(searchController)
}
}
}
// MARK: Modifiers
func conditionalContextMenu(id: some Hashable,
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
{
modifier(ConditionalContextMenu(internalContent, id: id))
modifier(ConditionalContextMenuModifier(internalContent, id: id))
}
func conditionalId(_ id: some Hashable) -> some View {
modifier(ConditionalId(id: id))
modifier(ConditionalIdModifier(id: id))
}
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
modifier(DisabledAppearance(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
}
func disableInteraction(_ disabled: Bool) -> some View {
modifier(DisableInteraction(disabled: disabled))
modifier(DisableInteractionModifier(disabled: disabled))
}
func inlinedList() -> some View {
modifier(InlinedList())
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

@ -15,16 +15,16 @@ struct FerriteApp: App {
@StateObject var toastModel: ToastViewModel = .init()
@StateObject var debridManager: DebridManager = .init()
@StateObject var navModel: NavigationViewModel = .init()
@StateObject var sourceManager: SourceManager = .init()
@StateObject var pluginManager: PluginManager = .init()
@StateObject var backupManager: BackupManager = .init()
var body: some Scene {
WindowGroup {
MainView()
.onAppear {
.backport.onAppear {
scrapingModel.toastModel = toastModel
debridManager.toastModel = toastModel
sourceManager.toastModel = toastModel
pluginManager.toastModel = toastModel
backupManager.toastModel = toastModel
navModel.toastModel = toastModel
}
@ -32,7 +32,7 @@ struct FerriteApp: App {
.environmentObject(scrapingModel)
.environmentObject(toastModel)
.environmentObject(navModel)
.environmentObject(sourceManager)
.environmentObject(pluginManager)
.environmentObject(backupManager)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}

View file

@ -0,0 +1,32 @@
//
// ActionModels.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import Foundation
public struct ActionJson: Codable, Hashable, PluginJson {
public let name: String
public let version: Int16
let minVersion: String?
let requires: [ActionRequirement]
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
public func getTags() -> [PluginTagJson] {
return requires.map { PluginTagJson(name: $0.rawValue, colorHex: nil) } + (tags.map { $0 } ?? [])
}
}
public enum ActionRequirement: String, Codable {
case magnet
case debrid
}

View file

@ -7,12 +7,17 @@
import Foundation
// Version is optional until v1 is phased out
public struct Backup: Codable {
let version: Int
let version: Int?
var bookmarks: [BookmarkJson]?
var history: [HistoryJson]?
var sourceNames: [String]?
var sourceLists: [SourceListBackupJson]?
var actionNames: [String]?
var pluginListUrls: [String]?
// MARK: Remove once v1 backups are unsupported
var sourceLists: [PluginListBackupJson]?
}
// MARK: - CoreData translation
@ -43,8 +48,8 @@ struct HistoryEntryJson: Codable {
let source: String?
}
// Differs from SourceListJson
struct SourceListBackupJson: Codable {
// Differs from PluginListJson
struct PluginListBackupJson: Codable {
let name: String
let author: String
let id: String

View file

@ -0,0 +1,32 @@
//
// PluginModels.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import Foundation
public struct PluginListJson: Codable {
let name: String
let author: String
var sources: [SourceJson]?
var actions: [ActionJson]?
}
// Color: Hex value
public struct PluginTagJson: Codable, Hashable, Sendable {
public let name: String
public let colorHex: String?
enum CodingKeys: String, CodingKey {
case name
case colorHex = "color"
}
}
extension PluginManager {
enum PluginManagerError: Error {
case ListAddition(description: String)
}
}

View file

@ -1,32 +0,0 @@
//
// SettingsModels.swift
// Ferrite
//
// Created by Brian Dashore on 8/11/22.
//
import Foundation
public enum DefaultMagnetActionType: Int, CaseIterable {
// Let the user choose
case none = 0
// Open in actions come first
case webtor = 1
// Sharing actions come last
case shareMagnet = 2
}
public enum DefaultDebridActionType: Int, CaseIterable {
// Let the user choose
case none = 0
// Open in actions come first
case outplayer = 1
case vlc = 2
case infuse = 3
// Sharing actions come last
case shareDownload = 4
}

View file

@ -12,26 +12,28 @@ public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case text
}
public struct SourceListJson: Codable, Sendable {
let name: String
let author: String
var sources: [SourceJson]
}
public struct SourceJson: Codable, Hashable, Sendable {
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 baseUrl: String?
let fallbackUrls: [String]?
var dynamicBaseUrl: Bool?
var author: String?
var listId: UUID?
let dynamicBaseUrl: Bool?
let trackers: [String]?
let api: SourceApiJson?
let jsonParser: SourceJsonParserJson?
let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson?
public var author: String?
public var listId: UUID?
public var tags: [PluginTagJson]?
}
extension SourceJson {
// Fetches all tags without optional requirement
public func getTags() -> [PluginTagJson] {
return tags ?? []
}
}
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
@ -60,10 +62,11 @@ public struct SourceJsonParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let results: String?
let subResults: String?
let magnetHash: SouceComplexQueryJson?
let magnetLink: SouceComplexQueryJson?
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
@ -71,10 +74,11 @@ public struct SourceRssParserJson: Codable, Hashable, Sendable {
let rssUrl: String?
let searchUrl: String
let items: String
let magnetHash: SouceComplexQueryJson?
let magnetLink: SouceComplexQueryJson?
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
@ -82,12 +86,13 @@ public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let rows: String
let magnet: SourceMagnetJson
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
public struct SouceComplexQueryJson: Codable, Hashable, Sendable {
public struct SourceComplexQueryJson: Codable, Hashable, Sendable {
let query: String
let discriminator: String?
let attribute: String?

View file

@ -0,0 +1,35 @@
//
// Plugin.swift
// Ferrite
//
// Created by Brian Dashore on 1/25/23.
//
import CoreData
import Foundation
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 enabled: Bool { get set }
var tags: NSOrderedSet? { get set }
func getTags() -> [PluginTagJson]
}
extension Plugin {
var tagArray: [PluginTag] {
return self.tags?.array as? [PluginTag] ?? []
}
}
public protocol PluginJson: Hashable {
var name: String { get }
var version: Int16 { get }
var author: String? { get set }
var listId: UUID? { get set }
var tags: [PluginTagJson]? { get set }
func getTags() -> [PluginTagJson]
}

View file

@ -9,18 +9,33 @@ import Foundation
public class BackupManager: ObservableObject {
// Constant variable for backup versions
let latestBackupVersion: Int = 1
let latestBackupVersion: Int = 2
var toastModel: ToastViewModel?
@Published var showRestoreAlert = false
@Published var showRestoreCompletedAlert = false
@Published var restoreCompletedMessage: [String] = []
@Published var backupUrls: [URL] = []
@Published var backupSourceNames: [String] = []
@Published var selectedBackupUrl: URL?
func createBackup() {
@MainActor
func updateRestoreCompletedMessage(newString: String) {
restoreCompletedMessage.append(newString)
}
@MainActor
func toggleRestoreCompletedAlert() {
showRestoreCompletedAlert.toggle()
}
@MainActor
func updateBackupUrls(newUrl: URL) {
backupUrls.append(newUrl)
}
func createBackup() async {
var backup = Backup(version: latestBackupVersion)
let backgroundContext = PersistenceController.shared.backgroundContext
@ -71,16 +86,14 @@ public class BackupManager: ObservableObject {
backup.sourceNames = sources.map(\.name)
}
let sourceListRequest = SourceList.fetchRequest()
if let sourceLists = try? backgroundContext.fetch(sourceListRequest) {
backup.sourceLists = sourceLists.map {
SourceListBackupJson(
name: $0.name,
author: $0.author,
id: $0.id.uuidString,
urlString: $0.urlString
)
}
let actionRequest = Action.fetchRequest()
if let actions = try? backgroundContext.fetch(actionRequest) {
backup.actionNames = actions.map(\.name)
}
let pluginListRequest = PluginList.fetchRequest()
if let pluginLists = try? backgroundContext.fetch(pluginListRequest) {
backup.pluginListUrls = pluginLists.map(\.urlString)
}
do {
@ -94,18 +107,20 @@ public class BackupManager: ObservableObject {
let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb")
try encodedJson.write(to: writeUrl)
backupUrls.append(writeUrl)
await updateBackupUrls(newUrl: writeUrl)
} catch {
print(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
func restoreBackup() {
// 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 {
Task {
await toastModel?.updateToastDescription("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
}
@ -113,64 +128,74 @@ public class BackupManager: ObservableObject {
let backgroundContext = PersistenceController.shared.backgroundContext
do {
// Delete all relevant entities to prevent issues with restoration if overwrite is selected
if doOverwrite {
try PersistenceController.shared.batchDelete("Bookmark")
try PersistenceController.shared.batchDelete("History")
try PersistenceController.shared.batchDelete("HistoryEntry")
try PersistenceController.shared.batchDelete("PluginList")
try PersistenceController.shared.batchDelete("Source")
try PersistenceController.shared.batchDelete("Action")
}
let file = try Data(contentsOf: backupUrl)
let backup = try JSONDecoder().decode(Backup.self, from: file)
if let bookmarks = backup.bookmarks {
for bookmark in bookmarks {
PersistenceController.shared.createBookmark(bookmark)
PersistenceController.shared.createBookmark(bookmark, performSave: false)
}
}
if let storedHistories = backup.history {
for storedHistory in storedHistories {
for storedEntry in storedHistory.entries {
PersistenceController.shared.createHistory(storedEntry, date: storedHistory.date)
PersistenceController.shared.createHistory(
storedEntry,
performSave: false,
isBackup: true,
date: storedHistory.date
)
}
}
}
if let storedLists = backup.sourceLists {
let version = backup.version ?? -1
if let storedLists = backup.sourceLists, version < 2 {
// Only present in v1 or no version backups
for list in storedLists {
let sourceListRequest = SourceList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", list.urlString)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", list.author, list.name)
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
sourceListRequest.fetchLimit = 1
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
continue
}
let newSourceList = SourceList(context: backgroundContext)
newSourceList.name = list.name
newSourceList.urlString = list.urlString
newSourceList.id = UUID(uuidString: list.id) ?? UUID()
newSourceList.author = list.author
try await pluginManager.addPluginList(list.urlString, existingPluginList: nil)
}
} else if let pluginListUrls = backup.pluginListUrls {
// v2 and up
for listUrl in pluginListUrls {
try await pluginManager.addPluginList(listUrl, existingPluginList: nil)
}
}
backupSourceNames = backup.sourceNames ?? []
if let sourceNames = backup.sourceNames {
await updateRestoreCompletedMessage(newString: sourceNames.isEmpty ? "No sources need to be reinstalled" : "Reinstall sources: \(sourceNames.joined(separator: ", "))")
}
if let actionNames = backup.actionNames {
await updateRestoreCompletedMessage(newString: actionNames.isEmpty ? "No actions need to be reinstalled" : "Reinstall actions: \(actionNames.joined(separator: ", "))")
}
PersistenceController.shared.save(backgroundContext)
// if iOS 14 is available, sleep to prevent any issues with alerts
if #available(iOS 15, *) {
showRestoreCompletedAlert.toggle()
await toggleRestoreCompletedAlert()
} else {
Task {
try? await Task.sleep(seconds: 0.1)
try? await Task.sleep(seconds: 0.1)
Task { @MainActor in
showRestoreCompletedAlert.toggle()
}
}
await toggleRestoreCompletedAlert()
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup restore: \(error)")
}
await toastModel?.updateToastDescription("Backup restore error: \(error)")
print("Backup restore error: \(error)")
}
}
@ -187,7 +212,8 @@ public class BackupManager: ObservableObject {
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup removal: \(error)")
await toastModel?.updateToastDescription("Backup removal error: \(error)")
print("Backup removal error: \(error)")
}
}
}

View file

@ -19,7 +19,6 @@ public class DebridManager: ObservableObject {
// UI Variables
@Published var showWebView: Bool = false
@Published var showAuthSession: Bool = false
@Published var showLoadingProgress: Bool = false
// Service agnostic variables
@Published var enabledDebrids: Set<DebridType> = [] {
@ -50,6 +49,7 @@ public class DebridManager: ObservableObject {
var selectedRealDebridFile: RealDebrid.IAFile?
var selectedRealDebridID: String?
// TODO: Maybe make these generic?
// RealDebrid cloud variables
@Published var realDebridCloudTorrents: [RealDebrid.UserTorrentsResponse] = []
@Published var realDebridCloudDownloads: [RealDebrid.UserDownloadsResponse] = []
@ -479,10 +479,13 @@ public class DebridManager: ObservableObject {
public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
defer {
currentDebridTask = nil
showLoadingProgress = false
toastModel?.hideIndeterminateToast()
}
showLoadingProgress = true
toastModel?.updateIndeterminateToast("Loading content", cancelAction: {
self.currentDebridTask?.cancel()
self.currentDebridTask = nil
})
switch selectedDebridType {
case .realDebrid:
@ -557,7 +560,7 @@ public class DebridManager: ObservableObject {
await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false)
}
showLoadingProgress = false
toastModel?.hideIndeterminateToast()
}
}

View file

@ -7,30 +7,38 @@
import SwiftUI
enum ViewTab {
case search
case sources
case settings
case library
}
@MainActor
class NavigationViewModel: ObservableObject {
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
}
case magnet
case action
case batch
case activity
}
@Published var isEditingSearch: Bool = false
@Published var isSearching: Bool = false
enum ViewTab {
case search
case plugins
case settings
case library
}
enum LibraryPickerSegment {
case bookmarks
case history
case debridCloud
}
enum PluginPickerSegment {
case sources
case actions
}
@Published var selectedMagnet: Magnet?
@Published var selectedHistoryInfo: HistoryEntryJson?
@ -49,82 +57,13 @@ class NavigationViewModel: ObservableObject {
@Published var showLocalActivitySheet = false
@Published var selectedTab: ViewTab = .search
@Published var showSearchProgress: Bool = false
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
var selectedSource: Source?
@Published var showSourceListEditor: Bool = false
var selectedSourceList: SourceList?
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
public func runDebridAction(urlString: String, _ action: DefaultDebridActionType? = nil) {
let selectedAction = action ?? defaultDebridAction
switch selectedAction {
case .none:
currentChoiceSheet = .magnet
case .outplayer:
if let downloadUrl = URL(string: "outplayer://\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.updateToastDescription("Could not create an Outplayer URL")
}
case .vlc:
if let downloadUrl = URL(string: "vlc://\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.updateToastDescription("Could not create a VLC URL")
}
case .infuse:
if let downloadUrl = URL(string: "infuse://x-callback-url/play?url=\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.updateToastDescription("Could not create a Infuse URL")
}
case .shareDownload:
if let downloadUrl = URL(string: urlString), currentChoiceSheet == nil {
activityItems = [downloadUrl]
currentChoiceSheet = .activity
} else {
toastModel?.updateToastDescription("Could not create object for sharing")
}
}
}
public func runMagnetAction(magnet: Magnet?, _ action: DefaultMagnetActionType? = nil) {
// Fall back to selected magnet if the provided magnet is nil
let magnet = magnet ?? selectedMagnet
guard let magnetLink = magnet?.link else {
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("Magnet action error: The magnet link is invalid.")
return
}
let selectedAction = action ?? defaultMagnetAction
switch selectedAction {
case .none:
currentChoiceSheet = .magnet
case .webtor:
if let url = URL(string: "https://webtor.io/#/show?magnet=\(magnetLink)") {
UIApplication.shared.open(url)
} else {
toastModel?.updateToastDescription("Could not create a WebTor URL")
}
case .shareMagnet:
if let magnetUrl = URL(string: magnetLink),
currentChoiceSheet == nil
{
activityItems = [magnetUrl]
currentChoiceSheet = .activity
} else {
toastModel?.updateToastDescription("Could not create object for sharing")
}
}
}
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
@Published var pluginPickerSelection: PluginPickerSegment = .sources
}

View file

@ -0,0 +1,685 @@
//
// SourceManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/25/22.
//
import Foundation
import SwiftUI
public class PluginManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var availableSources: [SourceJson] = []
@Published var availableActions: [ActionJson] = []
@Published var showBrokenDefaultActionAlert = false
@MainActor
public func fetchPluginsFromUrl() async {
let pluginListRequest = PluginList.fetchRequest()
do {
let pluginLists = try PersistenceController.shared.backgroundContext.fetch(pluginListRequest)
// Clean availablePlugin arrays for repopulation
availableSources = []
availableActions = []
for pluginList in pluginLists {
guard let url = URL(string: pluginList.urlString) else {
return
}
// 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 = try JSONDecoder().decode(PluginListJson.self, from: data)
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
}
}
}
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
}
}
}
}
} catch {
let error = error as NSError
if error.code != -999 {
toastModel?.updateToastDescription("Plugin fetch error: \(error)")
}
print("Plugin fetch error: \(error)")
}
}
// forType required to guide generic inferences
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
})
if searchText.isEmpty {
return !pluginExists
} else {
return !pluginExists && availablePlugin.name.lowercased().contains(searchText.lowercased())
}
}
}
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
}),
availablePlugin.version > plugin.version
{
updatedPlugins.append(availablePlugin)
}
}
return updatedPlugins
.filter {
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
}
}
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
switch String(describing: PJ.self) {
case "SourceJson":
return availableSources as? [PJ] ?? []
case "ActionJson":
return availableActions as? [PJ] ?? []
default:
return []
}
}
// Checks if the current app version is supported by the source
func checkAppVersion(minVersion: String?) -> Bool {
// If there's no min version, assume that every version is supported
guard let minVersion else {
return true
}
return Application.shared.appVersion >= minVersion
}
// Fetches sources using the background context
public func fetchInstalledSources() -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
return sources.compactMap { $0 }
} else {
return []
}
}
@MainActor
public func runDebridAction(urlString: String?, currentChoiceSheet: inout NavigationViewModel.ChoiceSheetType?) {
let context = PersistenceController.shared.backgroundContext
if
let defaultDebridActionName = UserDefaults.standard.string(forKey: "Actions.DefaultDebridName"),
let defaultDebridActionList = UserDefaults.standard.string(forKey: "Actions.DefaultDebridList")
{
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 {
currentChoiceSheet = .action
UserDefaults.standard.set(nil, forKey: "Actions.DefaultDebridName")
UserDefaults.standard.set(nil, forKey: "Action.DefaultDebridList")
showBrokenDefaultActionAlert.toggle()
}
} else {
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
public func runDeeplinkAction(_ action: Action, urlString: String?) {
guard let deeplink = action.deeplink, let urlString else {
toastModel?.updateToastDescription("Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!")
print("Could not run action: \(action.name) since there is no deeplink to execute.")
return
}
let playbackUrl = URL(string: deeplink.replacingOccurrences(of: "{link}", with: urlString))
if let playbackUrl {
UIApplication.shared.open(playbackUrl)
} else {
toastModel?.updateToastDescription("Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!")
print("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
}
}
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else {
await toastModel?.updateToastDescription("Action addition error: No action present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
if actionJson.requires.count < 1 {
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 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
}
let existingActionRequest = Action.fetchRequest()
existingActionRequest.predicate = NSPredicate(format: "name == %@", actionJson.name)
existingActionRequest.fetchLimit = 1
if let existingAction = try? backgroundContext.fetch(existingActionRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingAction, context: backgroundContext)
} else {
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
}
}
let newAction = Action(context: backgroundContext)
newAction.id = UUID()
newAction.name = actionJson.name
newAction.version = actionJson.version
newAction.author = actionJson.author ?? "Unknown"
newAction.listId = actionJson.listId
newAction.requires = actionJson.requires.map { $0.rawValue }
newAction.enabled = true
if let jsonTags = actionJson.tags {
for tag in jsonTags {
let newTag = PluginTag(context: backgroundContext)
newTag.name = tag.name
newTag.colorHex = tag.colorHex
newTag.parentAction = newAction
}
}
newAction.deeplink = deeplink
do {
try backgroundContext.save()
} catch {
await toastModel?.updateToastDescription("Action addition error: \(error)")
print("Action addition error: \(error)")
}
}
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
guard let sourceJson else {
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 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
}
// If a source exists, don't add the new one unless upserting
let existingSourceRequest = Source.fetchRequest()
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
existingSourceRequest.fetchLimit = 1
if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
print("Source name \(sourceJson.name) already exists")
return
}
}
let newSource = Source(context: backgroundContext)
newSource.id = UUID()
newSource.name = sourceJson.name
newSource.version = sourceJson.version
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
if let jsonTags = sourceJson.tags {
for tag in jsonTags {
let newTag = PluginTag(context: backgroundContext)
newTag.name = tag.name
newTag.colorHex = tag.colorHex
newTag.parentSource = newSource
}
}
if let sourceApiJson = sourceJson.api {
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
}
if let jsonParserJson = sourceJson.jsonParser {
addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson)
}
// Adds an RSS parser if present
if let rssParserJson = sourceJson.rssParser {
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
}
// Adds an HTML parser if present
if let htmlParserJson = sourceJson.htmlParser {
addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
}
// Add an API condition as well
if newSource.jsonParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue)
} else if newSource.rssParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
} else {
newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
}
newSource.enabled = true
do {
try backgroundContext.save()
} catch {
await toastModel?.updateToastDescription("Source addition error: \(error)")
print("Source addition error: \(error)")
}
}
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext)
newSourceApi.apiUrl = apiJson.apiUrl
if let clientIdJson = apiJson.clientId {
let newClientId = SourceApiClientId(context: backgroundContext)
newClientId.query = clientIdJson.query
newClientId.urlString = clientIdJson.url
newClientId.dynamic = clientIdJson.dynamic ?? false
newClientId.value = clientIdJson.value
newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientId.expiryLength = clientIdJson.expiryLength ?? 0
newClientId.timeStamp = Date()
newSourceApi.clientId = newClientId
}
if let clientSecretJson = apiJson.clientSecret {
let newClientSecret = SourceApiClientSecret(context: backgroundContext)
newClientSecret.query = clientSecretJson.query
newClientSecret.urlString = clientSecretJson.url
newClientSecret.dynamic = clientSecretJson.dynamic ?? false
newClientSecret.value = clientSecretJson.value
newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0
newClientSecret.timeStamp = Date()
newSourceApi.clientSecret = newClientSecret
}
newSource.api = newSourceApi
}
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
newSourceJsonParser.searchUrl = jsonParserJson.searchUrl
newSourceJsonParser.results = jsonParserJson.results
newSourceJsonParser.subResults = jsonParserJson.subResults
// Tune these complex queries to the final JSON parser format
if let magnetLinkJson = jsonParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceJsonParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = jsonParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceJsonParser.magnetHash = newSourceMagnetHash
}
if let subNameJson = jsonParserJson.subName {
let newSourceSubName = SourceSubName(context: backgroundContext)
newSourceSubName.query = subNameJson.query
newSourceSubName.attribute = subNameJson.query
newSourceSubName.discriminator = subNameJson.discriminator
newSourceJsonParser.subName = newSourceSubName
}
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
}
if let sizeJson = jsonParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceJsonParser.size = newSourceSize
}
if let seedLeechJson = jsonParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceJsonParser.seedLeech = newSourceSeedLeech
}
newSource.jsonParser = newSourceJsonParser
}
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext)
newSourceRssParser.rssUrl = rssParserJson.rssUrl
newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items
if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceMagnetLink.regex = magnetLinkJson.regex
newSourceRssParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = rssParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceMagnetHash.regex = magnetHashJson.regex
newSourceRssParser.magnetHash = newSourceMagnetHash
}
if let subNameJson = rssParserJson.subName {
let newSourceSubName = SourceSubName(context: backgroundContext)
newSourceSubName.query = subNameJson.query
newSourceSubName.attribute = subNameJson.attribute ?? "text"
newSourceSubName.discriminator = subNameJson.discriminator
newSourceSubName.regex = subNameJson.regex
newSourceRssParser.subName = newSourceSubName
}
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
}
if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceSize.regex = sizeJson.regex
newSourceRssParser.size = newSourceSize
}
if let seedLeechJson = rssParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceRssParser.seedLeech = newSourceSeedLeech
}
newSource.rssParser = newSourceRssParser
}
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
newSourceHtmlParser.rows = htmlParserJson.rows
if let subNameJson = htmlParserJson.subName {
let newSourceSubName = SourceSubName(context: backgroundContext)
newSourceSubName.query = subNameJson.query
newSourceSubName.attribute = subNameJson.attribute ?? "text"
newSourceSubName.regex = subNameJson.regex
newSourceHtmlParser.subName = newSourceSubName
}
// 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.title = newSourceTitle
}
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.regex = sizeJson.regex
newSourceHtmlParser.size = newSourceSize
}
if let seedLeechJson = htmlParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceHtmlParser.seedLeech = newSourceSeedLeech
}
// Adds a magnet complex query and its unique properties
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
newSourceMagnet.query = htmlParserJson.magnet.query
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
newSourceMagnet.regex = htmlParserJson.magnet.regex
newSourceHtmlParser.magnetLink = newSourceMagnet
newSource.htmlParser = newSourceHtmlParser
}
// Adds a plugin list
// Can move this to PersistenceController if needed
public func addPluginList(_ url: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext
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.")
}
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 = 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 == %@", url)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
pluginListRequest.fetchLimit = 1
if let existingPluginList = try? backgroundContext.fetch(pluginListRequest).first, !isSheet {
PersistenceController.shared.delete(existingPluginList, context: backgroundContext)
} else if isSheet {
throw PluginManagerError.ListAddition(description: "An existing plugin list with this information was found. Please try editing an existing plugin list instead.")
}
let newPluginList = PluginList(context: backgroundContext)
newPluginList.id = UUID()
newPluginList.urlString = url
newPluginList.name = rawResponse.name
newPluginList.author = rawResponse.author
try backgroundContext.save()
}
}
}

View file

@ -18,13 +18,23 @@ class ScrapingViewModel: ObservableObject {
var runningSearchTask: Task<Void, Error>?
@Published var searchResults: [SearchResult] = []
@Published var searchText: String = ""
@Published var filteredSource: Source?
@Published var currentSourceName: String?
// Only add results with valid magnet hashes to the search results array
@MainActor
func updateSearchResults(newResults: [SearchResult]) {
searchResults = newResults
searchResults += newResults.filter { $0.magnet.hash != nil }
}
@MainActor
func clearSearchResults() {
searchResults = []
}
func cancelCurrentTask() {
runningSearchTask?.cancel()
runningSearchTask = nil
}
// Utility function to print source specific errors
@ -38,7 +48,7 @@ class ScrapingViewModel: ObservableObject {
print(newDescription)
}
public func scanSources(sources: [Source]) async {
public func scanSources(sources: [Source], searchText: String) async {
if sources.isEmpty {
await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info)
@ -46,13 +56,20 @@ class ScrapingViewModel: ObservableObject {
return
}
var tempResults: [SearchResult] = []
await clearSearchResults()
await toastModel?.updateIndeterminateToast("Loading", cancelAction: {
self.cancelCurrentTask()
})
for source in sources {
// If the search is cancelled, return
if let runningSearchTask, runningSearchTask.isCancelled {
return
}
if source.enabled {
Task { @MainActor in
currentSourceName = source.name
}
await toastModel?.updateIndeterminateToast("Loading \(source.name)", cancelAction: nil)
guard let baseUrl = source.baseUrl else {
await toastModel?.updateToastDescription("The base URL could not be found for source \(source.name)")
@ -86,7 +103,7 @@ class ScrapingViewModel: ObservableObject {
let html = String(data: data, encoding: .utf8)
{
let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html)
tempResults += sourceResults
await updateSearchResults(newResults: sourceResults)
}
}
case .rss:
@ -111,7 +128,7 @@ class ScrapingViewModel: ObservableObject {
let rss = String(data: data, encoding: .utf8)
{
let sourceResults = await scrapeRss(source: source, rss: rss)
tempResults += sourceResults
await updateSearchResults(newResults: sourceResults)
}
}
case .siteApi:
@ -155,7 +172,7 @@ class ScrapingViewModel: ObservableObject {
if let data {
let sourceResults = await scrapeJson(source: source, jsonData: data)
tempResults += sourceResults
await updateSearchResults(newResults: sourceResults)
}
}
case .none:
@ -164,12 +181,10 @@ class ScrapingViewModel: ObservableObject {
}
}
// If the task is cancelled, return
// If the search is cancelled, return
if let searchTask = runningSearchTask, searchTask.isCancelled {
return
}
await updateSearchResults(newResults: tempResults)
}
// Checks the base URL for any website data then iterates through the fallback URLs
@ -399,6 +414,12 @@ class ScrapingViewModel: ObservableObject {
}
}
var subName: String?
if let subNameParser = jsonParser.subName {
let rawSubName = result[subNameParser.query.components(separatedBy: ".")].rawValue
subName = rawSubName is NSNull ? nil : String(describing: rawSubName)
}
var link: String? = existingSearchResult?.magnet.link
if let magnetLinkParser = jsonParser.magnetLink, link == nil {
let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue
@ -432,7 +453,7 @@ class ScrapingViewModel: ObservableObject {
let result = SearchResult(
title: title,
source: source.name,
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
size: size,
magnet: Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers),
seeders: seeders,
@ -462,6 +483,7 @@ class ScrapingViewModel: ObservableObject {
}
for item in items {
//print(item)
// Parse magnet link or translate hash
var magnetHash: String?
if let magnetHashParser = rssParser.magnetHash {
@ -474,6 +496,18 @@ class ScrapingViewModel: ObservableObject {
)
}
// Fetches the subName for the source if there is one
var subName: String?
if let subNameParser = rssParser.subName {
subName = try? runRssComplexQuery(
item: item,
query: subNameParser.query,
attribute: subNameParser.attribute,
discriminator: subNameParser.discriminator,
regexString: subNameParser.regex
)
}
var title: String?
if let titleParser = rssParser.title {
title = try? runRssComplexQuery(
@ -485,21 +519,15 @@ class ScrapingViewModel: ObservableObject {
)
}
var link: String?
var href: String?
if let magnetLinkParser = rssParser.magnetLink {
link = try? runRssComplexQuery(
href = try? runRssComplexQuery(
item: item,
query: magnetLinkParser.query,
attribute: magnetLinkParser.attribute,
discriminator: magnetLinkParser.discriminator,
regexString: magnetLinkParser.regex
)
} else {
continue
}
guard let href = link, href.starts(with: "magnet:") else {
continue
}
var size: String?
@ -543,7 +571,7 @@ class ScrapingViewModel: ObservableObject {
let result = SearchResult(
title: title ?? "No title",
source: source.name,
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
size: size ?? "",
magnet: Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers),
seeders: seeders,
@ -649,10 +677,6 @@ class ScrapingViewModel: ObservableObject {
href = link
}
if !href.starts(with: "magnet:") {
continue
}
// Fetches the episode/movie title
var title: String?
if let titleParser = htmlParser.title {
@ -664,8 +688,17 @@ class ScrapingViewModel: ObservableObject {
)
}
// Fetches the torrent's size
// TODO: Add int translation
var subName: String?
if let subNameParser = htmlParser.subName {
subName = try? runHtmlComplexQuery(
row: row,
query: subNameParser.query,
attribute: subNameParser.attribute,
regexString: subNameParser.regex
)
}
// Fetches the size
var size: String?
if let sizeParser = htmlParser.size {
size = try? runHtmlComplexQuery(
@ -718,7 +751,7 @@ class ScrapingViewModel: ObservableObject {
let result = SearchResult(
title: title ?? "No title",
source: source.name,
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
size: size ?? "",
magnet: Magnet(hash: nil, link: href),
seeders: seeders,

View file

@ -1,426 +0,0 @@
//
// SourceManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/25/22.
//
import CoreData
import Foundation
import SwiftUI
public class SourceManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var availableSources: [SourceJson] = []
var urlErrorAlertText = ""
@Published var showUrlErrorAlert = false
@MainActor
public func fetchSourcesFromUrl() async {
let sourceListRequest = SourceList.fetchRequest()
do {
let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest)
var tempAvailableSources: [SourceJson] = []
for sourceList in sourceLists {
guard let url = URL(string: sourceList.urlString) else {
return
}
// 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 sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
for var source in sourceResponse.sources {
// If there is a minVersion, check and see if the source is valid
if checkAppVersion(minVersion: source.minVersion) {
source.author = sourceList.author
source.listId = sourceList.id
tempAvailableSources.append(source)
}
}
}
availableSources = tempAvailableSources
} catch {
print(error)
}
}
func fetchUpdatedSources(installedSources: FetchedResults<Source>) -> [SourceJson] {
var updatedSources: [SourceJson] = []
for source in installedSources {
if let availableSource = availableSources.first(where: {
source.listId == $0.listId && source.name == $0.name && source.author == $0.author
}),
availableSource.version > source.version
{
updatedSources.append(availableSource)
}
}
return updatedSources
}
// Checks if the current app version is supported by the source
func checkAppVersion(minVersion: String?) -> Bool {
// If there's no min version, assume that every version is supported
guard let minVersion else {
return true
}
return Application.shared.appVersion >= minVersion
}
// Fetches sources using the background context
public func fetchInstalledSources() -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
return sources.compactMap { $0 }
} else {
return []
}
}
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async {
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
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 this source because base URL parameters are malformed")
return
}
// If a source exists, don't add the new one unless upserting
let existingSourceRequest = Source.fetchRequest()
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
existingSourceRequest.fetchLimit = 1
if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
return
}
}
let newSource = Source(context: backgroundContext)
newSource.id = UUID()
newSource.name = sourceJson.name
newSource.version = sourceJson.version
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
if let sourceApiJson = sourceJson.api {
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
}
if let jsonParserJson = sourceJson.jsonParser {
addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson)
}
// Adds an RSS parser if present
if let rssParserJson = sourceJson.rssParser {
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
}
// Adds an HTML parser if present
if let htmlParserJson = sourceJson.htmlParser {
addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
}
// Add an API condition as well
if newSource.jsonParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue)
} else if newSource.rssParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
} else {
newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
}
newSource.enabled = true
do {
try backgroundContext.save()
} catch {
await toastModel?.updateToastDescription(error.localizedDescription)
}
}
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext)
newSourceApi.apiUrl = apiJson.apiUrl
if let clientIdJson = apiJson.clientId {
let newClientId = SourceApiClientId(context: backgroundContext)
newClientId.query = clientIdJson.query
newClientId.urlString = clientIdJson.url
newClientId.dynamic = clientIdJson.dynamic ?? false
newClientId.value = clientIdJson.value
newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientId.expiryLength = clientIdJson.expiryLength ?? 0
newClientId.timeStamp = Date()
newSourceApi.clientId = newClientId
}
if let clientSecretJson = apiJson.clientSecret {
let newClientSecret = SourceApiClientSecret(context: backgroundContext)
newClientSecret.query = clientSecretJson.query
newClientSecret.urlString = clientSecretJson.url
newClientSecret.dynamic = clientSecretJson.dynamic ?? false
newClientSecret.value = clientSecretJson.value
newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0
newClientSecret.timeStamp = Date()
newSourceApi.clientSecret = newClientSecret
}
newSource.api = newSourceApi
}
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
newSourceJsonParser.searchUrl = jsonParserJson.searchUrl
newSourceJsonParser.results = jsonParserJson.results
newSourceJsonParser.subResults = jsonParserJson.subResults
// Tune these complex queries to the final JSON parser format
if let magnetLinkJson = jsonParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceJsonParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = jsonParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceJsonParser.magnetHash = newSourceMagnetHash
}
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
}
if let sizeJson = jsonParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceJsonParser.size = newSourceSize
}
if let seedLeechJson = jsonParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceJsonParser.seedLeech = newSourceSeedLeech
}
newSource.jsonParser = newSourceJsonParser
}
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext)
newSourceRssParser.rssUrl = rssParserJson.rssUrl
newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items
if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceRssParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = rssParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceRssParser.magnetHash = newSourceMagnetHash
}
if let titleJson = rssParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.discriminator = titleJson.discriminator
newSourceRssParser.title = newSourceTitle
}
if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceRssParser.size = newSourceSize
}
if let seedLeechJson = rssParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceRssParser.seedLeech = newSourceSeedLeech
}
newSource.rssParser = newSourceRssParser
}
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
newSourceHtmlParser.rows = htmlParserJson.rows
// 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.title = newSourceTitle
}
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.regex = sizeJson.regex
newSourceHtmlParser.size = newSourceSize
}
if let seedLeechJson = htmlParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceHtmlParser.seedLeech = newSourceSeedLeech
}
// Adds a magnet complex query and its unique properties
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
newSourceMagnet.query = htmlParserJson.magnet.query
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
newSourceMagnet.regex = htmlParserJson.magnet.regex
newSourceHtmlParser.magnetLink = newSourceMagnet
newSource.htmlParser = newSourceHtmlParser
}
@MainActor
public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool {
let backgroundContext = PersistenceController.shared.backgroundContext
if sourceUrl.isEmpty || URL(string: sourceUrl) == nil {
urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly."
showUrlErrorAlert.toggle()
return false
}
do {
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!))
let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
if let existingSourceList {
existingSourceList.urlString = sourceUrl
existingSourceList.name = rawResponse.name
existingSourceList.author = rawResponse.author
try PersistenceController.shared.container.viewContext.save()
} else {
let sourceListRequest = SourceList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", sourceUrl)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
sourceListRequest.fetchLimit = 1
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
urlErrorAlertText = "An existing source with this information was found. Please try editing the source list instead."
showUrlErrorAlert.toggle()
return false
}
let newSourceUrl = SourceList(context: backgroundContext)
newSourceUrl.id = UUID()
newSourceUrl.urlString = sourceUrl
newSourceUrl.name = rawResponse.name
newSourceUrl.author = rawResponse.author
try backgroundContext.save()
}
return true
} catch {
print(error)
urlErrorAlertText = error.localizedDescription
showUrlErrorAlert.toggle()
return false
}
}
}

View file

@ -35,6 +35,10 @@ class ToastViewModel: ObservableObject {
@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
@ -43,6 +47,24 @@ class ToastViewModel: ObservableObject {
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

@ -29,9 +29,9 @@ struct AlertButton: Identifiable {
}
// Used for buttons with no action
init(_ label: String = "Cancel", role: Role? = nil) {
init(_ label: String? = nil, role: Role? = nil) {
id = UUID()
self.label = label
self.label = label ?? (role == .cancel ? "Cancel" : "OK")
action = {}
self.role = role
}

View file

@ -6,6 +6,7 @@
//
import SwiftUI
import Introspect
public struct Backport<Content> {
public let content: Content
@ -20,7 +21,12 @@ extension View {
}
extension Backport where Content: View {
@ViewBuilder func alert(isPresented: Binding<Bool>, title: String, message: String?, buttons: [AlertButton]) -> some View {
@ViewBuilder func alert(
isPresented: Binding<Bool>,
title: String,
message: String?,
buttons: [AlertButton] = []
) -> some View {
if #available(iOS 15, *) {
content
.alert(
@ -55,7 +61,7 @@ extension Backport where Content: View {
return Alert(
title: Text(title),
message: message.map { Text($0) } ?? nil,
dismissButton: buttons[0].toActionButton()
dismissButton: buttons[safe: 0].map { $0.toActionButton() } ?? .cancel()
)
}
}
@ -63,7 +69,11 @@ extension Backport where Content: View {
}
}
@ViewBuilder func confirmationDialog(isPresented: Binding<Bool>, title: String, message: String?, buttons: [AlertButton]) -> some View {
@ViewBuilder func confirmationDialog(
isPresented: Binding<Bool>,
title: String, message: String?,
buttons: [AlertButton]
) -> some View {
if #available(iOS 15, *) {
content
.confirmationDialog(
@ -100,4 +110,31 @@ extension Backport where Content: View {
.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

@ -21,9 +21,10 @@ struct DynamicFetchRequest<T: NSManagedObject, Content: View>: View {
}
init(predicate: NSPredicate?,
sortDescriptors: [NSSortDescriptor] = [],
@ViewBuilder content: @escaping (FetchedResults<T>) -> Content)
{
_fetchRequest = FetchRequest<T>(sortDescriptors: [], predicate: predicate)
_fetchRequest = FetchRequest<T>(entity: T.entity(), sortDescriptors: sortDescriptors, predicate: predicate)
self.content = content
}
}

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

@ -25,7 +25,7 @@ struct IndeterminateProgressView: View {
.offset(x: -reader.size.width * 0.6, y: 0)
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: self.offset)
.onAppear {
.backport.onAppear {
withAnimation {
self.offset = 1
}

View file

@ -0,0 +1,17 @@
//
// LibraryHeaderView.swift
// Ferrite
//
// Created by Brian Dashore on 2/12/23.
//
import SwiftUI
struct LibraryHeaderView: View {
@EnvironmentObject var debridManager: DebridManager
@Binding var selectedSegment: LibraryPickerSegment
var body: some View {
}
}

View file

@ -10,7 +10,7 @@
import SwiftUI
struct ConditionalContextMenu<InternalContent: View, ID: Hashable>: ViewModifier {
struct ConditionalContextMenuModifier<InternalContent: View, ID: Hashable>: ViewModifier {
let internalContent: () -> InternalContent
let id: ID

View file

@ -10,7 +10,7 @@
import SwiftUI
struct ConditionalId<ID: Hashable>: ViewModifier {
struct ConditionalIdModifier<ID: Hashable>: ViewModifier {
let id: ID
func body(content: Content) -> some View {

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

@ -9,7 +9,7 @@
import SwiftUI
struct DisableInteraction: ViewModifier {
struct DisableInteractionModifier: ViewModifier {
let disabled: Bool
func body(content: Content) -> some View {

View file

@ -9,7 +9,7 @@
import SwiftUI
struct DisabledAppearance: ViewModifier {
struct DisabledAppearanceModifier: ViewModifier {
let disabled: Bool
let dimmedOpacity: Double?
let animation: Animation?

View file

@ -11,17 +11,19 @@
import Introspect
import SwiftUI
struct InlinedList: ViewModifier {
struct InlinedListModifier: ViewModifier {
let inset: CGFloat
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
.introspectCollectionView { collectionView in
collectionView.contentInset.top = -20
collectionView.contentInset.top = inset
}
} else {
content
.introspectTableView { tableView in
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 20))
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

@ -11,19 +11,16 @@
import SwiftUI
struct NavView<Content: View>: View {
let content: () -> Content
init(@ViewBuilder _ content: @escaping () -> Content) {
self.content = content
}
@ViewBuilder var content: Content
var body: some View {
if #available(iOS 16, *) {
NavigationStack {
content()
content
}
} else {
NavigationView {
content()
content
}
.navigationViewStyle(.stack)
}

View file

@ -0,0 +1,37 @@
//
// SearchableContent.swift
// Ferrite
//
// Created by Brian Dashore on 2/11/23.
//
// View to link animations together with searchbar
// Passes through geometry proxy and last height vars for any comparison
//
import SwiftUI
struct SearchableContent<Content: View>: View {
@Binding var searching: Bool
@State private var lastHeight: CGFloat = 0.0
@ViewBuilder var content: (Bool) -> Content
var body: some View {
GeometryReader { geom in
// Return if the height has changed as a closure variable for child transactions
content(geom.size.height != lastHeight)
.backport.onAppear {
lastHeight = geom.size.height
}
.onChange(of: geom.size.height) { newHeight in
lastHeight = newHeight
}
.transaction {
if geom.size.height != lastHeight && searching {
$0.animation = .default.speed(2)
}
}
}
}
}

View file

@ -0,0 +1,20 @@
//
// SectionHeaderView.swift
// Ferrite
//
// Created by Brian Dashore on 2/15/23.
//
import SwiftUI
struct SectionHeaderView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
struct SectionHeaderView_Previews: PreviewProvider {
static var previews: some View {
SectionHeaderView()
}
}

View file

@ -0,0 +1,28 @@
//
// Tag.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
import SwiftUI
struct Tag: View {
let name: String
let color: Color?
var horizontalPadding: CGFloat = 7
var verticalPadding: CGFloat = 4
var body: some View {
Text(name.capitalizingFirstLetter())
.font(.caption)
.opacity(0.8)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(
RoundedRectangle(cornerRadius: 5)
.foregroundColor(color.map { $0 } ?? .tertiaryLabel)
.opacity(0.3)
)
}
}

View file

@ -0,0 +1,75 @@
//
// TestHostingView.swift
// Ferrite
//
// Created by Brian Dashore on 2/13/23.
//
import SwiftUI
struct TestHostingView: View {
@State private var textName = "First"
@State private var secondTextName = "First"
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
Menu {
Picker("", selection: $textName) {
Text("First").tag("First")
Text("Second").tag("Second")
Text("Third").tag("Third")
}
} label: {
HStack(spacing: 2) {
Text(textName)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
.font(.caption, weight: .bold)
.background(Capsule().foregroundColor(.secondarySystemFill))
}
.id(textName)
.transaction {
$0.animation = .none
}
Menu {
Picker("", selection: $secondTextName) {
Text("First").tag("First")
Text("Second").tag("Second")
Text("Third").tag("Third")
}
} label: {
HStack(spacing: 2) {
Text(secondTextName)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
.font(.caption, weight: .bold)
.background(Capsule().foregroundColor(.secondarySystemFill))
}
.id(secondTextName)
.transaction {
$0.animation = .none
}
}
.padding(.horizontal, 18)
}
}
}
struct TestHostingView_Previews: PreviewProvider {
static var previews: some View {
TestHostingView()
}
}

View file

@ -15,31 +15,31 @@ struct DebridLabelView: View {
var body: some View {
if let selectedDebridType = debridManager.selectedDebridType {
Text(selectedDebridType.toString(abbreviated: true))
.fontWeight(.bold)
.padding(2)
.background {
Group {
if let magnet, cloudLinks.isEmpty {
switch debridManager.matchMagnetHash(magnet) {
case .full:
Color.green
case .partial:
Color.orange
case .none:
Color.red
}
} else if cloudLinks.count == 1 {
Color.green
} else if cloudLinks.count > 1 {
Color.orange
} else {
Color.red
}
}
.cornerRadius(4)
.opacity(0.5)
}
Tag(
name: selectedDebridType.toString(abbreviated: true),
color: getTagColor(),
horizontalPadding: 5,
verticalPadding: 3
)
}
}
func getTagColor() -> Color {
if let magnet, cloudLinks.isEmpty {
switch debridManager.matchMagnetHash(magnet) {
case .full:
return Color.green
case .partial:
return Color.orange
case .none:
return Color.red
}
} else if cloudLinks.count == 1 {
return Color.green
} else if cloudLinks.count > 1 {
return Color.orange
} else {
return Color.red
}
}
}

View file

@ -7,15 +7,17 @@
import SwiftUI
struct DebridChoiceView: View {
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())
@ -24,14 +26,7 @@ struct DebridChoiceView: View {
}
}
} label: {
Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid")
label
}
.animation(.none)
}
}
struct DebridChoiceView_Previews: PreviewProvider {
static var previews: some View {
DebridChoiceView()
}
}

View file

@ -21,37 +21,39 @@ struct BookmarksView: View {
@State private var bookmarkPredicate: NSPredicate?
var body: some View {
DynamicFetchRequest(predicate: bookmarkPredicate) { (bookmarks: FetchedResults<Bookmark>) in
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)
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 }
.onMove { source, destination in
var changedBookmarks = bookmarks.map { $0 }
changedBookmarks.move(fromOffsets: source, toOffset: destination)
changedBookmarks.move(fromOffsets: source, toOffset: destination)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
}
PersistenceController.shared.save()
}
PersistenceController.shared.save()
}
}
}
.inlinedList()
.listStyle(.insetGrouped)
.onAppear {
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 15 : -25)
.backport.onAppear {
if debridManager.enabledDebrids.count > 0 {
viewTask = Task {
let magnets = bookmarks.compactMap {
@ -69,7 +71,7 @@ struct BookmarksView: View {
viewTask?.cancel()
}
}
.onAppear {
.backport.onAppear {
applyPredicate()
}
.onChange(of: searchText) { _ in

View file

@ -10,6 +10,7 @@ import SwiftUI
struct AllDebridCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@Binding var searchText: String
@ -37,8 +38,11 @@ struct AllDebridCloudView: View {
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
PersistenceController.shared.createHistory(historyInfo, performSave: true)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
}
} else {
@ -80,7 +84,7 @@ struct AllDebridCloudView: View {
}
}
}
.onAppear {
.backport.onAppear {
viewTask = Task {
await debridManager.fetchAdCloud()
}

View file

@ -11,6 +11,7 @@ import SwiftUIX
struct PremiumizeCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@Binding var searchText: String
@ -34,10 +35,14 @@ struct PremiumizeCloudView: View {
name: item.name,
url: debridManager.downloadUrl,
source: DebridType.premiumize.toString()
)
),
performSave: true
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
}
}
@ -54,7 +59,7 @@ struct PremiumizeCloudView: View {
}
}
}
.onAppear {
.backport.onAppear {
viewTask = Task {
await debridManager.fetchPmCloud()
}

View file

@ -10,6 +10,7 @@ import SwiftUI
struct RealDebridCloudView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
@Binding var searchText: String
@ -31,10 +32,14 @@ struct RealDebridCloudView: View {
name: downloadResponse.filename,
url: downloadResponse.download,
source: DebridType.realDebrid.toString()
)
),
performSave: true
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
.backport.tint(.primary)
}
@ -69,9 +74,12 @@ struct RealDebridCloudView: View {
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink)
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo)
PersistenceController.shared.createHistory(historyInfo, performSave: true)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
}
} else {
@ -116,7 +124,7 @@ struct RealDebridCloudView: View {
}
}
}
.onAppear {
.backport.onAppear {
viewTask = Task {
await debridManager.fetchRdCloud()
}

View file

@ -11,6 +11,7 @@ struct HistoryButtonView: View {
@EnvironmentObject var toastModel: ToastViewModel
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
let entry: HistoryEntry
@ -23,14 +24,20 @@ struct HistoryButtonView: View {
if url.starts(with: "https://") {
Task {
debridManager.downloadUrl = url
navModel.runDebridAction(urlString: url)
pluginManager.runDebridAction(
urlString: url,
currentChoiceSheet: &navModel.currentChoiceSheet
)
if navModel.currentChoiceSheet != .magnet {
if navModel.currentChoiceSheet != .action {
debridManager.downloadUrl = ""
}
}
} else {
navModel.runMagnetAction(magnet: Magnet(hash: nil, link: url))
pluginManager.runMagnetAction(
urlString: url,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
} else {
toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.")
@ -77,4 +84,12 @@ struct HistoryButtonView: View {
.backport.tint(.primary)
.disableInteraction(navModel.currentChoiceSheet != nil)
}
func getTagColor() -> Color {
if let url = entry.url, url.starts(with: "https://") {
return Color.green
} else {
return Color.red
}
}
}

View file

@ -27,7 +27,7 @@ struct HistoryView: View {
}
.listStyle(.insetGrouped)
}
.onAppear {
.backport.onAppear {
applyPredicate()
}
.onChange(of: searchText) { _ in
@ -71,7 +71,7 @@ struct HistorySectionView: View {
var body: some View {
if compareGroup(historyGroup) > 0 {
Section(header: Text(formatter.string(from: historyGroup[0].date ?? Date()))) {
Section(header: InlineHeader(formatter.string(from: historyGroup[0].date ?? Date()))) {
ForEach(historyGroup, id: \.self) { history in
ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in
HistoryButtonView(entry: entry)

View file

@ -0,0 +1,31 @@
//
// LibraryPickerView.swift
// Ferrite
//
// Created by Brian Dashore on 2/13/23.
//
import SwiftUI
struct LibraryPickerView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
var body: some View {
HStack {
Picker("Segments", selection: $navModel.libraryPickerSelection) {
Text("Bookmarks").tag(NavigationViewModel.LibraryPickerSegment.bookmarks)
Text("History").tag(NavigationViewModel.LibraryPickerSegment.history)
if !debridManager.enabledDebrids.isEmpty {
Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud)
}
}
}
.pickerStyle(.segmented)
.padding(.horizontal, verticalSizeClass == .compact && UIDevice.current.hasNotch ? 65 : 18)
.padding(.vertical, 5)
}
}

View file

@ -7,52 +7,60 @@
import SwiftUI
struct InstalledSourceButtonView: View {
struct InstalledPluginButtonView<P: Plugin>: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@ObservedObject var installedSource: Source
@ObservedObject var installedPlugin: P
var body: some View {
Toggle(isOn: Binding<Bool>(
get: { installedSource.enabled },
get: { installedPlugin.enabled },
set: {
installedSource.enabled = $0
installedPlugin.enabled = $0
PersistenceController.shared.save()
}
)) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(installedSource.name)
Text("v\(installedSource.version)")
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(installedPlugin.name)
Text("v\(installedPlugin.version)")
.foregroundColor(.secondary)
}
Text("by \(installedPlugin.author)")
.foregroundColor(.secondary)
}
Text("by \(installedSource.author)")
.foregroundColor(.secondary)
if let tags = installedPlugin.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
}
.padding(.vertical, 2)
}
.contextMenu {
Button {
navModel.selectedSource = installedSource
navModel.showSourceSettings.toggle()
} label: {
Text("Settings")
Image(systemName: "gear")
if let installedSource = installedPlugin as? Source {
Button {
navModel.selectedSource = installedSource
navModel.showSourceSettings.toggle()
} label: {
Text("Settings")
Image(systemName: "gear")
}
}
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")

View file

@ -0,0 +1,51 @@
//
// SourceCatalogButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 8/5/22.
//
import SwiftUI
struct PluginCatalogButtonView<PJ: PluginJson>: View {
@EnvironmentObject var pluginManager: PluginManager
let availablePlugin: PJ
let doUpsert: Bool
var body: some View {
HStack {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(availablePlugin.name)
Text("v\(availablePlugin.version)")
.foregroundColor(.secondary)
}
Text("by \(availablePlugin.author ?? "No author")")
.foregroundColor(.secondary)
}
if let tags = availablePlugin.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
}
Spacer()
Button("Install") {
Task {
if let availableSource = availablePlugin as? SourceJson {
await pluginManager.installSource(sourceJson: availableSource, doUpsert: doUpsert)
} else if let availableAction = availablePlugin as? ActionJson {
await pluginManager.installAction(actionJson: availableAction, doUpsert: doUpsert)
} else {
return
}
}
}
}
.padding(.vertical, 2)
}
}

View file

@ -8,7 +8,7 @@
import SwiftUI
struct SourceCatalogButtonView: View {
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
let availableSource: SourceJson
@ -29,7 +29,7 @@ struct SourceCatalogButtonView: View {
Button("Install") {
Task {
await sourceManager.installSource(sourceJson: availableSource)
await pluginManager.installSource(sourceJson: availableSource)
}
}
}

View file

@ -0,0 +1,78 @@
//
// SourceListView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
struct PluginListView<P: Plugin, PJ: PluginJson>: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@Binding var searchText: String
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var sourcePredicate: NSPredicate?
var body: some View {
DynamicFetchRequest(predicate: sourcePredicate) { (installedPlugins: FetchedResults<P>) in
List {
if
let filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins(
forType: PJ.self,
installedPlugins: installedPlugins,
searchText: searchText
),
!filteredUpdatedPlugins.isEmpty
{
Section(header: InlineHeader("Updates")) {
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
PluginCatalogButtonView(availablePlugin: updatedPlugin, doUpsert: true)
}
}
}
if !installedPlugins.isEmpty {
Section(header: InlineHeader("Installed")) {
ForEach(installedPlugins, id: \.self) { source in
InstalledPluginButtonView(installedPlugin: source)
}
}
}
if
let filteredAvailablePlugins = pluginManager.fetchFilteredPlugins(
forType: PJ.self,
installedPlugins: installedPlugins,
searchText: searchText
),
!filteredAvailablePlugins.isEmpty
{
Section(header: InlineHeader("Catalog")) {
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
PluginCatalogButtonView(availablePlugin: availablePlugin, doUpsert: false)
}
}
}
}
.inlinedList(inset: 0)
.listStyle(.insetGrouped)
.sheet(isPresented: $navModel.showSourceSettings) {
if String(describing: P.self) == "Source" {
SourceSettingsView()
.environmentObject(navModel)
}
}
.onChange(of: searchText) { _ in
sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
}
}
}
}

View file

@ -0,0 +1,27 @@
//
// PluginPickerView.swift
// Ferrite
//
// Created by Brian Dashore on 2/14/23.
//
import SwiftUI
struct PluginPickerView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var navModel: NavigationViewModel
@State private var textName = ""
@State private var secondTextName = ""
var body: some View {
Picker("Segments", selection: $navModel.pluginPickerSelection) {
Text("Sources").tag(NavigationViewModel.PluginPickerSegment.sources)
Text("Actions").tag(NavigationViewModel.PluginPickerSegment.actions)
}
.pickerStyle(.segmented)
.padding(.horizontal, verticalSizeClass == .compact && UIDevice.current.hasNotch ? 65 : 18)
.padding(.vertical, 5)
}
}

View file

@ -0,0 +1,22 @@
//
// PluginTagView.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
import SwiftUI
struct PluginTagsView: View {
let tags: [PluginTagJson]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(tags, id: \.self) { tag in
Tag(name: tag.name, color: tag.colorHex.map { Color(hexadecimal: $0) })
}
}
}
}
}

View file

@ -12,33 +12,46 @@ struct SourceSettingsView: View {
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: PluginList.entity(),
sortDescriptors: []
) var pluginLists: FetchedResults<PluginList>
var body: some View {
NavView {
List {
if let selectedSource = navModel.selectedSource {
Section(header: InlineHeader("Info")) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(selectedSource.name)
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(selectedSource.name)
Text("v\(selectedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(selectedSource.author)")
.foregroundColor(.secondary)
Group {
Text("ID: \(selectedSource.id)")
if let listId = selectedSource.listId {
Text("List ID: \(listId)")
} else {
Text("No list ID found. This source should be removed.")
Text("v\(selectedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(selectedSource.author)")
.foregroundColor(.secondary)
Group {
Text("ID: \(selectedSource.id)")
if let pluginList = pluginLists.first(where: { $0.id == selectedSource.listId })
{
Text("List: \(pluginList.name)")
Text("List ID: \(pluginList.id.uuidString)")
} else {
Text("No plugin list found. This source should be removed.")
}
}
.foregroundColor(.secondary)
.font(.caption)
}
if let tags = selectedSource.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 2)
}
@ -91,7 +104,7 @@ struct SourceSettingsBaseUrlView: View {
}
})
.keyboardType(.URL)
.onAppear {
.backport.onAppear {
tempBaseUrl = selectedSource.baseUrl ?? ""
}
}
@ -121,7 +134,7 @@ struct SourceSettingsApiView: View {
}
})
.autocapitalization(.none)
.onAppear {
.backport.onAppear {
tempClientId = clientId.value ?? ""
}
}
@ -134,7 +147,7 @@ struct SourceSettingsApiView: View {
}
})
.autocapitalization(.none)
.onAppear {
.backport.onAppear {
tempClientSecret = clientSecret.value ?? ""
}
}

View file

@ -0,0 +1,49 @@
//
// SearchFilterHeaderView.swift
// Ferrite
//
// Created by Brian Dashore on 2/13/23.
//
import SwiftUI
struct SearchFilterHeaderView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
Menu {
Picker("", selection: $scrapingModel.filteredSource) {
Text("None").tag(nil as Source?)
ForEach(sources, id: \.self) { source in
if let name = source.name, source.enabled {
Text(name)
.tag(Source?.some(source))
}
}
}
} label: {
FilterLabelView(name: scrapingModel.filteredSource?.name ?? "Source")
}
.id(scrapingModel.filteredSource)
DebridPickerView() {
FilterLabelView(name: debridManager.selectedDebridType?.toString() ?? "Debrid")
}
.id(debridManager.selectedDebridType)
}
.padding(.horizontal, verticalSizeClass == .compact ? (Application.shared.osVersion.majorVersion > 14 ? 65 : 18) : 18)
.padding(.top, Application.shared.osVersion.majorVersion > 14 ? 0 : 10)
}
}
}

View file

@ -12,6 +12,7 @@ struct SearchResultButtonView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
var result: SearchResult
@ -38,12 +39,16 @@ struct SearchResultButtonView: View {
name: result.title,
url: debridManager.downloadUrl,
source: result.source
)
),
performSave: true
)
navModel.runDebridAction(urlString: debridManager.downloadUrl)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
if navModel.currentChoiceSheet != .magnet {
if navModel.currentChoiceSheet != .action {
debridManager.downloadUrl = ""
}
}
@ -63,10 +68,14 @@ struct SearchResultButtonView: View {
name: result.title,
url: result.magnet.link,
source: result.source
)
),
performSave: true
)
navModel.runMagnetAction(magnet: result.magnet)
pluginManager.runMagnetAction(
urlString: result.magnet.link,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
}
} label: {
@ -136,7 +145,7 @@ struct SearchResultButtonView: View {
existingBookmark = nil
}
}
.onAppear {
.backport.onAppear {
// Only run a exists request if a bookmark isn't passed to the view
if existingBookmark == nil, !runOnce {
let bookmarkRequest = Bookmark.fetchRequest()

View file

@ -44,11 +44,11 @@ struct BackupsView: View {
}
}
}
.inlinedList()
.inlinedList(inset: -20)
.listStyle(.insetGrouped)
}
}
.onAppear {
.backport.onAppear {
backupManager.backupUrls = FileManager.default.appDirectory
.appendingPathComponent("Backups", isDirectory: true).contentsByDateAdded
}
@ -57,7 +57,9 @@ struct BackupsView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
backupManager.createBackup()
Task {
await backupManager.createBackup()
}
} label: {
Image(systemName: "plus")
}

View file

@ -0,0 +1,109 @@
//
// DefaultActionsPickerViews.swift
// Ferrite
//
// Created by Brian Dashore on 8/11/22.
//
import SwiftUI
struct DefaultActionPickerView: View {
@EnvironmentObject var toastModel: ToastViewModel
let actionRequirement: ActionRequirement
@Binding var defaultActionName: String?
@Binding var defaultActionList: String?
@FetchRequest(
entity: Action.entity(),
sortDescriptors: []
) var actions: FetchedResults<Action>
@FetchRequest(
entity: PluginList.entity(),
sortDescriptors: []
) var pluginLists: FetchedResults<PluginList>
var body: some View {
List {
UserChoiceButton(
defaultActionName: $defaultActionName,
defaultActionList: $defaultActionList,
pluginLists: pluginLists
)
ForEach(actions.filter { $0.requires.contains(actionRequirement.rawValue) }, id: \.id) { action in
Button {
if let actionListId = action.listId?.uuidString {
defaultActionName = action.name
defaultActionList = actionListId
} else {
toastModel.updateToastDescription(
"Default action error: This action doesn't have a corresponding plugin list! Please uninstall the action"
)
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 5) {
Text(action.name)
Group {
if let pluginList = pluginLists.first(where: { $0.id == action.listId }) {
Text("List: \(pluginList.name)")
Text(pluginList.id.uuidString)
.font(.caption)
} else {
Text("No plugin list found")
.font(.caption)
}
}
.foregroundColor(.secondary)
}
Spacer()
if
let defaultActionList,
action.listId?.uuidString == defaultActionList,
action.name == defaultActionName
{
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.inlinedList(inset: -20)
.navigationTitle("Default \(actionRequirement == .debrid ? "debrid" : "magnet") action")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct UserChoiceButton: View {
@Binding var defaultActionName: String?
@Binding var defaultActionList: String?
var pluginLists: FetchedResults<PluginList>
var body: some View {
Button {
defaultActionName = nil
defaultActionList = nil
} label: {
HStack {
Text("Let me choose")
Spacer()
// Force "Let me choose" if the name OR list ID is nil
// Prevents any mismatches
if defaultActionName == nil || pluginLists.contains(where: { $0.id.uuidString == defaultActionList }) {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
.backport.tint(.primary)
}
}

View file

@ -1,90 +0,0 @@
//
// DefaultActionsPickerViews.swift
// Ferrite
//
// Created by Brian Dashore on 8/11/22.
//
import SwiftUI
struct MagnetActionPickerView: View {
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
var body: some View {
List {
ForEach(DefaultMagnetActionType.allCases, id: \.self) { action in
Button {
defaultMagnetAction = action
} label: {
HStack {
Text(fetchPickerChoiceName(choice: action))
Spacer()
if action == defaultMagnetAction {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.inlinedList()
.navigationTitle("Default magnet action")
.navigationBarTitleDisplayMode(.inline)
}
func fetchPickerChoiceName(choice: DefaultMagnetActionType) -> String {
switch choice {
case .none:
return "Let me choose"
case .webtor:
return "Open in Webtor"
case .shareMagnet:
return "Share magnet link"
}
}
}
struct DebridActionPickerView: View {
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
var body: some View {
List {
ForEach(DefaultDebridActionType.allCases, id: \.self) { action in
Button {
defaultDebridAction = action
} label: {
HStack {
Text(fetchPickerChoiceName(choice: action))
Spacer()
if action == defaultDebridAction {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.inlinedList()
.navigationTitle("Default debrid action")
.navigationBarTitleDisplayMode(.inline)
}
func fetchPickerChoiceName(choice: DefaultDebridActionType) -> String {
switch choice {
case .none:
return "Let me choose"
case .outplayer:
return "Open in Outplayer"
case .vlc:
return "Open in VLC"
case .infuse:
return "Open in Infuse"
case .shareDownload:
return "Share download link"
}
}
}

View file

@ -7,38 +7,41 @@
import SwiftUI
struct SourceListEditorView: View {
struct PluginListEditorView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
let backgroundContext = PersistenceController.shared.backgroundContext
@State private var sourceUrlSet = false
@State var selectedPluginList: PluginList?
@State private var sourceUrl: String = ""
@State private var sourceUrlSet = false
@State private var showUrlErrorAlert = false
@State private var pluginListUrl: String = ""
@State private var urlErrorAlertText: String = ""
var body: some View {
NavView {
Form {
TextField("Enter URL", text: $sourceUrl)
TextField("Enter URL", text: $pluginListUrl)
.disableAutocorrection(true)
.keyboardType(.URL)
.autocapitalization(.none)
.conditionalId(sourceUrlSet)
}
.onAppear {
sourceUrl = navModel.selectedSourceList?.urlString ?? ""
.backport.onAppear {
pluginListUrl = selectedPluginList?.urlString ?? ""
sourceUrlSet = true
}
.backport.alert(
isPresented: $sourceManager.showUrlErrorAlert,
isPresented: $showUrlErrorAlert,
title: "Error",
message: sourceManager.urlErrorAlertText,
buttons: [AlertButton("OK")]
message: urlErrorAlertText
)
.navigationTitle("Editing source list")
.navigationTitle("Editing Plugin List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@ -50,25 +53,23 @@ struct SourceListEditorView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
Task {
if await sourceManager.addSourceList(
sourceUrl: sourceUrl,
existingSourceList: navModel.selectedSourceList
) {
do {
try await pluginManager.addPluginList(pluginListUrl, existingPluginList: selectedPluginList)
presentationMode.wrappedValue.dismiss()
} catch {
urlErrorAlertText = error.localizedDescription
showUrlErrorAlert.toggle()
}
}
}
}
}
.onDisappear {
navModel.selectedSourceList = nil
}
}
}
}
struct SourceListEditorView_Previews: PreviewProvider {
struct PluginListEditorView_Previews: PreviewProvider {
static var previews: some View {
SourceListEditorView()
PluginListEditorView()
}
}

View file

@ -30,7 +30,7 @@ struct SettingsAppVersionView: View {
.listStyle(.insetGrouped)
}
}
.onAppear {
.backport.onAppear {
viewTask = Task {
do {
if let fetchedReleases = try await Github().fetchReleases() {

View file

@ -7,40 +7,41 @@
import SwiftUI
struct SettingsSourceListView: View {
struct SettingsPluginListView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: SourceList.entity(),
entity: PluginList.entity(),
sortDescriptors: []
) var sourceLists: FetchedResults<SourceList>
) var pluginLists: FetchedResults<PluginList>
@State private var presentSourceSheet = false
@State private var selectedSourceList: SourceList?
@State private var selectedPluginList: PluginList?
var body: some View {
ZStack {
if sourceLists.isEmpty {
if pluginLists.isEmpty {
EmptyInstructionView(title: "No Lists", message: "Add a source list using the + button in the top-right")
} else {
List {
ForEach(sourceLists, id: \.self) { sourceList in
ForEach(pluginLists, id: \.self) { pluginList in
VStack(alignment: .leading, spacing: 5) {
Text(sourceList.name)
Text(pluginList.name)
Text(sourceList.author)
.foregroundColor(.gray)
Group {
Text(pluginList.author)
Text("ID: \(sourceList.id)")
.font(.caption)
.foregroundColor(.gray)
Text("ID: \(pluginList.id)")
.font(.caption)
}
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
.contextMenu {
Button {
navModel.selectedSourceList = sourceList
selectedPluginList = pluginList
presentSourceSheet.toggle()
} label: {
Text("Edit")
@ -49,14 +50,14 @@ struct SettingsSourceListView: View {
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
PersistenceController.shared.delete(pluginList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
PersistenceController.shared.delete(pluginList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
@ -66,25 +67,25 @@ struct SettingsSourceListView: View {
}
.onDelete { offsets in
for index in offsets {
if let list = sourceLists[safe: index] {
if let list = pluginLists[safe: index] {
PersistenceController.shared.delete(list, context: backgroundContext)
}
}
}
}
.listStyle(.insetGrouped)
.inlinedList()
.inlinedList(inset: -20)
}
}
.sheet(isPresented: $presentSourceSheet) {
if #available(iOS 16, *) {
SourceListEditorView()
PluginListEditorView(selectedPluginList: selectedPluginList)
.presentationDetents([.medium])
} else {
SourceListEditorView()
PluginListEditorView(selectedPluginList: selectedPluginList)
}
}
.navigationTitle("Source Lists")
.navigationTitle("Plugin Lists")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@ -98,8 +99,8 @@ struct SettingsSourceListView: View {
}
}
struct SettingsSourceListView_Previews: PreviewProvider {
struct SettingsPluginListView_Previews: PreviewProvider {
static var previews: some View {
SettingsSourceListView()
SettingsPluginListView()
}
}

View file

@ -1,38 +0,0 @@
//
// SourceUpdateButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 8/5/22.
//
import SwiftUI
struct SourceUpdateButtonView: View {
@EnvironmentObject var sourceManager: SourceManager
let updatedSource: SourceJson
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(updatedSource.name)
Text("v\(updatedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(updatedSource.author ?? "Unknown")")
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
Spacer()
Button("Update") {
Task {
await sourceManager.installSource(sourceJson: updatedSource, doUpsert: true)
}
}
}
}
}

View file

@ -12,120 +12,129 @@ struct ContentView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var toastModel: ToastViewModel
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText: Bool = false
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var searchText: String = ""
@State private var selectedSource: Source? {
didSet {
scrapingModel.filteredSource = selectedSource
}
}
@State private var lastSearchTextIndex: Int = -1
@State private var searchBarText: String = "Search"
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"
]
var body: some View {
NavView {
VStack(spacing: 10) {
HStack(spacing: 6) {
Text("Filter")
.foregroundColor(.secondary)
Menu {
Button {
selectedSource = nil
} label: {
Text("None")
if selectedSource == nil {
Image(systemName: "checkmark")
}
}
ForEach(sources, id: \.self) { source in
if let name = source.name, source.enabled {
Button {
selectedSource = source
} label: {
if selectedSource == source {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
}
} label: {
Text(selectedSource?.name ?? "Source")
.padding(.trailing, -3)
Image(systemName: "chevron.down")
List {
ForEach(scrapingModel.searchResults, id: \.self) { result in
if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil {
SearchResultButtonView(result: result)
}
.foregroundColor(.primary)
.animation(.none)
Spacer()
}
.padding(.vertical, 5)
.padding(.horizontal, 20)
SearchResultsView()
}
.listStyle(.insetGrouped)
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 20 : -20)
.overlay {
if scrapingModel.searchResults.isEmpty && isSearching && scrapingModel.runningSearchTask == nil {
Text("No results found")
}
}
.onChange(of: searchText) { newText in
if newText.isEmpty && isSearching {
searchBarText = getSearchBarText()
}
}
.onChange(of: scrapingModel.searchResults) { _ in
// Cleans up any leftover search results in the event of an abrupt cancellation
if !isSearching {
scrapingModel.searchResults = []
}
}
.onChange(of: navModel.selectedTab) { tab in
// Cancel the search if tab is switched while search is in progress
if tab != .search, scrapingModel.runningSearchTask != nil {
scrapingModel.searchResults = []
scrapingModel.runningSearchTask?.cancel()
scrapingModel.runningSearchTask = nil
isSearching = false
searchText = ""
}
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(
navModel.isSearching && Application.shared.osVersion.majorVersion > 14 ? .inline : .large
)
.navigationSearchBar {
SearchBar("Search",
text: $scrapingModel.searchText,
isEditing: $navModel.isEditingSearch,
onCommit: {
scrapingModel.searchResults = []
scrapingModel.runningSearchTask = Task {
navModel.isSearching = true
navModel.showSearchProgress = true
SearchBar(
searchBarText,
text: $searchText,
isEditing: $isEditingSearch,
onCommit: {
if let runningSearchTask = scrapingModel.runningSearchTask, runningSearchTask.isCancelled {
scrapingModel.runningSearchTask = nil
return
}
let sources = sourceManager.fetchInstalledSources()
await scrapingModel.scanSources(sources: sources)
scrapingModel.runningSearchTask = Task {
isSearching = true
if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty {
debridManager.clearIAValues()
let sources = pluginManager.fetchInstalledSources()
await scrapingModel.scanSources(sources: sources, searchText: searchText)
// Remove magnets that don't have a hash
let magnets = scrapingModel.searchResults.compactMap {
if let magnetHash = $0.magnet.hash {
return Magnet(hash: magnetHash, link: $0.magnet.link)
} else {
return nil
}
}
await debridManager.populateDebridIA(magnets)
}
if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty {
debridManager.clearIAValues()
navModel.showSearchProgress = false
}
})
.showsCancelButton(navModel.isEditingSearch || navModel.isSearching)
.onCancel {
scrapingModel.searchResults = []
scrapingModel.runningSearchTask?.cancel()
scrapingModel.runningSearchTask = nil
navModel.isSearching = false
scrapingModel.searchText = ""
}
}
.introspectSearchController { searchController in
searchController.hidesNavigationBarDuringPresentation = false
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
DebridChoiceView()
let magnets = scrapingModel.searchResults.map(\.magnet)
await debridManager.populateDebridIA(magnets)
}
toastModel.hideIndeterminateToast()
scrapingModel.runningSearchTask = nil
}
}
)
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
scrapingModel.searchResults = []
scrapingModel.runningSearchTask?.cancel()
scrapingModel.runningSearchTask = nil
isSearching = false
searchText = ""
searchBarText = getSearchBarText()
}
}
.navigationSearchBarHiddenWhenScrolling(false)
.customScopeBar {
SearchFilterHeaderView()
.environmentObject(scrapingModel)
.environmentObject(debridManager)
}
}
.backport.onAppear {
searchBarText = getSearchBarText()
}
}
// Fetches random searchbar text if enabled, otherwise deinit the last case value
func getSearchBarText() -> String {
if usesRandomSearchText {
let num = Int.random(in: 0..<searchBarTextArray.count - 1)
if num == lastSearchTextIndex {
lastSearchTextIndex = num + 1
return searchBarTextArray[safe: num + 1] ?? "Search"
} else {
lastSearchTextIndex = num
return searchBarTextArray[safe: num] ?? "Search"
}
} else {
lastSearchTextIndex = -1
return "Search"
}
}
}

View file

@ -9,14 +9,8 @@ import SwiftUI
import SwiftUIX
struct LibraryView: View {
enum LibraryPickerSegment {
case bookmarks
case history
case debridCloud
}
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Bookmark.entity(),
@ -32,7 +26,6 @@ struct LibraryView: View {
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var selectedSegment: LibraryPickerSegment = .bookmarks
@State private var editMode: EditMode = .inactive
@State private var searchText: String = ""
@ -41,20 +34,8 @@ struct LibraryView: View {
var body: some View {
NavView {
VStack {
Picker("Segments", selection: $selectedSegment) {
Text("Bookmarks").tag(LibraryPickerSegment.bookmarks)
Text("History").tag(LibraryPickerSegment.history)
if !debridManager.enabledDebrids.isEmpty {
Text("Cloud").tag(LibraryPickerSegment.debridCloud)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 5)
switch selectedSegment {
ZStack {
switch navModel.libraryPickerSelection {
case .bookmarks:
BookmarksView(searchText: $searchText)
case .history:
@ -62,25 +43,9 @@ struct LibraryView: View {
case .debridCloud:
DebridCloudView(searchText: $searchText)
}
Spacer()
}
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
.overlay {
switch selectedSegment {
switch navModel.libraryPickerSelection {
case .bookmarks:
if bookmarks.isEmpty {
EmptyInstructionView(title: "No Bookmarks", message: "Add a bookmark from search results")
@ -102,19 +67,39 @@ struct LibraryView: View {
Spacer()
EditButton()
switch selectedSegment {
switch navModel.libraryPickerSelection {
case .bookmarks, .debridCloud:
DebridChoiceView()
DebridPickerView() {
Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid")
}
.transaction {
$0.animation = .none
}
case .history:
HistoryActionsView()
}
}
.animation(.none)
}
}
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.navigationSearchBarHiddenWhenScrolling(false)
.customScopeBar {
LibraryPickerView()
.environmentObject(debridManager)
.environmentObject(navModel)
}
.environment(\.editMode, $editMode)
}
.onChange(of: selectedSegment) { _ in
.onChange(of: navModel.libraryPickerSelection) { _ in
editMode = .inactive
}
.onDisappear {

View file

@ -14,6 +14,7 @@ struct MainView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var backupManager: BackupManager
@EnvironmentObject var pluginManager: PluginManager
@AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true
@ -28,33 +29,35 @@ struct MainView: View {
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(ViewTab.search)
.tag(NavigationViewModel.ViewTab.search)
LibraryView()
.tabItem {
Label("Library", systemImage: "book.closed")
}
.tag(ViewTab.library)
.tag(NavigationViewModel.ViewTab.library)
SourcesView()
PluginsView()
.tabItem {
Label("Sources", systemImage: "doc.text")
Label("Plugins", systemImage: "doc.text")
}
.tag(ViewTab.sources)
.tag(NavigationViewModel.ViewTab.plugins)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(ViewTab.settings)
.tag(NavigationViewModel.ViewTab.settings)
}
.sheet(item: $navModel.currentChoiceSheet) { item in
switch item {
case .magnet:
MagnetChoiceView()
case .action:
ActionChoiceView()
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
.environmentObject(pluginManager)
.environment(\.managedObjectContext, PersistenceController.shared.container.viewContext)
case .batch:
BatchChoiceView()
.environmentObject(debridManager)
@ -69,7 +72,7 @@ struct MainView: View {
}
}
}
.onAppear {
.backport.onAppear {
if autoUpdateNotifs {
viewTask = Task {
do {
@ -101,42 +104,53 @@ struct MainView: View {
backupManager.showRestoreAlert.toggle()
}
}
// Global alerts for backups
.backport.alert(
// Global alerts and dialogs for backups
.backport.confirmationDialog(
isPresented: $backupManager.showRestoreAlert,
title: "Restore backup?",
message: "Restoring this backup will merge all your data!",
message:
"Merge (preferred): Will merge your current data with the backup \n\n" +
"Overwrite: Will delete and replace all your data \n\n" +
"If Merge causes app instability, uninstall Ferrite and use the Overwrite option.",
buttons: [
.init("Restore", role: .destructive) {
backupManager.restoreBackup()
.init("Merge", role: .destructive) {
Task {
await backupManager.restoreBackup(pluginManager: pluginManager, doOverwrite: false)
}
},
.init(role: .cancel)
.init("Overwrite", role: .destructive) {
Task {
await backupManager.restoreBackup(pluginManager: pluginManager, doOverwrite: true)
}
}
]
)
.backport.alert(
isPresented: $backupManager.showRestoreCompletedAlert,
title: "Backup restored",
message: backupManager.backupSourceNames.isEmpty ?
"No sources need to be reinstalled" :
"Reinstall sources: \(backupManager.backupSourceNames.joined(separator: ", "))",
message: backupManager.restoreCompletedMessage.joined(separator: " \n\n"),
buttons: [
.init("OK") {}
.init("OK") {
backupManager.restoreCompletedMessage = []
}
]
)
// Updater alert
.backport.alert(
isPresented: $showUpdateAlert,
title: "Update available",
message: "Ferrite \(releaseVersionString) can be downloaded. \n\n This alert can be disabled in Settings.",
message:
"Ferrite \(releaseVersionString) can be downloaded. \n\n" +
"This alert can be disabled in Settings.",
buttons: [
AlertButton("Download") {
.init("Download") {
guard let releaseUrl = URL(string: releaseUrlString) else {
return
}
UIApplication.shared.open(releaseUrl)
},
AlertButton(role: .cancel)
.init(role: .cancel)
]
)
.overlay {
@ -159,17 +173,18 @@ struct MainView: View {
.cornerRadius(10)
}
if debridManager.showLoadingProgress {
if toastModel.showIndeterminateToast {
VStack {
Text("Loading content")
Text(toastModel.indeterminateToastDescription ?? "Loading...")
HStack {
IndeterminateProgressView()
Button("Cancel") {
debridManager.currentDebridTask?.cancel()
debridManager.currentDebridTask = nil
debridManager.showLoadingProgress = false
if let cancelAction = toastModel.indeterminateCancelAction {
Button("Cancel") {
cancelAction()
toastModel.hideIndeterminateToast()
}
}
}
}
@ -186,7 +201,7 @@ struct MainView: View {
.foregroundColor(.clear)
.frame(height: 60)
}
.animation(.easeInOut(duration: 0.3), value: toastModel.showToast || debridManager.showLoadingProgress)
.animation(.easeInOut(duration: 0.3), value: toastModel.showToast || toastModel.showIndeterminateToast)
}
}
}

View file

@ -0,0 +1,97 @@
//
// PluginsView.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import SwiftUI
import SwiftUIX
struct PluginsView: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
@FetchRequest(
entity: Action.entity(),
sortDescriptors: []
) var actions: FetchedResults<Action>
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var checkedForPlugins = false
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var searchText: String = ""
@State private var viewTask: Task<Void, Never>?
var body: some View {
NavView {
ZStack {
if checkedForPlugins {
switch navModel.pluginPickerSelection {
case .sources:
PluginListView<Source, SourceJson>(searchText: $searchText)
case .actions:
PluginListView<Action, ActionJson>(searchText: $searchText)
}
}
}
.overlay {
if checkedForPlugins {
switch navModel.pluginPickerSelection {
case .sources:
if sources.isEmpty && pluginManager.availableSources.isEmpty {
EmptyInstructionView(title: "No Sources", message: "Add a plugin list in Settings")
}
case .actions:
if actions.isEmpty && pluginManager.availableActions.isEmpty {
EmptyInstructionView(title: "No Actions", message: "Add a plugin list in Settings")
}
}
} else {
ProgressView()
}
}
.backport.onAppear {
viewTask = Task {
await pluginManager.fetchPluginsFromUrl()
checkedForPlugins = true
}
}
.onDisappear {
viewTask?.cancel()
checkedForPlugins = false
}
.navigationTitle("Plugins")
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.navigationSearchBarHiddenWhenScrolling(false)
.customScopeBar {
PluginPickerView()
.environmentObject(navModel)
}
}
}
}
struct PluginsView_Previews: PreviewProvider {
static var previews: some View {
PluginsView()
}
}

View file

@ -0,0 +1,43 @@
//
// ViewDidAppearHandler.swift
// Ferrite
//
// Created by Brian Dashore on 2/8/23.
//
// UIKit onAppear hook to fix onAppear behavior in iOS 14
//
import SwiftUI
struct ViewDidAppearHandler: UIViewControllerRepresentable {
let callback: () -> Void
class Coordinator: UIViewController {
let callback: () -> Void
init(callback: @escaping () -> Void) {
self.callback = callback
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
callback()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(callback: callback)
}
func makeUIViewController(context: Context) -> UIViewController {
context.coordinator
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

View file

@ -1,60 +0,0 @@
//
// SearchResultsView.swift
// Ferrite
//
// Created by Brian Dashore on 7/11/22.
//
import SwiftUI
struct SearchResultsView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
var body: some View {
List {
ForEach(scrapingModel.searchResults, id: \.self) { result in
if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil {
SearchResultButtonView(result: result)
}
}
}
.listStyle(.insetGrouped)
.inlinedList()
.overlay {
if scrapingModel.searchResults.isEmpty {
if navModel.showSearchProgress {
VStack(spacing: 5) {
ProgressView()
Text("Loading \(scrapingModel.currentSourceName ?? "")")
}
} else if navModel.isSearching, scrapingModel.runningSearchTask != nil {
Text("No results found")
}
}
}
.onChange(of: navModel.selectedTab) { tab in
// Cancel the search if tab is switched while search is in progress
if tab != .search, navModel.showSearchProgress {
scrapingModel.searchResults = []
scrapingModel.runningSearchTask?.cancel()
scrapingModel.runningSearchTask = nil
navModel.isSearching = false
scrapingModel.searchText = ""
}
}
.onChange(of: scrapingModel.searchResults) { _ in
// Cleans up any leftover search results in the event of an abrupt cancellation
if !navModel.isSearching {
scrapingModel.searchResults = []
}
}
}
}
struct SearchResultsView_Previews: PreviewProvider {
static var previews: some View {
SearchResultsView()
}
}

View file

@ -11,16 +11,20 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var pluginManager: PluginManager
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false
@AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
@AppStorage("Actions.DefaultDebridName") var defaultDebridActionName: String?
@AppStorage("Actions.DefaultDebridList") var defaultDebridActionList: String?
@AppStorage("Actions.DefaultMagnetName") var defaultMagnetActionName: String?
@AppStorage("Actions.DefaultMagnetList") var defaultMagnetActionList: String?
var body: some View {
NavView {
@ -78,80 +82,74 @@ struct SettingsView: View {
}
}
Section(header: Text("Behavior")) {
Section(header: InlineHeader("Behavior")) {
Toggle(isOn: $autocorrectSearch) {
Text("Autocorrect search")
}
Toggle(isOn: $usesRandomSearchText) {
Text("Random searchbar text")
}
}
Section(header: Text("Source management")) {
NavigationLink("Source lists", destination: SettingsSourceListView())
Section(header: InlineHeader("Plugin management")) {
NavigationLink("Plugin lists", destination: SettingsPluginListView())
}
Section(header: Text("Default actions")) {
if debridManager.enabledDebrids.count > 0 {
Section(header: InlineHeader("Default actions")) {
//if debridManager.enabledDebrids.count > 0 {
NavigationLink(
destination: DebridActionPickerView(),
destination: DefaultActionPickerView(
actionRequirement: .debrid,
defaultActionName: $defaultDebridActionName,
defaultActionList: $defaultDebridActionList
),
label: {
HStack {
Text("Default debrid action")
Text("Debrid action")
Spacer()
Group {
switch defaultDebridAction {
case .none:
Text("User choice")
case .outplayer:
Text("Outplayer")
case .vlc:
Text("VLC")
case .infuse:
Text("Infuse")
case .shareDownload:
Text("Share")
}
}
.foregroundColor(.gray)
// TODO: Maybe make this check for nil list as well?
Text(defaultDebridActionName.map { $0 } ?? "User choice")
.foregroundColor(.secondary)
}
}
)
}
//}
NavigationLink(
destination: MagnetActionPickerView(),
destination: DefaultActionPickerView(
actionRequirement: .magnet,
defaultActionName: $defaultMagnetActionName,
defaultActionList: $defaultMagnetActionList
),
label: {
HStack {
Text("Default magnet action")
Text("Magnet action")
Spacer()
Group {
switch defaultMagnetAction {
case .none:
Text("User choice")
case .webtor:
Text("Webtor")
case .shareMagnet:
Text("Share")
}
}
.foregroundColor(.gray)
// TODO: Maybe make this check for nil list as well?
Text(defaultMagnetActionName.map { $0 } ?? "User choice")
.foregroundColor(.secondary)
}
}
)
}
Section(header: Text("Backups")) {
Section(header: InlineHeader("Backups")) {
NavigationLink(destination: BackupsView()) {
Text("Backups")
}
}
Section(header: Text("Updates")) {
Section(header: InlineHeader("Updates")) {
Toggle(isOn: $autoUpdateNotifs) {
Text("Show update alerts")
}
NavigationLink("Version history", destination: SettingsAppVersionView())
}
Section(header: Text("Information")) {
Section(header: InlineHeader("Information")) {
ListRowLinkView(text: "Donate", link: "https://ko-fi.com/kingbri")
ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues")
NavigationLink("About", destination: AboutView())

View file

@ -8,14 +8,18 @@
import SwiftUI
import SwiftUIX
struct MagnetChoiceView: View {
struct ActionChoiceView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@FetchRequest(
entity: Action.entity(),
sortDescriptors: []
) var actions: FetchedResults<Action>
@State private var showLinkCopyAlert = false
@State private var showMagnetCopyAlert = false
@ -39,16 +43,12 @@ struct MagnetChoiceView: View {
if !debridManager.downloadUrl.isEmpty {
Section(header: "Debrid options") {
ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.downloadUrl, .outplayer)
}
ListRowButtonView("Play on VLC", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.downloadUrl, .vlc)
}
ListRowButtonView("Play on Infuse", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(urlString: debridManager.downloadUrl, .infuse)
ForEach(actions, id: \.id) { action in
if action.requires.contains(ActionRequirement.debrid.rawValue) {
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
pluginManager.runDeeplinkAction(action, urlString: debridManager.downloadUrl)
}
}
}
ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") {
@ -73,6 +73,14 @@ struct MagnetChoiceView: View {
if !navModel.resultFromCloud {
Section(header: "Magnet options") {
ForEach(actions, id: \.id) { action in
if action.requires.contains(ActionRequirement.magnet.rawValue) {
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
pluginManager.runDeeplinkAction(action, urlString: navModel.selectedMagnet?.link)
}
}
}
ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = navModel.selectedMagnet?.link
showMagnetCopyAlert.toggle()
@ -92,10 +100,6 @@ struct MagnetChoiceView: View {
navModel.showLocalActivitySheet.toggle()
}
}
ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") {
navModel.runMagnetAction(magnet: navModel.selectedMagnet, .webtor)
}
}
}
}
@ -108,6 +112,13 @@ struct MagnetChoiceView: View {
AppActivityView(activityItems: navModel.activityItems)
}
}
.backport.alert(
isPresented: $pluginManager.showBrokenDefaultActionAlert,
title: "Action not found",
message:
"The default action could not be run. The action choice sheet has been opened. \n\n" +
"Please check your default actions in Settings"
)
.onDisappear {
debridManager.downloadUrl = ""
navModel.selectedTitle = ""
@ -131,8 +142,8 @@ struct MagnetChoiceView: View {
}
}
struct MagnetChoiceView_Previews: PreviewProvider {
struct ActionChoiceView_Previews: PreviewProvider {
static var previews: some View {
MagnetChoiceView()
ActionChoiceView()
}
}

View file

@ -11,9 +11,11 @@ struct BatchChoiceView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
let backgroundContext = PersistenceController.shared.backgroundContext
// TODO: Make this generic for IA(?) and add searchbar
var body: some View {
NavView {
List {
@ -48,7 +50,7 @@ struct BatchChoiceView: View {
}
.backport.tint(.primary)
.listStyle(.insetGrouped)
.inlinedList()
.inlinedList(inset: -20)
.navigationTitle("Select a file")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -79,10 +81,13 @@ struct BatchChoiceView: View {
if var selectedHistoryInfo = navModel.selectedHistoryInfo {
selectedHistoryInfo.url = debridManager.downloadUrl
selectedHistoryInfo.subName = fileName
PersistenceController.shared.createHistory(selectedHistoryInfo)
PersistenceController.shared.createHistory(selectedHistoryInfo, performSave: true)
}
navModel.runDebridAction(urlString: debridManager.downloadUrl)
pluginManager.runDebridAction(
urlString: debridManager.downloadUrl,
currentChoiceSheet: &navModel.currentChoiceSheet
)
}
debridManager.clearSelectedDebridItems()

View file

@ -1,142 +0,0 @@
//
// SourceListView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import Introspect
import SwiftUI
import SwiftUIX
struct SourcesView: View {
@EnvironmentObject var sourceManager: SourceManager
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@State private var checkedForSources = false
@State private var isEditingSearch = false
@State private var isSearching = false
@State private var viewTask: Task<Void, Never>? = nil
@State private var searchText: String = ""
@State private var filteredUpdatedSources: [SourceJson] = []
@State private var filteredAvailableSources: [SourceJson] = []
@State private var sourcePredicate: NSPredicate?
var body: some View {
NavView {
DynamicFetchRequest(predicate: sourcePredicate) { (installedSources: FetchedResults<Source>) in
ZStack {
if !checkedForSources {
ProgressView()
} else if installedSources.isEmpty, sourceManager.availableSources.isEmpty {
EmptyInstructionView(title: "No Sources", message: "Add a source list in Settings")
} else {
List {
if !filteredUpdatedSources.isEmpty {
Section(header: InlineHeader("Updates")) {
ForEach(filteredUpdatedSources, id: \.self) { source in
SourceUpdateButtonView(updatedSource: source)
}
}
}
if !installedSources.isEmpty {
Section(header: InlineHeader("Installed")) {
ForEach(installedSources, id: \.self) { source in
InstalledSourceButtonView(installedSource: source)
}
}
}
if !filteredAvailableSources.isEmpty {
Section(header: InlineHeader("Catalog")) {
ForEach(filteredAvailableSources, id: \.self) { availableSource in
if !installedSources.contains(where: {
availableSource.name == $0.name &&
availableSource.listId == $0.listId &&
availableSource.author == $0.author
}) {
SourceCatalogButtonView(availableSource: availableSource)
}
}
}
}
}
.conditionalId(UUID())
.listStyle(.insetGrouped)
}
}
.sheet(isPresented: $navModel.showSourceSettings) {
SourceSettingsView()
.environmentObject(navModel)
}
.onAppear {
viewTask = Task {
await sourceManager.fetchSourcesFromUrl()
filteredAvailableSources = sourceManager.availableSources.filter { availableSource in
!installedSources.contains(where: {
availableSource.name == $0.name &&
availableSource.listId == $0.listId &&
availableSource.author == $0.author
})
}
filteredUpdatedSources = sourceManager.fetchUpdatedSources(installedSources: installedSources)
checkedForSources = true
}
}
.onDisappear {
viewTask?.cancel()
}
.onChange(of: searchText) { _ in
sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
}
.onReceive(installedSources.publisher.count()) { _ in
filteredAvailableSources = sourceManager.availableSources.filter { availableSource in
let sourceExists = installedSources.contains(where: {
availableSource.name == $0.name &&
availableSource.listId == $0.listId &&
availableSource.author == $0.author
})
if searchText.isEmpty {
return !sourceExists
} else {
return !sourceExists && availableSource.name.lowercased().contains(searchText.lowercased())
}
}
filteredUpdatedSources = sourceManager.fetchUpdatedSources(installedSources: installedSources).filter {
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
}
}
.navigationTitle("Sources")
.navigationSearchBar {
SearchBar("Search", text: $searchText, isEditing: $isEditingSearch, onCommit: {
isSearching = true
})
.showsCancelButton(isEditingSearch || isSearching)
.onCancel {
searchText = ""
isSearching = false
}
}
.introspectSearchController { searchController in
searchController.searchBar.autocorrectionType = autocorrectSearch ? .default : .no
searchController.searchBar.autocapitalizationType = autocorrectSearch ? .sentences : .none
}
}
}
}
}
struct SourcesView_Previews: PreviewProvider {
static var previews: some View {
SourcesView()
}
}