v0.5 #11

Merged
kingbri1 merged 22 commits from next into default 2022-11-19 17:51:06 +00:00
67 changed files with 2565 additions and 525 deletions

View file

@ -7,23 +7,34 @@
objects = {
/* Begin PBXBuildFile section */
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0167DB29293FA900B65783 /* RealDebridModels.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* SourcesView.swift */; };
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */; };
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C12D43C28CC332A000195BF /* HistoryButtonView.swift */; };
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 */; };
0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB542890D1BF002BD219 /* UIApplication.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 */; };
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; };
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; };
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; };
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; };
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 */; };
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; };
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */; };
0C60B1EF28A1A00000E3FD7E /* SearchProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; };
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
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 */; };
@ -31,13 +42,15 @@
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 /* InstalledSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */; };
0C794B6B289DACF100DD1CC8 /* SourceCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */; };
0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */; };
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.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 */; };
0C7D11FC28AA01E900ED92DB /* DynamicAccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */; };
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; };
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; };
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; };
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14228D65518009E29AD /* FileManager.swift */; };
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F4752895BE680074B7C9 /* FerriteDB.xcdatamodeld */; };
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47A2895BFED0074B7C9 /* Source+CoreDataClass.swift */; };
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; };
@ -56,7 +69,6 @@
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 */; };
0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C4288903F000DE2211 /* RealDebridModels.swift */; };
0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; };
0CA148E0288903F000DE2211 /* FerriteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C7288903F000DE2211 /* FerriteApp.swift */; };
0CA148E1288903F000DE2211 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C9288903F000DE2211 /* Collection.swift */; };
@ -69,41 +81,68 @@
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 */; };
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23828C2660D00616D3A /* BookmarksView.swift */; };
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */; };
0CA3B23D28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */; };
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
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 */; };
0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */; };
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; };
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0C0167DB29293FA900B65783 /* RealDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = "<group>"; };
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAppVersionView.swift; sourceTree = "<group>"; };
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryButtonView.swift; sourceTree = "<group>"; };
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>"; };
0C32FB542890D1BF002BD219 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.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>"; };
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>"; };
0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
0C44E2AC28D51C63007711AE /* BackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManager.swift; sourceTree = "<group>"; };
0C44E2AE28D52E8A007711AE /* BackupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsView.swift; sourceTree = "<group>"; };
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>"; };
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = "<group>"; };
0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchProgressView.swift; sourceTree = "<group>"; };
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = "<group>"; };
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = "<group>"; };
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = "<group>"; };
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
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 /* InstalledSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledSourceView.swift; sourceTree = "<group>"; };
0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceCatalogView.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>"; };
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>"; };
0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicAccentColor.swift; sourceTree = "<group>"; };
0C7C128528DAA3CD00381CD1 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = "<group>"; };
0C7D11FD28AA03FE00ED92DB /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
0C7ED14028D61BBA009E29AD /* BackupModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupModels.swift; sourceTree = "<group>"; };
0C7ED14228D65518009E29AD /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = "<group>"; };
0C84F4762895BE680074B7C9 /* FerriteDB.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FerriteDB.xcdatamodel; sourceTree = "<group>"; };
0C84F47A2895BFED0074B7C9 /* Source+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataClass.swift"; sourceTree = "<group>"; };
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; };
@ -122,7 +161,6 @@
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>"; };
0CA148C4288903F000DE2211 /* RealDebridModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 3; lastKnownFileType = sourcecode.swift; path = RealDebridModels.swift; sourceTree = "<group>"; };
0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
0CA148C7288903F000DE2211 /* FerriteApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FerriteApp.swift; sourceTree = "<group>"; };
0CA148C9288903F000DE2211 /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
@ -135,13 +173,26 @@
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>"; };
0CA3B23828C2660D00616D3A /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataClass.swift"; sourceTree = "<group>"; };
0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = "<group>"; };
0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = "<group>"; };
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = "<group>"; };
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = "<group>"; };
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupBoxStyle.swift; sourceTree = "<group>"; };
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; };
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; };
0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -153,6 +204,7 @@
0C64A4B4288903680079976D /* Base32 in Frameworks */,
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */,
0CB6516828C5A5EC00DCA721 /* Introspect in Frameworks */,
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
);
@ -164,6 +216,10 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */,
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */,
0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */,
0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */,
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */,
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */,
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */,
@ -185,21 +241,60 @@
0C0D50E3288DFE6E0035ECC8 /* Models */ = {
isa = PBXGroup;
children = (
0CA148C4288903F000DE2211 /* RealDebridModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */,
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
0C44E2A628D4DDC6007711AE /* Classes */ = {
isa = PBXGroup;
children = (
0C44E2A728D4DDDC007711AE /* Application.swift */,
);
path = Classes;
sourceTree = "<group>";
};
0C44E2A928D4DFC4007711AE /* Modifiers */ = {
isa = PBXGroup;
children = (
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */,
0CB6516228C5A57300DCA721 /* ConditionalId.swift */,
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
);
path = Modifiers;
sourceTree = "<group>";
};
0C44E2AA28D4E09B007711AE /* Buttons */ = {
isa = PBXGroup;
children = (
0C794B68289DACC800DD1CC8 /* InstalledSourceButtonView.swift */,
0C794B6A289DACF100DD1CC8 /* SourceCatalogButtonView.swift */,
0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */,
);
path = Buttons;
sourceTree = "<group>";
};
0C44E2AB28D4E126007711AE /* SearchResultViews */ = {
isa = PBXGroup;
children = (
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */,
);
path = SearchResultViews;
sourceTree = "<group>";
};
0C794B65289DAC9F00DD1CC8 /* SourceViews */ = {
isa = PBXGroup;
children = (
0C44E2AA28D4E09B007711AE /* Buttons */,
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
0C794B66289DACB600DD1CC8 /* SourceUpdateButtonView.swift */,
0C794B68289DACC800DD1CC8 /* InstalledSourceView.swift */,
0C794B6A289DACF100DD1CC8 /* SourceCatalogView.swift */,
);
path = SourceViews;
sourceTree = "<group>";
@ -207,6 +302,7 @@
0CA0545C288F7CB200850554 /* SettingsViews */ = {
isa = PBXGroup;
children = (
0C44E2AE28D52E8A007711AE /* BackupsView.swift */,
0CA05456288EE58200850554 /* SettingsSourceListView.swift */,
0CA0545A288EEA4E00850554 /* SourceListEditorView.swift */,
0C95D8D728A55B03005E22B3 /* DefaultActionsPickerViews.swift */,
@ -224,6 +320,7 @@
0C0D50E3288DFE6E0035ECC8 /* Models */,
0CA148EF2889061600DE2211 /* ViewModels */,
0CA148EE2889061200DE2211 /* Views */,
0C44E2A628D4DDC6007711AE /* Classes */,
0CA148C8288903F000DE2211 /* Extensions */,
0CA148C5288903F000DE2211 /* Preview Content */,
0CA148C7288903F000DE2211 /* FerriteApp.swift */,
@ -236,11 +333,15 @@
0CA148C0288903F000DE2211 /* CommonViews */ = {
isa = PBXGroup;
children = (
0C44E2A928D4DFC4007711AE /* Modifiers */,
0CE66B3928E640D200F69346 /* Backport.swift */,
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */,
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */,
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
0CA148C1288903F000DE2211 /* NavView.swift */,
0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */,
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
);
path = CommonViews;
sourceTree = "<group>";
@ -258,10 +359,13 @@
children = (
0CA148C9288903F000DE2211 /* Collection.swift */,
0CA148CA288903F000DE2211 /* Data.swift */,
0CA148CB288903F000DE2211 /* Task.swift */,
0C32FB542890D1BF002BD219 /* UIApplication.swift */,
0C7D11FD28AA03FE00ED92DB /* View.swift */,
0CA429F728C5098D000D0610 /* DateFormatter.swift */,
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */,
0C78041C28BFB3EA001E8CA3 /* String.swift */,
0CA148CB288903F000DE2211 /* Task.swift */,
0C7D11FD28AA03FE00ED92DB /* View.swift */,
0C7ED14228D65518009E29AD /* FileManager.swift */,
0C7C128528DAA3CD00381CD1 /* URL.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -269,21 +373,22 @@
0CA148EE2889061200DE2211 /* Views */ = {
isa = PBXGroup;
children = (
0CA3B23528C265FD00616D3A /* LibraryViews */,
0C794B65289DAC9F00DD1CC8 /* SourceViews */,
0CA148F02889062700DE2211 /* RepresentableViews */,
0CA148C0288903F000DE2211 /* CommonViews */,
0C44E2AB28D4E126007711AE /* SearchResultViews */,
0CA0545C288F7CB200850554 /* SettingsViews */,
0CA148D3288903F000DE2211 /* SearchResultsView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */,
0CA148D4288903F000DE2211 /* ContentView.swift */,
0CA148D1288903F000DE2211 /* MainView.swift */,
0CA148D4288903F000DE2211 /* ContentView.swift */,
0CA148D3288903F000DE2211 /* SearchResultsView.swift */,
0CA3B23328C2658700616D3A /* LibraryView.swift */,
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */,
0CA148BB288903F000DE2211 /* SettingsView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */,
0CA148BC288903F000DE2211 /* LoginWebView.swift */,
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */,
0CA148BD288903F000DE2211 /* MagnetChoiceView.swift */,
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */,
0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -296,6 +401,7 @@
0CA148CF288903F000DE2211 /* ToastViewModel.swift */,
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */,
0CA05458288EE9E600850554 /* SourceManager.swift */,
0C44E2AC28D51C63007711AE /* BackupManager.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -311,12 +417,23 @@
0CA148F12889066000DE2211 /* API */ = {
isa = PBXGroup;
children = (
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */,
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
);
path = API;
sourceTree = "<group>";
};
0CA3B23528C265FD00616D3A /* LibraryViews */ = {
isa = PBXGroup;
children = (
0CA3B23828C2660D00616D3A /* BookmarksView.swift */,
0CA3B23628C2660700616D3A /* HistoryView.swift */,
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */,
0C12D43C28CC332A000195BF /* HistoryButtonView.swift */,
);
path = LibraryViews;
sourceTree = "<group>";
};
0CAF1C5F286F5C0D00296F86 = {
isa = PBXGroup;
children = (
@ -366,6 +483,7 @@
0C4CFC452897030D00AD9FAD /* Regex */,
0C7376EF28A97D1400D60918 /* SwiftUIX */,
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
0CB6516728C5A5EC00DCA721 /* Introspect */,
);
productName = Torrenter;
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
@ -402,6 +520,7 @@
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
0C7376EE28A97D1400D60918 /* XCRemoteSwiftPackageReference "SwiftUIX" */,
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
);
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
projectDirPath = "";
@ -430,61 +549,86 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
0C60B1EF28A1A00000E3FD7E /* SearchProgressView.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 */,
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 */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */,
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
0C84F4872895BFED0074B7C9 /* SourceList+CoreDataProperties.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* SourceCatalogView.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* SourceCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */,
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
0C794B69289DACC800DD1CC8 /* InstalledSourceView.swift in Sources */,
0C794B69289DACC800DD1CC8 /* InstalledSourceButtonView.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */,
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */,
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.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 */,
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
0CFEFCFD288A006200B3F490 /* GroupBoxStyle.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 */,
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */,
0CA148E2288903F000DE2211 /* Data.swift in Sources */,
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */,
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */,
0C7D11FC28AA01E900ED92DB /* DynamicAccentColor.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* SourceManager.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 */,
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CA148DE288903F000DE2211 /* RealDebridModels.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
0C32FB572890D1F2002BD219 /* ListRowViews.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 */,
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */,
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */,
@ -615,7 +759,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
@ -623,6 +767,7 @@
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@ -633,10 +778,11 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.4.0;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@ -648,7 +794,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
@ -656,6 +802,7 @@
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@ -666,10 +813,11 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.4.0;
MARKETING_VERSION = 0.5.0;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@ -747,6 +895,14 @@
minimumVersion = 2.0.0;
};
};
0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
requirement = {
branch = master;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -780,6 +936,11 @@
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
0CB6516728C5A5EC00DCA721 /* Introspect */ = {
isa = XCSwiftPackageProductDependency;
package = 0CB6516628C5A5EC00DCA721 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = Introspect;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View file

@ -14,6 +14,7 @@ public enum RealDebridError: Error {
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
@ -248,9 +249,22 @@ public class RealDebrid {
}
}
availableHashes.append(RealDebridIA(hash: hash, files: files, batches: batches))
// TTL: 5 minutes
availableHashes.append(
RealDebridIA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches
)
)
} else {
availableHashes.append(RealDebridIA(hash: hash))
availableHashes.append(
RealDebridIA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300
)
)
}
}
@ -294,21 +308,33 @@ public class RealDebrid {
try await performRequest(request: &request, requestName: #function)
}
// Fetches the info of a torrent
// Gets the info of a torrent from a given ID
public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
// Error out if no index is provided
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1] {
// Let the user know if a torrent is downloading
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
return torrentLink
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
throw RealDebridError.EmptyTorrents
} else {
throw RealDebridError.EmptyData
}
}
// Gets the user's torrent library
public func userTorrents() async throws -> [UserTorrentsResponse] {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
return rawResponse
}
// Deletes a torrent download from RD
public func deleteTorrent(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!)
@ -333,4 +359,14 @@ public class RealDebrid {
return rawResponse.download
}
// Gets the user's downloads
public func userDownloads() async throws -> [UserDownloadsResponse] {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
return rawResponse
}
}

View file

@ -1,14 +1,17 @@
//
// UIApplication.swift
// Application.swift
// Ferrite
//
// Created by Brian Dashore on 7/26/22.
// Created by Brian Dashore on 9/16/22.
//
// A thread-safe UIApplication alternative for specifying app properties
//
import SwiftUI
import Foundation
public class Application {
static let shared = Application()
// Extensions to get the version/build number for AboutView
extension UIApplication {
var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}

View file

@ -0,0 +1,25 @@
//
// Bookmark+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
//
import CoreData
import Foundation
@objc(Bookmark)
public class Bookmark: NSManagedObject {
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnetLink: magnetLink,
magnetHash: magnetHash,
seeders: seeders,
leechers: leechers
)
}
}

View file

@ -0,0 +1,27 @@
//
// Bookmark+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 9/3/22.
//
//
import CoreData
import Foundation
public extension Bookmark {
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
NSFetchRequest<Bookmark>(entityName: "Bookmark")
}
@NSManaged var leechers: String?
@NSManaged var magnetHash: String?
@NSManaged var magnetLink: String?
@NSManaged var seeders: String?
@NSManaged var size: String?
@NSManaged var source: String
@NSManaged var title: String?
@NSManaged var orderNum: Int16
}
extension Bookmark: Identifiable {}

View file

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

View file

@ -0,0 +1,46 @@
//
// History+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 9/4/22.
//
//
import CoreData
import Foundation
public extension History {
@nonobjc class func fetchRequest() -> NSFetchRequest<History> {
NSFetchRequest<History>(entityName: "History")
}
@NSManaged var date: Date?
@NSManaged var dateString: String?
@NSManaged var entries: NSSet?
internal var entryArray: [HistoryEntry] {
let entrySet = entries as? Set<HistoryEntry> ?? []
return entrySet.sorted {
$0.timeStamp > $1.timeStamp
}
}
}
// MARK: Generated accessors for entries
public extension History {
@objc(addEntriesObject:)
@NSManaged func addToEntries(_ value: HistoryEntry)
@objc(removeEntriesObject:)
@NSManaged func removeFromEntries(_ value: HistoryEntry)
@objc(addEntries:)
@NSManaged func addToEntries(_ values: NSSet)
@objc(removeEntries:)
@NSManaged func removeFromEntries(_ values: NSSet)
}
extension History: Identifiable {}

View file

@ -1,5 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21277" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="21G83" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<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="Source" representedClassName="Source" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="baseUrl" optional="YES" attributeType="String"/>

View file

@ -7,9 +7,21 @@
import CoreData
enum HistoryDeleteRange {
case day
case week
case month
case allTime
}
enum HistoryDeleteError: Error {
case noDate(String)
case unknown(String)
}
// No iCloud until finalized sources
struct PersistenceController {
static var shared = PersistenceController()
static let shared = PersistenceController()
// Coredata storage
let container: NSPersistentContainer
@ -33,7 +45,7 @@ struct PersistenceController {
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { _, error in
if let error = error {
if let error {
fatalError("CoreData init error: \(error)")
}
}
@ -78,4 +90,130 @@ struct PersistenceController {
container.viewContext.delete(object)
save()
}
func createBookmark(_ bookmarkJson: BookmarkJson) {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "source == %@ AND title == %@ AND magnetLink == %@",
bookmarkJson.source,
bookmarkJson.title ?? "",
bookmarkJson.magnetLink ?? ""
)
if (try? backgroundContext.fetch(bookmarkRequest).first) != nil {
return
}
let newBookmark = Bookmark(context: backgroundContext)
newBookmark.title = bookmarkJson.title
newBookmark.source = bookmarkJson.source
newBookmark.magnetHash = bookmarkJson.magnetHash
newBookmark.magnetLink = bookmarkJson.magnetLink
newBookmark.seeders = bookmarkJson.seeders
newBookmark.leechers = bookmarkJson.leechers
}
func createHistory(entryJson: HistoryEntryJson, date: Double?) {
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)
// 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 }
if !existingEntries.isEmpty {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
}
}
if history.entryArray.isEmpty {
PersistenceController.shared.delete(history, context: backgroundContext)
histories.remove(at: i)
}
}
newHistoryEntry.parentHistory = histories.first ?? History(context: backgroundContext)
} else {
newHistoryEntry.parentHistory = History(context: backgroundContext)
}
newHistoryEntry.parentHistory?.dateString = historyDateString
newHistoryEntry.parentHistory?.date = historyDate
}
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {
if range == .allTime {
return nil
}
var components = Calendar.current.dateComponents([.day, .month, .year], from: Date())
components.hour = 0
components.minute = 0
components.second = 0
guard let today = Calendar.current.date(from: components) else {
return nil
}
var offsetComponents = DateComponents(day: 1)
guard let tomorrow = Calendar.current.date(byAdding: offsetComponents, to: today) else {
return nil
}
switch range {
case .week:
offsetComponents.day = -7
case .month:
offsetComponents.day = -28
default:
break
}
guard var offsetDate = Calendar.current.date(byAdding: offsetComponents, to: today) else {
return nil
}
if TimeZone.current.isDaylightSavingTime(for: offsetDate) {
offsetDate = offsetDate.addingTimeInterval(3600)
}
let predicate = NSPredicate(format: "date >= %@ && date < %@", range == .day ? today as NSDate : offsetDate as NSDate, tomorrow as NSDate)
return predicate
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
func batchDeleteHistory(range: HistoryDeleteRange) throws {
let predicate = getHistoryPredicate(range: range)
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "History")
if let predicate {
fetchRequest.predicate = predicate
} else if range != .allTime {
throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?")
}
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeObjectIDs
let result = try backgroundContext.execute(batchDeleteRequest) as? NSBatchDeleteResult
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [container.viewContext, backgroundContext])
}
}

View file

@ -0,0 +1,17 @@
//
// DateFormatter.swift
// Ferrite
//
// Created by Brian Dashore on 9/4/22.
//
import Foundation
extension DateFormatter {
static let historyDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "ddMMyyyy"
return df
}()
}

View file

@ -0,0 +1,14 @@
//
// FileManager.swift
// Ferrite
//
// Created by Brian Dashore on 9/17/22.
//
import Foundation
extension FileManager {
var appDirectory: URL {
urls(for: .documentDirectory, in: .userDomainMask)[0]
}
}

View file

@ -0,0 +1,14 @@
//
// NotificationCenter.swift
// Ferrite
//
// Created by Brian Dashore on 9/3/22.
//
import Foundation
extension Notification.Name {
static var didDeleteBookmark: Notification.Name {
Notification.Name("Deleted bookmark")
}
}

View file

@ -0,0 +1,29 @@
//
// URL.swift
// Ferrite
//
// Created by Brian Dashore on 9/20/22.
//
import Foundation
extension URL {
// From https://github.com/Aidoku/Aidoku/blob/main/Shared/Extensions/FileManager.swift
// Used for FileManager
var contentsByDateAdded: [URL] {
if let urls = try? FileManager.default.contentsOfDirectory(
at: self,
includingPropertiesForKeys: [.contentModificationDateKey]
) {
return urls.sorted {
((try? $0.resourceValues(forKeys: [.addedToDirectoryDateKey]))?.addedToDirectoryDate ?? Date.distantPast)
>
((try? $1.resourceValues(forKeys: [.addedToDirectoryDateKey]))?.addedToDirectoryDate ?? Date.distantPast)
}
}
let contents = try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil)
return contents ?? []
}
}

View file

@ -5,12 +5,45 @@
// Created by Brian Dashore on 8/15/22.
//
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
))
}
// MARK: Modifiers
func dynamicAccentColor(_ color: Color) -> some View {
modifier(DynamicAccentColor(color: color))
func conditionalContextMenu(id: some Hashable,
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
{
modifier(ConditionalContextMenu(internalContent, id: id))
}
func conditionalId(_ id: some Hashable) -> some View {
modifier(ConditionalId(id: id))
}
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
modifier(DisabledAppearance(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
}
func disableInteraction(_ disabled: Bool) -> some View {
modifier(DisableInteraction(disabled: disabled))
}
func inlinedList() -> some View {
modifier(InlinedList())
}
}

View file

@ -16,6 +16,7 @@ struct FerriteApp: App {
@StateObject var debridManager: DebridManager = .init()
@StateObject var navModel: NavigationViewModel = .init()
@StateObject var sourceManager: SourceManager = .init()
@StateObject var backupManager: BackupManager = .init()
var body: some Scene {
WindowGroup {
@ -24,6 +25,7 @@ struct FerriteApp: App {
scrapingModel.toastModel = toastModel
debridManager.toastModel = toastModel
sourceManager.toastModel = toastModel
backupManager.toastModel = toastModel
navModel.toastModel = toastModel
}
.environmentObject(debridManager)
@ -31,6 +33,7 @@ struct FerriteApp: App {
.environmentObject(toastModel)
.environmentObject(navModel)
.environmentObject(sourceManager)
.environmentObject(backupManager)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}

View file

@ -2,6 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Ferrite Backup</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>me.kingbri.Ferrite.feb</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@ -9,5 +22,27 @@
</dict>
<key>UILaunchScreen</key>
<false/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.json</string>
</array>
<key>UTTypeDescription</key>
<string>Ferrite Backup</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>me.kingbri.Ferrite.feb</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>feb</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,42 @@
//
// BackupModels.swift
// Ferrite
//
// Created by Brian Dashore on 9/17/22.
//
import Foundation
public struct Backup: Codable {
var bookmarks: [BookmarkJson]?
var history: [HistoryJson]?
var sourceNames: [String]?
var sourceLists: [SourceListBackupJson]?
}
// MARK: - CoreData translation
typealias BookmarkJson = SearchResult
// Date is an epoch timestamp
struct HistoryJson: Codable {
let dateString: String?
let date: Double
let entries: [HistoryEntryJson]
}
struct HistoryEntryJson: Codable {
let name: String
let subName: String?
let url: String
let timeStamp: Double?
let source: String?
}
// Differs from SourceListJson
struct SourceListBackupJson: Codable {
let name: String
let author: String
let id: String
let urlString: String
}

View file

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

View file

@ -2,7 +2,7 @@
// RealDebridModels.swift
// Ferrite
//
// Created by Brian Dashore on 7/5/22.
// Created by Brian Dashore on 11/19/22.
//
// Structures generated from Quicktype
@ -10,7 +10,7 @@ import Foundation
// MARK: - device code endpoint
public struct DeviceCodeResponse: Codable {
public struct DeviceCodeResponse: Codable, Sendable {
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
@ -27,7 +27,7 @@ public struct DeviceCodeResponse: Codable {
// MARK: - device credentials endpoint
public struct DeviceCredentialsResponse: Codable {
public struct DeviceCredentialsResponse: Codable, Sendable {
let clientID, clientSecret: String?
enum CodingKeys: String, CodingKey {
@ -38,7 +38,7 @@ public struct DeviceCredentialsResponse: Codable {
// MARK: - token endpoint
public struct TokenResponse: Codable {
public struct TokenResponse: Codable, Sendable {
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
@ -54,7 +54,7 @@ public struct TokenResponse: Codable {
// MARK: - instantAvailability endpoint
// Thanks Skitty!
public struct InstantAvailabilityResponse: Codable {
public struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
public init(from decoder: Decoder) throws {
@ -66,39 +66,40 @@ public struct InstantAvailabilityResponse: Codable {
}
}
struct InstantAvailabilityData: Codable {
// MARK: - Instant Availability client side structures
struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
struct InstantAvailabilityInfo: Codable {
struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String
var filesize: Int
}
// MARK: - Instant Availability client side structures
public struct RealDebridIA: Codable, Hashable {
public struct RealDebridIA: Codable, Hashable, Sendable {
let hash: String
let expiryTimeStamp: Double
var files: [RealDebridIAFile] = []
var batches: [RealDebridIABatch] = []
}
public struct RealDebridIABatch: Codable, Hashable {
public struct RealDebridIABatch: Codable, Hashable, Sendable {
let files: [RealDebridIABatchFile]
}
public struct RealDebridIABatchFile: Codable, Hashable {
public struct RealDebridIABatchFile: Codable, Hashable, Sendable {
let id: Int
let fileName: String
}
public struct RealDebridIAFile: Codable, Hashable {
public struct RealDebridIAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
public enum RealDebridIAStatus: Codable, Hashable {
public enum RealDebridIAStatus: Codable, Hashable, Sendable {
case full
case partial
case none
@ -106,14 +107,14 @@ public enum RealDebridIAStatus: Codable, Hashable {
// MARK: - addMagnet endpoint
public struct AddMagnetResponse: Codable {
public struct AddMagnetResponse: Codable, Sendable {
let id: String
let uri: String
}
// MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable {
struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
@ -121,27 +122,41 @@ struct TorrentInfoResponse: Codable {
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String
let ended: String?
let speed: Int?
let seeders: Int?
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended
case host, split, progress, status, added, files, links, ended, speed, seeders
}
}
struct TorrentInfoFile: Codable {
struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
}
public struct UserTorrentsResponse: Codable, Sendable {
let id, filename, hash: String
let bytes: Int
let host: String
let split, progress: Int
let status, added: String
let links: [String]
let speed, seeders: Int?
let ended: String?
}
// MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable {
let id, filename, mimeType: String
struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
@ -156,3 +171,24 @@ struct UnrestrictLinkResponse: Codable {
case chunks, crc, download, streamable
}
}
// MARK: - User downloads list
public struct UserDownloadsResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks: Int
let download: String
let streamable: Int
let generated: String
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, download, streamable, generated
}
}

View file

@ -0,0 +1,18 @@
//
// SearchModels.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import Foundation
public struct SearchResult: Hashable, Codable, Sendable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let seeders: String?
let leechers: String?
}

View file

@ -7,18 +7,18 @@
import Foundation
public enum ApiCredentialResponseType: String, Codable, Hashable {
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case json
case text
}
public struct SourceListJson: Codable {
public struct SourceListJson: Codable, Sendable {
let name: String
let author: String
var sources: [SourceJson]
}
public struct SourceJson: Codable, Hashable {
public struct SourceJson: Codable, Hashable, Sendable {
let name: String
let version: Int16
let minVersion: String?
@ -34,20 +34,20 @@ public struct SourceJson: Codable, Hashable {
let htmlParser: SourceHtmlParserJson?
}
public enum SourcePreferredParser: Int16, CaseIterable {
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
// case none = 0
case scraping = 1
case rss = 2
case siteApi = 3
}
public struct SourceApiJson: Codable, Hashable {
public struct SourceApiJson: Codable, Hashable, Sendable {
let apiUrl: String?
let clientId: SourceApiCredentialJson?
let clientSecret: SourceApiCredentialJson?
}
public struct SourceApiCredentialJson: Codable, Hashable {
public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let query: String?
let value: String?
let dynamic: Bool?
@ -56,7 +56,7 @@ public struct SourceApiCredentialJson: Codable, Hashable {
let expiryLength: Double?
}
public struct SourceJsonParserJson: Codable, Hashable {
public struct SourceJsonParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let results: String?
let subResults: String?
@ -67,7 +67,7 @@ public struct SourceJsonParserJson: Codable, Hashable {
let sl: SourceSLJson?
}
public struct SourceRssParserJson: Codable, Hashable {
public struct SourceRssParserJson: Codable, Hashable, Sendable {
let rssUrl: String?
let searchUrl: String
let items: String
@ -78,7 +78,7 @@ public struct SourceRssParserJson: Codable, Hashable {
let sl: SourceSLJson?
}
public struct SourceHtmlParserJson: Codable, Hashable {
public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let rows: String
let magnet: SourceMagnetJson
@ -87,21 +87,21 @@ public struct SourceHtmlParserJson: Codable, Hashable {
let sl: SourceSLJson?
}
public struct SouceComplexQueryJson: Codable, Hashable {
public struct SouceComplexQueryJson: Codable, Hashable, Sendable {
let query: String
let discriminator: String?
let attribute: String?
let regex: String?
}
public struct SourceMagnetJson: Codable, Hashable {
public struct SourceMagnetJson: Codable, Hashable, Sendable {
let query: String
let attribute: String
let regex: String?
let externalLinkQuery: String?
}
public struct SourceSLJson: Codable, Hashable {
public struct SourceSLJson: Codable, Hashable, Sendable {
let seeders: String?
let leechers: String?
let combined: String?

View file

@ -0,0 +1,220 @@
//
// BackupManager.swift
// Ferrite
//
// Created by Brian Dashore on 9/16/22.
//
import Foundation
public class BackupManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var showRestoreAlert = false
@Published var showRestoreCompletedAlert = false
@Published var backupUrls: [URL] = []
@Published var backupSourceNames: [String] = []
@Published var selectedBackupUrl: URL?
func createBackup() {
var backup = Backup()
let backgroundContext = PersistenceController.shared.backgroundContext
let bookmarkRequest = Bookmark.fetchRequest()
if let fetchedBookmarks = try? backgroundContext.fetch(bookmarkRequest) {
backup.bookmarks = fetchedBookmarks.compactMap {
BookmarkJson(
title: $0.title,
source: $0.source,
size: $0.size,
magnetLink: $0.magnetLink,
magnetHash: $0.magnetHash,
seeders: $0.seeders,
leechers: $0.leechers
)
}
}
let historyRequest = History.fetchRequest()
if let fetchedHistory = try? backgroundContext.fetch(historyRequest) {
backup.history = fetchedHistory.compactMap { history in
if history.entries == nil {
return nil
} else {
return HistoryJson(
dateString: history.dateString,
date: history.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970,
entries: history.entryArray.compactMap { entry in
if let name = entry.name, let url = entry.url {
return HistoryEntryJson(
name: name,
subName: entry.subName,
url: url,
timeStamp: entry.timeStamp,
source: entry.source
)
} else {
return nil
}
}
)
}
}
}
let sourceRequest = Source.fetchRequest()
if let sources = try? backgroundContext.fetch(sourceRequest) {
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
)
}
}
do {
let encodedJson = try JSONEncoder().encode(backup)
let backupsPath = FileManager.default.appDirectory.appendingPathComponent("Backups")
if !FileManager.default.fileExists(atPath: backupsPath.path) {
try FileManager.default.createDirectory(atPath: backupsPath.path, withIntermediateDirectories: true, attributes: nil)
}
let snapshot = Int(Date().timeIntervalSince1970.rounded())
let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb")
try encodedJson.write(to: writeUrl)
backupUrls.append(writeUrl)
} catch {
print(error)
}
}
// Backup is in local documents directory, so no need to restore it from the shared URL
func restoreBackup() {
guard let backupUrl = selectedBackupUrl else {
Task {
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
}
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
do {
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)
}
}
if let storedHistories = backup.history {
for storedHistory in storedHistories {
for storedEntry in storedHistory.entries {
PersistenceController.shared.createHistory(entryJson: storedEntry, date: storedHistory.date)
}
}
}
if let storedLists = backup.sourceLists {
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
}
}
backupSourceNames = backup.sourceNames ?? []
PersistenceController.shared.save(backgroundContext)
// if iOS 14 is available, sleep to prevent any issues with alerts
if #available(iOS 15, *) {
showRestoreCompletedAlert.toggle()
} else {
Task {
try? await Task.sleep(seconds: 0.1)
Task { @MainActor in
showRestoreCompletedAlert.toggle()
}
}
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup restore: \(error)")
}
}
}
// Remove the backup from files and then the list
// Removes an index if it's provided
func removeBackup(backupUrl: URL, index: Int?) {
do {
try FileManager.default.removeItem(at: backupUrl)
if let index {
backupUrls.remove(at: index)
} else {
backupUrls.removeAll(where: { $0 == backupUrl })
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup removal: \(error)")
}
}
}
func copyBackup(backupUrl: URL) {
let backupSecured = backupUrl.startAccessingSecurityScopedResource()
defer {
if backupSecured {
backupUrl.stopAccessingSecurityScopedResource()
}
}
let backupsPath = FileManager.default.appDirectory.appendingPathComponent("Backups")
let localBackupPath = backupsPath.appendingPathComponent(backupUrl.lastPathComponent)
do {
if FileManager.default.fileExists(atPath: localBackupPath.path) {
try FileManager.default.removeItem(at: localBackupPath)
} else if !FileManager.default.fileExists(atPath: backupsPath.path) {
try FileManager.default.createDirectory(atPath: backupsPath.path, withIntermediateDirectories: true, attributes: nil)
}
try FileManager.default.copyItem(at: backupUrl, to: localBackupPath)
selectedBackupUrl = localBackupPath
} catch {
Task {
await toastModel?.updateToastDescription("Backup copy: \(error)")
}
}
}
}

View file

@ -10,17 +10,18 @@ import SwiftUI
@MainActor
public class DebridManager: ObservableObject {
// UI Variables
// Linked classes
var toastModel: ToastViewModel?
let realDebrid: RealDebrid = .init()
// UI Variables
@Published var showWebView: Bool = false
@Published var showLoadingProgress: Bool = false
// Service agnostic variables
@Published var currentDebridTask: Task<Void, Never>?
var currentDebridTask: Task<Void, Never>?
// RealDebrid auth variables
let realDebrid: RealDebrid = .init()
@Published var realDebridEnabled: Bool = false {
didSet {
UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled")
@ -28,31 +29,47 @@ public class DebridManager: ObservableObject {
}
@Published var realDebridAuthProcessing: Bool = false
@Published var realDebridAuthUrl: String = ""
var realDebridAuthUrl: String = ""
// RealDebrid fetch variables
@Published var realDebridHashes: [RealDebridIA] = []
@Published var realDebridDownloadUrl: String = ""
@Published var selectedRealDebridItem: RealDebridIA?
@Published var selectedRealDebridFile: RealDebridIAFile?
@Published var realDebridIAValues: [RealDebridIA] = []
var realDebridDownloadUrl: String = ""
@Published var showDeleteAlert: Bool = false
// TODO: Switch to an individual item based sheet system to remove these variables
var selectedRealDebridItem: RealDebridIA?
var selectedRealDebridFile: RealDebridIAFile?
var selectedRealDebridID: String?
init() {
realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled")
}
public func populateDebridHashes(_ searchResults: [SearchResult]) async {
var hashes: [String] = []
for result in searchResults {
if let hash = result.magnetHash {
hashes.append(hash)
}
}
public func populateDebridHashes(_ resultHashes: [String]) async {
do {
let debridHashes = try await realDebrid.instantAvailability(magnetHashes: hashes)
let now = Date()
realDebridHashes = debridHashes
// If a hash isn't found in the IA, update it
// If the hash is expired, remove it and update it
let sendHashes = resultHashes.filter { hash in
if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }) {
if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp {
realDebridIAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if !sendHashes.isEmpty {
let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes)
realDebridIAValues += fetchedIAValues
}
} catch {
let error = error as NSError
@ -65,11 +82,11 @@ public class DebridManager: ObservableObject {
}
public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus {
guard let result = result else {
guard let result else {
return .none
}
guard let debridMatch = realDebridHashes.first(where: { result.magnetHash == $0.hash }) else {
guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else {
return .none
}
@ -86,7 +103,7 @@ public class DebridManager: ObservableObject {
return false
}
if let realDebridItem = realDebridHashes.first(where: { magnetHash == $0.hash }) {
if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) {
selectedRealDebridItem = realDebridItem
return true
} else {
@ -126,7 +143,7 @@ public class DebridManager: ObservableObject {
}
}
public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async {
public func fetchRdDownload(searchResult: SearchResult) async {
defer {
currentDebridTask = nil
showLoadingProgress = false
@ -141,14 +158,10 @@ public class DebridManager: ObservableObject {
return
}
var realDebridId: String?
do {
realDebridId = try await realDebrid.addMagnet(magnetLink: magnetLink)
var fileIds: [Int] = []
if let iaFile = iaFile {
if let iaFile = selectedRealDebridFile {
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
return
}
@ -156,29 +169,52 @@ public class DebridManager: ObservableObject {
fileIds = iaBatchFromFile.files.map(\.id)
}
if let realDebridId = realDebridId {
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
// If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link
let existingTorrents = try await realDebrid.userTorrents().filter { $0.hash == selectedRealDebridItem?.hash }
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile?.batchFileIndex ?? 0)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
// If the links match from a user's downloads, no need to re-run a download
if let existingTorrent = existingTorrents[safe: 0],
let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0]
{
let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink }
if let existingLink = existingLinks[safe: 0]?.download {
realDebridDownloadUrl = existingLink
} else {
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
realDebridDownloadUrl = downloadLink
}
realDebridDownloadUrl = downloadLink
} else {
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
// Add a magnet after all the cache checks fail
selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink)
if let realDebridId = selectedRealDebridID {
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
realDebridDownloadUrl = downloadLink
} else {
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
}
}
} catch {
let error = error as NSError
switch error.code {
case -999:
toastModel?.updateToastDescription("Download cancelled", newToastType: .info)
switch error {
case RealDebridError.EmptyTorrents:
showDeleteAlert.toggle()
default:
toastModel?.updateToastDescription("RealDebrid download error: \(error)")
}
let error = error as NSError
// Delete the torrent download if it exists
if let realDebridId = realDebridId {
try? await realDebrid.deleteTorrent(debridID: realDebridId)
switch error.code {
case -999:
toastModel?.updateToastDescription("Download cancelled", newToastType: .info)
default:
toastModel?.updateToastDescription("RealDebrid download error: \(error)")
}
await deleteRdTorrent()
}
showLoadingProgress = false
@ -186,4 +222,12 @@ public class DebridManager: ObservableObject {
print("RealDebrid download error: \(error)")
}
}
public func deleteRdTorrent() async {
if let realDebridId = selectedRealDebridID {
try? await realDebrid.deleteTorrent(debridID: realDebridId)
}
selectedRealDebridID = nil
}
}

View file

@ -11,6 +11,7 @@ enum ViewTab {
case search
case sources
case settings
case library
}
@MainActor
@ -31,10 +32,16 @@ class NavigationViewModel: ObservableObject {
@Published var isEditingSearch: Bool = false
@Published var isSearching: Bool = false
@Published var selectedSearchResult: SearchResult?
// For giving information in magnet choice sheet
@Published var selectedTitle: String?
@Published var selectedBatchTitle: String?
@Published var hideNavigationBar = false
@Published var currentChoiceSheet: ChoiceSheetType?
@Published var activityItems: [Any] = []
var activityItems: [Any] = []
// Used to show the activity sheet in the share menu
@Published var showLocalActivitySheet = false
@ -44,15 +51,15 @@ class NavigationViewModel: ObservableObject {
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
@Published var selectedSource: Source?
var selectedSource: Source?
@Published var showSourceListEditor: Bool = false
@Published var selectedSourceList: SourceList?
var selectedSourceList: SourceList?
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
public func runDebridAction(action: DefaultDebridActionType?, urlString: String) {
public func runDebridAction(urlString: String, _ action: DefaultDebridActionType? = nil) {
let selectedAction = action ?? defaultDebridAction
switch selectedAction {
@ -86,11 +93,11 @@ class NavigationViewModel: ObservableObject {
}
}
public func runMagnetAction(action: DefaultMagnetActionType?, searchResult: SearchResult) {
public func runMagnetAction(magnetString: String?, _ action: DefaultMagnetActionType? = nil) {
let selectedAction = action ?? defaultMagnetAction
guard let magnetLink = searchResult.magnetLink else {
toastModel?.toastDescription = "Could not run your action because the magnet link is invalid."
guard let magnetLink = magnetString else {
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("Magnet action error: The magnet link is invalid.")
return
@ -116,4 +123,22 @@ class NavigationViewModel: ObservableObject {
}
}
}
public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) {
let backgroundContext = PersistenceController.shared.backgroundContext
// The timeStamp and date are nil because the create function will make them automatically
PersistenceController.shared.createHistory(
entryJson: HistoryEntryJson(
name: name ?? "",
subName: subName,
url: url ?? "",
timeStamp: nil,
source: source
),
date: nil
)
PersistenceController.shared.save(backgroundContext)
}
}

View file

@ -11,16 +11,6 @@ import SwiftSoup
import SwiftUI
import SwiftyJSON
public struct SearchResult: Hashable, Codable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let seeders: String?
let leechers: String?
}
class ScrapingViewModel: ObservableObject {
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@ -28,10 +18,9 @@ class ScrapingViewModel: ObservableObject {
var toastModel: ToastViewModel?
let byteCountFormatter: ByteCountFormatter = .init()
@Published var runningSearchTask: Task<Void, Error>?
var runningSearchTask: Task<Void, Error>?
@Published var searchResults: [SearchResult] = []
@Published var searchText: String = ""
@Published var selectedSearchResult: SearchResult?
@Published var filteredSource: Source?
@Published var currentSourceName: String?
@ -40,6 +29,17 @@ class ScrapingViewModel: ObservableObject {
searchResults = newResults
}
// Utility function to print source specific errors
func sendSourceError(_ description: String, newToastType: ToastViewModel.ToastType? = nil) async {
let newDescription = "\(currentSourceName ?? "No source given"): \(description)"
await toastModel?.updateToastDescription(
newDescription,
newToastType: newToastType
)
print(newDescription)
}
public func scanSources(sources: [Source]) async {
if sources.isEmpty {
await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info)
@ -67,8 +67,7 @@ class ScrapingViewModel: ObservableObject {
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
await toastModel?.updateToastDescription("Could not process search query, invalid characters present.")
print("Could not process search query, invalid characters present")
await sendSourceError("Could not process search query, invalid characters present.")
continue
}
@ -85,7 +84,7 @@ class ScrapingViewModel: ObservableObject {
fallbackUrls: source.fallbackUrls
)
if let data = data,
if let data,
let html = String(data: data, encoding: .utf8)
{
let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html)
@ -110,7 +109,7 @@ class ScrapingViewModel: ObservableObject {
)
}
if let data = data,
if let data,
let rss = String(data: data, encoding: .utf8)
{
let sourceResults = await scrapeRss(source: source, rss: rss)
@ -156,7 +155,7 @@ class ScrapingViewModel: ObservableObject {
fallbackUrls: source.fallbackUrls
)
if let data = data {
if let data {
let sourceResults = await scrapeJson(source: source, jsonData: data)
tempResults += sourceResults
}
@ -181,7 +180,7 @@ class ScrapingViewModel: ObservableObject {
return data
}
if let fallbackUrls = fallbackUrls {
if let fallbackUrls {
for fallbackUrl in fallbackUrls {
if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl) {
return data
@ -266,13 +265,11 @@ class ScrapingViewModel: ObservableObject {
case -999:
await toastModel?.updateToastDescription("Search cancelled", newToastType: .info)
case -1001:
await toastModel?.updateToastDescription("Credentials request timed out")
await sendSourceError("Credentials request timed out")
default:
await toastModel?.updateToastDescription("Error in fetching an API credential \(error)")
await sendSourceError("Error in fetching an API credential \(error)")
}
print("Error in fetching an API credential \(error)")
return nil
}
}
@ -280,9 +277,7 @@ class ScrapingViewModel: ObservableObject {
// Fetches the data for a URL
public func fetchWebsiteData(urlString: String) async -> Data? {
guard let url = URL(string: urlString) else {
await toastModel?.updateToastDescription("Source doesn't contain a valid URL, contact the source dev!")
print("Source doesn't contain a valid URL, contact the source dev!")
await sendSourceError("Source doesn't contain a valid URL, contact the source dev!")
return nil
}
@ -299,13 +294,11 @@ class ScrapingViewModel: ObservableObject {
case -999:
await toastModel?.updateToastDescription("Search cancelled", newToastType: .info)
case -1001:
await toastModel?.updateToastDescription("Data request timed out. Trying fallback URLs if present.")
await sendSourceError("Data request timed out. Trying fallback URLs if present.")
default:
await toastModel?.updateToastDescription("Error in fetching website data \(error)")
await sendSourceError("Error in fetching website data \(error)")
}
print("Error in fetching data \(error)")
return nil
}
}
@ -370,7 +363,7 @@ class ScrapingViewModel: ObservableObject {
}
}
} else if
let searchResult = searchResult,
let searchResult,
let magnetLink = searchResult.magnetLink,
magnetLink.starts(with: "magnet:"),
!tempResults.contains(searchResult)
@ -415,7 +408,7 @@ class ScrapingViewModel: ObservableObject {
if let magnetLinkParser = jsonParser.magnetLink, existingSearchResult?.magnetLink == nil {
let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue
link = rawLink is NSNull ? nil : String(describing: rawLink)
} else if let magnetHash = magnetHash {
} else if let magnetHash {
link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers)
}
@ -476,8 +469,7 @@ class ScrapingViewModel: ObservableObject {
let document = try SwiftSoup.parse(rss, "", Parser.xmlParser())
items = try document.getElementsByTag(rssParser.items)
} catch {
await toastModel?.updateToastDescription("RSS scraping error, couldn't fetch items: \(error)")
print("RSS scraping error, couldn't fetch items: \(error)")
await sendSourceError("RSS scraping error, couldn't fetch items: \(error)")
return tempResults
}
@ -517,7 +509,7 @@ class ScrapingViewModel: ObservableObject {
discriminator: magnetLinkParser.discriminator,
regexString: magnetLinkParser.regex
)
} else if let magnetHash = magnetHash {
} else if let magnetHash {
link = generateMagnetLink(magnetHash: magnetHash, title: title, trackers: source.trackers)
} else {
continue
@ -597,7 +589,7 @@ class ScrapingViewModel: ObservableObject {
parsedValue = try item.getElementsByTag(query).first()?.text()
default:
// If there's a key/value to lookup the attribute with, query it. Othewise assume the value is in the same attribute
if let discriminator = discriminator {
if let discriminator {
let containerElement = try item.getElementsByAttributeValue(discriminator, query).first()
parsedValue = try containerElement?.attr(attribute)
} else {
@ -607,8 +599,8 @@ class ScrapingViewModel: ObservableObject {
}
// A capture group must be used in the provided regex
if let regexString = regexString,
let parsedValue = parsedValue,
if let regexString,
let parsedValue,
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
{
return regexValue
@ -631,8 +623,7 @@ class ScrapingViewModel: ObservableObject {
let document = try SwiftSoup.parse(html)
rows = try document.select(htmlParser.rows)
} catch {
await toastModel?.updateToastDescription("Scraping error, couldn't fetch rows: \(error)")
print("Scraping error, couldn't fetch rows: \(error)")
await sendSourceError("Scraping error, couldn't fetch rows: \(error)")
return tempResults
}
@ -764,8 +755,7 @@ class ScrapingViewModel: ObservableObject {
tempResults.append(result)
}
} catch {
await toastModel?.updateToastDescription("Scraping error: \(error)")
print("Scraping error: \(error)")
await sendSourceError("Scraping error: \(error)")
continue
}
@ -788,8 +778,8 @@ class ScrapingViewModel: ObservableObject {
}
// A capture group must be used in the provided regex
if let regexString = regexString,
let parsedValue = parsedValue,
if let regexString,
let parsedValue,
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
{
return regexValue
@ -802,10 +792,10 @@ class ScrapingViewModel: ObservableObject {
public func fetchMagnetHash(magnetLink: String? = nil, existingHash: String? = nil) -> String? {
var magnetHash: String
if let existingHash = existingHash {
if let existingHash {
magnetHash = existingHash
} else if
let magnetLink = magnetLink,
let magnetLink,
let firstSplit = magnetLink.split(separator: ":")[safe: 3],
let tempHash = firstSplit.split(separator: "&")[safe: 0]
{
@ -850,11 +840,11 @@ class ScrapingViewModel: ObservableObject {
magnetLinkArray.append(magnetHash)
if let title = title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
magnetLinkArray.append("&dn=\(encodedTitle)")
}
if let trackers = trackers {
if let trackers {
for trackerUrl in trackers {
if URL(string: trackerUrl) != nil,
let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
@ -912,7 +902,7 @@ class ScrapingViewModel: ObservableObject {
}
}
await toastModel?.updateToastDescription(responseArray.joined())
await sendSourceError(responseArray.joined(separator: " "))
PersistenceController.shared.save(backgroundContext)
}

View file

@ -1,5 +1,5 @@
//
// SourceViewModel.swift
// SourceManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/25/22.
@ -7,14 +7,14 @@
import CoreData
import Foundation
import UIKit
import SwiftUI
public class SourceManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var availableSources: [SourceJson] = []
@Published var urlErrorAlertText = ""
var urlErrorAlertText = ""
@Published var showUrlErrorAlert = false
@MainActor
@ -52,14 +52,30 @@ public class SourceManager: ObservableObject {
}
}
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 = minVersion else {
guard let minVersion else {
return true
}
return UIApplication.shared.appVersion >= minVersion
return Application.shared.appVersion >= minVersion
}
// Fetches sources using the background context
@ -369,7 +385,7 @@ public class SourceManager: ObservableObject {
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 {
if let existingSourceList {
existingSourceList.urlString = sourceUrl
existingSourceList.name = rawResponse.name
existingSourceList.author = rawResponse.author

View file

@ -36,7 +36,7 @@ class ToastViewModel: ObservableObject {
@Published var showToast: Bool = false
public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) {
if let newToastType = newToastType {
if let newToastType {
toastType = newToastType
}

View file

@ -9,24 +9,32 @@ import SwiftUI
struct AboutView: View {
var body: some View {
VStack {
Image("AppImage")
.resizable()
.frame(width: 100, height: 100)
.cornerRadius(25)
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
.padding()
List {
ListRowTextView(leftText: "Version", rightText: UIApplication.shared.appVersion)
ListRowTextView(leftText: "Build number", rightText: UIApplication.shared.appBuild)
ListRowTextView(leftText: "Build type", rightText: UIApplication.shared.buildType)
List {
Section {
ListRowTextView(leftText: "Version", rightText: Application.shared.appVersion)
ListRowTextView(leftText: "Build number", rightText: Application.shared.appBuild)
ListRowTextView(leftText: "Build type", rightText: Application.shared.buildType)
ListRowLinkView(text: "Discord server", link: "https://discord.gg/sYQxnuD7Fj")
ListRowLinkView(text: "GitHub repository", link: "https://github.com/bdashore3/Ferrite")
} header: {
VStack(alignment: .center) {
Image("AppImage")
.resizable()
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 100 * 0.225, style: .continuous))
.padding(.top, 24)
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
.textCase(.none)
.foregroundColor(.label)
.font(.body)
.padding(.top, 8)
.padding(.bottom, 20)
}
.listRowInsets(EdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 0))
}
.listStyle(.insetGrouped)
}
.listStyle(.insetGrouped)
.navigationTitle("About")
}
}

View file

@ -8,12 +8,12 @@
import SwiftUI
struct BatchChoiceView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
var body: some View {
NavView {
List {
@ -21,14 +21,16 @@ struct BatchChoiceView: View {
Button(file.name) {
debridManager.selectedRealDebridFile = file
if let searchResult = scrapingModel.selectedSearchResult {
if let searchResult = navModel.selectedSearchResult {
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file)
await debridManager.fetchRdDownload(searchResult: searchResult)
if !debridManager.realDebridDownloadUrl.isEmpty {
// The download may complete before this sheet dismisses
try? await Task.sleep(seconds: 1)
navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl)
navModel.selectedBatchTitle = file.name
navModel.addToHistory(name: searchResult.title, source: searchResult.source, url: debridManager.realDebridDownloadUrl, subName: file.name)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl)
}
debridManager.selectedRealDebridFile = nil
@ -36,8 +38,9 @@ struct BatchChoiceView: View {
}
}
presentationMode.wrappedValue.dismiss()
navModel.currentChoiceSheet = nil
}
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
@ -46,9 +49,12 @@ struct BatchChoiceView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
debridManager.selectedRealDebridItem = nil
navModel.currentChoiceSheet = nil
presentationMode.wrappedValue.dismiss()
Task {
try? await Task.sleep(seconds: 1)
debridManager.selectedRealDebridItem = nil
}
}
}
}

View file

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

View file

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

View file

@ -1,22 +0,0 @@
//
// dynamicAccentColor.swift
// Ferrite
//
// Created by Brian Dashore on 8/15/22.
//
import SwiftUI
struct DynamicAccentColor: ViewModifier {
let color: Color
func body(content: Content) -> some View {
if #available(iOS 15, *) {
content
.tint(color)
} else {
content
.accentColor(color)
}
}
}

View file

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

View file

@ -0,0 +1,27 @@
//
// EmptyInstructionView.swift
// Ferrite
//
// Created by Brian Dashore on 9/5/22.
//
import SwiftUI
struct EmptyInstructionView: View {
let title: String
let message: String
var body: some View {
VStack(spacing: 5) {
Text(title)
.font(.system(size: 25, weight: .semibold))
Text(message)
.padding(.horizontal, 50)
}
.multilineTextAlignment(.center)
.foregroundColor(.secondaryLabel)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
}

View file

@ -1,20 +0,0 @@
//
// GroupBoxStyle.swift
// Ferrite
//
// Created by Brian Dashore on 7/21/22.
//
import SwiftUI
struct ErrorGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack {
configuration.label
configuration.content
}
.padding(10)
.background(Color(UIColor.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}

View file

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

View file

@ -4,11 +4,11 @@
//
// Created by Brian Dashore on 7/26/22.
//
// List row button, text, and link boilerplate
//
import SwiftUI
// These views were imported from Asobi
// View alias for a list row with an external link
struct ListRowLinkView: View {
let text: String
let link: String
@ -64,7 +64,7 @@ struct ListRowTextView: View {
Spacer()
if let rightText = rightText {
if let rightText {
Text(rightText)
} else {
Image(systemName: rightSymbol!)

View file

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

View file

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

View file

@ -0,0 +1,25 @@
//
// DisableInteraction.swift
// Ferrite
//
// Created by Brian Dashore on 9/13/22.
//
// Disables interaction on any view without applying the appearance
//
import SwiftUI
struct DisableInteraction: ViewModifier {
let disabled: Bool
func body(content: Content) -> some View {
content
.overlay {
if disabled {
Color.clear
.contentShape(Rectangle())
.gesture(TapGesture())
}
}
}
}

View file

@ -0,0 +1,23 @@
//
// DisabledAppearance.swift
// Ferrite
//
// Created by Brian Dashore on 9/10/22.
//
// Adds opacity transitions to the disabled modifier
//
import SwiftUI
struct DisabledAppearance: ViewModifier {
let disabled: Bool
let dimmedOpacity: Double?
let animation: Animation?
func body(content: Content) -> some View {
content
.disabled(disabled)
.opacity(disabled ? dimmedOpacity.map { $0 } ?? 0.5 : 1)
.animation(animation.map { $0 } ?? .none, value: disabled)
}
}

View file

@ -0,0 +1,28 @@
//
// InlinedList.swift
// Ferrite
//
// Created by Brian Dashore on 9/4/22.
//
// Removes the top padding on unsectioned lists
// If a list is sectioned, see InlineHeader
//
import Introspect
import SwiftUI
struct InlinedList: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
.introspectCollectionView { collectionView in
collectionView.contentInset.top = -20
}
} else {
content
.introspectTableView { tableView in
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 20))
}
}
}
}

View file

@ -3,6 +3,9 @@
// Ferrite
//
// Created by Brian Dashore on 7/4/22.
// Contributed by Mantton
//
// A wrapper that switches between NavigationStack and the legacy NavigationView
//
import SwiftUI

View file

@ -30,7 +30,7 @@ struct ContentView: View {
var body: some View {
NavView {
VStack(spacing: 10) {
HStack {
HStack(spacing: 6) {
Text("Filter")
.foregroundColor(.secondary)
@ -50,10 +50,10 @@ struct ContentView: View {
Button {
selectedSource = source
} label: {
Text(name)
if selectedSource == source {
Image(systemName: "checkmark")
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
@ -72,30 +72,6 @@ struct ContentView: View {
SearchResultsView()
}
.sheet(item: $navModel.currentChoiceSheet) { item in
Group {
switch item {
case .magnet:
MagnetChoiceView()
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
case .batch:
BatchChoiceView()
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
case .activity:
if #available(iOS 16, *) {
AppActivityView(activityItems: navModel.activityItems)
.presentationDetents([.medium, .large])
} else {
AppActivityView(activityItems: navModel.activityItems)
}
}
}
.dynamicAccentColor(.primary)
}
.navigationTitle("Search")
.navigationSearchBar {
SearchBar("Search",
@ -111,7 +87,11 @@ struct ContentView: View {
await scrapingModel.scanSources(sources: sources)
if realDebridEnabled, !scrapingModel.searchResults.isEmpty {
await debridManager.populateDebridHashes(scrapingModel.searchResults)
debridManager.realDebridIAValues = []
await debridManager.populateDebridHashes(
scrapingModel.searchResults.compactMap(\.magnetHash)
)
}
navModel.showSearchProgress = false

View file

@ -0,0 +1,95 @@
//
// LibraryView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct LibraryView: View {
enum LibraryPickerSegment {
case bookmarks
case history
}
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Bookmark.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)
]
) var bookmarks: FetchedResults<Bookmark>
@FetchRequest(
entity: History.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \History.date, ascending: false)
]
) var history: FetchedResults<History>
@State private var historyEmpty = true
@State private var selectedSegment: LibraryPickerSegment = .bookmarks
@State private var editMode: EditMode = .inactive
var body: some View {
NavView {
VStack {
Picker("Segments", selection: $selectedSegment) {
Text("Bookmarks").tag(LibraryPickerSegment.bookmarks)
Text("History").tag(LibraryPickerSegment.history)
}
.pickerStyle(.segmented)
.padding()
switch selectedSegment {
case .bookmarks:
BookmarksView(bookmarks: bookmarks)
case .history:
HistoryView(history: history)
}
Spacer()
}
.overlay {
switch selectedSegment {
case .bookmarks:
if bookmarks.isEmpty {
EmptyInstructionView(title: "No Bookmarks", message: "Add a bookmark from search results")
}
case .history:
if history.isEmpty {
EmptyInstructionView(title: "No History", message: "Start watching to build history")
}
}
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
EditButton()
if selectedSegment == .history {
HistoryActionsView()
}
}
}
}
.environment(\.editMode, $editMode)
}
.onChange(of: selectedSegment) { _ in
editMode = .inactive
}
.onDisappear {
editMode = .inactive
}
}
}
struct LibraryView_Previews: PreviewProvider {
static var previews: some View {
LibraryView()
}
}

View file

@ -0,0 +1,68 @@
//
// BookmarksView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct BookmarksView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
let backgroundContext = PersistenceController.shared.backgroundContext
var bookmarks: FetchedResults<Bookmark>
@State private var viewTask: Task<Void, Never>?
var body: some View {
ZStack {
if !bookmarks.isEmpty {
List {
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: nil)
}
}
}
.onMove { source, destination in
var changedBookmarks = bookmarks.map { $0 }
changedBookmarks.move(fromOffsets: source, toOffset: destination)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
}
PersistenceController.shared.save()
}
}
.inlinedList()
.listStyle(.insetGrouped)
}
}
.onAppear {
if realDebridEnabled {
viewTask = Task {
let hashes = bookmarks.compactMap(\.magnetHash)
await debridManager.populateDebridHashes(hashes)
}
}
}
.onDisappear {
viewTask?.cancel()
}
}
}

View file

@ -0,0 +1,54 @@
//
// HistoryActionsView.swift
// Ferrite
//
// Created by Brian Dashore on 9/7/22.
//
import SwiftUI
struct HistoryActionsView: View {
@EnvironmentObject var toastModel: ToastViewModel
@State private var showActionSheet = false
var body: some View {
Button("Clear") {
showActionSheet.toggle()
}
.backport.tint(.red)
.backport.confirmationDialog(
isPresented: $showActionSheet,
title: "Clear watch history",
message: "This is an irreversible action!",
buttons: [
AlertButton("Past day", role: .destructive) {
deleteHistory(.day)
},
AlertButton("Past week", role: .destructive) {
deleteHistory(.week)
},
AlertButton("Past month", role: .destructive) {
deleteHistory(.month)
},
AlertButton("All time", role: .destructive) {
deleteHistory(.allTime)
}
]
)
}
func deleteHistory(_ deleteRange: HistoryDeleteRange) {
do {
try PersistenceController.shared.batchDeleteHistory(range: deleteRange)
} catch {
toastModel.updateToastDescription("History delete error: \(error)")
}
}
}
struct HistoryActionsView_Previews: PreviewProvider {
static var previews: some View {
HistoryActionsView()
}
}

View file

@ -0,0 +1,80 @@
//
// HistoryButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 9/9/22.
//
import SwiftUI
struct HistoryButtonView: View {
@EnvironmentObject var toastModel: ToastViewModel
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
let entry: HistoryEntry
var body: some View {
Button {
navModel.selectedTitle = entry.name
navModel.selectedBatchTitle = entry.subName
if let url = entry.url {
if url.starts(with: "https://") {
Task {
debridManager.realDebridDownloadUrl = url
navModel.runDebridAction(urlString: url)
if navModel.currentChoiceSheet != .magnet {
debridManager.realDebridDownloadUrl = ""
}
}
} else {
navModel.runMagnetAction(magnetString: url)
}
} else {
toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.")
}
} label: {
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text(entry.name ?? "Unknown title")
.font(entry.subName == nil ? .body : .subheadline)
.lineLimit(entry.subName == nil ? 2 : 1)
if let subName = entry.subName {
Text(subName)
.foregroundColor(.gray)
.font(.subheadline)
.lineLimit(2)
}
}
HStack {
Text(entry.source ?? "Unknown source")
Spacer()
Text("DEBRID")
.fontWeight(.bold)
.padding(3)
.background {
Group {
if let url = entry.url, url.starts(with: "https://") {
Color.green
} else {
Color.red
}
}
.cornerRadius(4)
.opacity(0.5)
}
}
.font(.caption)
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
}
.backport.tint(.primary)
.disableInteraction(navModel.currentChoiceSheet != nil)
}
}

View file

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

View file

@ -23,31 +23,44 @@ struct MagnetChoiceView: View {
var body: some View {
NavView {
Form {
if realDebridEnabled, debridManager.matchSearchResult(result: scrapingModel.selectedSearchResult) != .none {
Section(header: "Now Playing") {
VStack(alignment: .leading, spacing: 5) {
Text(navModel.selectedTitle ?? "No title")
.font(.callout)
.lineLimit(navModel.selectedBatchTitle == nil ? .max : 1)
if let batchTitle = navModel.selectedBatchTitle {
Text(batchTitle)
.foregroundColor(.gray)
.font(.subheadline)
}
}
}
if !debridManager.realDebridDownloadUrl.isEmpty {
Section(header: "Real Debrid options") {
ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(action: .outplayer, urlString: debridManager.realDebridDownloadUrl)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .outplayer)
}
ListRowButtonView("Play on VLC", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(action: .vlc, urlString: debridManager.realDebridDownloadUrl)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .vlc)
}
ListRowButtonView("Play on Infuse", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(action: .infuse, urlString: debridManager.realDebridDownloadUrl)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl, .infuse)
}
ListRowButtonView("Copy download URL", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = debridManager.realDebridDownloadUrl
showLinkCopyAlert.toggle()
}
.alert(isPresented: $showLinkCopyAlert) {
Alert(
title: Text("Copied"),
message: Text("Download link copied successfully"),
dismissButton: .cancel(Text("OK"))
)
}
.backport.alert(
isPresented: $showLinkCopyAlert,
title: "Copied",
message: "Download link copied successfully",
buttons: [AlertButton("OK")]
)
ListRowButtonView("Share download URL", systemImage: "square.and.arrow.up.fill") {
if let url = URL(string: debridManager.realDebridDownloadUrl) {
@ -60,19 +73,18 @@ struct MagnetChoiceView: View {
Section(header: "Magnet options") {
ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = scrapingModel.selectedSearchResult?.magnetLink
UIPasteboard.general.string = navModel.selectedSearchResult?.magnetLink
showMagnetCopyAlert.toggle()
}
.alert(isPresented: $showMagnetCopyAlert) {
Alert(
title: Text("Copied"),
message: Text("Magnet link copied successfully"),
dismissButton: .cancel(Text("OK"))
)
}
.backport.alert(
isPresented: $showMagnetCopyAlert,
title: "Copied",
message: "Magnet link copied successfully",
buttons: [AlertButton("OK")]
)
ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") {
if let result = scrapingModel.selectedSearchResult,
if let result = navModel.selectedSearchResult,
let magnetLink = result.magnetLink,
let url = URL(string: magnetLink)
{
@ -82,12 +94,11 @@ struct MagnetChoiceView: View {
}
ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") {
if let result = scrapingModel.selectedSearchResult {
navModel.runMagnetAction(action: .webtor, searchResult: result)
}
navModel.runMagnetAction(magnetString: navModel.selectedSearchResult?.magnetLink, .webtor)
}
}
}
.backport.tint(.primary)
.sheet(isPresented: $navModel.showLocalActivitySheet) {
if #available(iOS 16, *) {
AppActivityView(activityItems: navModel.activityItems)
@ -96,6 +107,9 @@ struct MagnetChoiceView: View {
AppActivityView(activityItems: navModel.activityItems)
}
}
.onDisappear {
debridManager.realDebridDownloadUrl = ""
}
.navigationTitle("Link actions")
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View file

@ -12,6 +12,8 @@ struct MainView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var toastModel: ToastViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var backupManager: BackupManager
@AppStorage("Updates.AutomaticNotifs") var autoUpdateNotifs = true
@ -28,6 +30,12 @@ struct MainView: View {
}
.tag(ViewTab.search)
LibraryView()
.tabItem {
Label("Library", systemImage: "book.closed")
}
.tag(ViewTab.library)
SourcesView()
.tabItem {
Label("Sources", systemImage: "doc.text")
@ -40,19 +48,26 @@ struct MainView: View {
}
.tag(ViewTab.settings)
}
.alert(isPresented: $showUpdateAlert) {
Alert(
title: Text("Update available"),
message: Text("Ferrite \(releaseVersionString) can be downloaded. \n\n This alert can be disabled in Settings."),
primaryButton: .default(Text("Download")) {
guard let releaseUrl = URL(string: releaseUrlString) else {
return
}
UIApplication.shared.open(releaseUrl)
},
secondaryButton: .cancel()
)
.sheet(item: $navModel.currentChoiceSheet) { item in
switch item {
case .magnet:
MagnetChoiceView()
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
case .batch:
BatchChoiceView()
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
case .activity:
if #available(iOS 16, *) {
AppActivityView(activityItems: navModel.activityItems)
.presentationDetents([.medium, .large])
} else {
AppActivityView(activityItems: navModel.activityItems)
}
}
}
.onAppear {
if autoUpdateNotifs {
@ -64,8 +79,7 @@ struct MainView: View {
}
let releaseVersion = String(latestRelease.tagName.dropFirst())
if releaseVersion > UIApplication.shared.appVersion {
print("Greater")
if releaseVersion > Application.shared.appVersion {
releaseVersionString = latestRelease.tagName
releaseUrlString = latestRelease.htmlUrl
showUpdateAlert.toggle()
@ -79,6 +93,52 @@ struct MainView: View {
.onDisappear {
viewTask?.cancel()
}
.onOpenURL { url in
if url.scheme == "file" {
// Attempt to copy to backups directory if backup doesn't exist
backupManager.copyBackup(backupUrl: url)
backupManager.showRestoreAlert.toggle()
}
}
// Global alerts for backups
.backport.alert(
isPresented: $backupManager.showRestoreAlert,
title: "Restore backup?",
message: "Restoring this backup will merge all your data!",
buttons: [
.init("Restore", role: .destructive) {
backupManager.restoreBackup()
},
.init(role: .cancel)
]
)
.backport.alert(
isPresented: $backupManager.showRestoreCompletedAlert,
title: "Backup restored",
message: backupManager.backupSourceNames.isEmpty ?
"No sources need to be reinstalled" :
"Reinstall sources: \(backupManager.backupSourceNames.joined(separator: ", "))",
buttons: [
.init("OK") {}
]
)
// Updater alert
.backport.alert(
isPresented: $showUpdateAlert,
title: "Update available",
message: "Ferrite \(releaseVersionString) can be downloaded. \n\n This alert can be disabled in Settings.",
buttons: [
AlertButton("Download") {
guard let releaseUrl = URL(string: releaseUrlString) else {
return
}
UIApplication.shared.open(releaseUrl)
},
AlertButton(role: .cancel)
]
)
.overlay {
VStack {
Spacer()

View file

@ -13,7 +13,7 @@ struct WebView: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.load(URLRequest(url: url))
let _ = webView.load(URLRequest(url: url))
return webView
}

View file

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

View file

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

View file

@ -37,20 +37,18 @@ struct SearchResultRDView: View {
.fontWeight(.bold)
.padding(2)
.background {
switch debridManager.matchSearchResult(result: result) {
case .full:
Color.green
.cornerRadius(4)
.opacity(0.5)
case .partial:
Color.orange
.cornerRadius(4)
.opacity(0.5)
case .none:
Color.red
.cornerRadius(4)
.opacity(0.5)
Group {
switch debridManager.matchSearchResult(result: result) {
case .full:
Color.green
case .partial:
Color.orange
case .none:
Color.red
}
}
.cornerRadius(4)
.opacity(0.5)
}
}
}

View file

@ -9,51 +9,18 @@ import SwiftUI
struct SearchResultsView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
var body: some View {
List {
ForEach(scrapingModel.searchResults, id: \.self) { result in
if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil {
VStack(alignment: .leading) {
Button {
if debridManager.currentDebridTask == nil {
scrapingModel.selectedSearchResult = result
switch debridManager.matchSearchResult(result: result) {
case .full:
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: result)
if !debridManager.realDebridDownloadUrl.isEmpty {
navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl)
}
}
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navModel.currentChoiceSheet = .batch
}
case .none:
navModel.runMagnetAction(action: nil, searchResult: result)
}
}
} label: {
Text(result.title ?? "No title")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
.dynamicAccentColor(.primary)
.padding(.bottom, 5)
SearchResultRDView(result: result)
}
SearchResultButtonView(result: result)
}
}
}
.listStyle(.insetGrouped)
.inlinedList()
.overlay {
if scrapingModel.searchResults.isEmpty {
if navModel.showSearchProgress {

View file

@ -5,6 +5,7 @@
// Created by Brian Dashore on 7/11/22.
//
import Introspect
import SwiftUI
struct SettingsView: View {
@ -21,7 +22,7 @@ struct SettingsView: View {
var body: some View {
NavView {
Form {
Section(header: "Debrid services") {
Section(header: InlineHeader("Debrid Services")) {
HStack {
Text("Real Debrid")
Spacer()
@ -36,15 +37,18 @@ struct SettingsView: View {
} label: {
Text(debridManager.realDebridEnabled ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login"))
.foregroundColor(debridManager.realDebridEnabled ? .red : .blue)
.onChange(of: debridManager.realDebridEnabled) { changed in
print("Debrid enabled changed to \(changed)")
}
}
}
}
Section(header: "Source management") {
Section(header: Text("Source management")) {
NavigationLink("Source lists", destination: SettingsSourceListView())
}
Section(header: "Default actions") {
Section(header: Text("Default actions")) {
if debridManager.realDebridEnabled {
NavigationLink(
destination: DebridActionPickerView(),
@ -94,6 +98,12 @@ struct SettingsView: View {
)
}
Section(header: Text("Backups")) {
NavigationLink(destination: BackupsView()) {
Text("Backups")
}
}
Section(header: Text("Updates")) {
Toggle(isOn: $autoUpdateNotifs) {
Text("Show update alerts")

View file

@ -0,0 +1,73 @@
//
// BackupsView.swift
// Ferrite
//
// Created by Brian Dashore on 9/16/22.
//
import SwiftUI
struct BackupsView: View {
@EnvironmentObject var backupManager: BackupManager
@EnvironmentObject var navModel: NavigationViewModel
@State private var selectedBackupUrl: URL?
@State private var showRestoreAlert = false
@State private var showRestoreCompletedAlert = false
var body: some View {
ZStack {
if backupManager.backupUrls.isEmpty {
EmptyInstructionView(title: "No Backups", message: "Create one using the + button in the top-right")
} else {
List {
ForEach(backupManager.backupUrls, id: \.self) { url in
Button(url.lastPathComponent) {
backupManager.selectedBackupUrl = url
backupManager.showRestoreAlert.toggle()
}
.contextMenu {
Button {
navModel.activityItems = [url]
navModel.currentChoiceSheet = .activity
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
}
.backport.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let url = backupManager.backupUrls[safe: index] {
backupManager.removeBackup(backupUrl: url, index: index)
}
}
}
}
.inlinedList()
.listStyle(.insetGrouped)
}
}
.onAppear {
backupManager.backupUrls = FileManager.default.appDirectory
.appendingPathComponent("Backups", isDirectory: true).contentsByDateAdded
}
.navigationTitle("Backups")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
backupManager.createBackup()
} label: {
Image(systemName: "plus")
}
}
}
}
}
struct BackupsView_Previews: PreviewProvider {
static var previews: some View {
BackupsView()
}
}

View file

@ -25,10 +25,11 @@ struct MagnetActionPickerView: View {
}
}
}
.dynamicAccentColor(.primary)
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.inlinedList()
.navigationTitle("Default magnet action")
.navigationBarTitleDisplayMode(.inline)
}
@ -63,10 +64,11 @@ struct DebridActionPickerView: View {
}
}
}
.dynamicAccentColor(.primary)
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.inlinedList()
.navigationTitle("Default debrid action")
.navigationBarTitleDisplayMode(.inline)
}

View file

@ -13,12 +13,21 @@ struct SettingsAppVersionView: View {
@State private var viewTask: Task<Void, Never>?
@State private var releases: [GithubRelease] = []
@State private var loadedReleases = false
var body: some View {
List {
Section(header: Text("GitHub links")) {
ForEach(releases, id: \.self) { release in
ListRowLinkView(text: release.tagName, link: release.htmlUrl)
ZStack {
if !loadedReleases {
ProgressView()
} else if !releases.isEmpty {
List {
Section(header: InlineHeader("GitHub links")) {
ForEach(releases, id: \.self) { release in
ListRowLinkView(text: release.tagName, link: release.htmlUrl)
}
}
}
.listStyle(.insetGrouped)
}
}
.onAppear {
@ -32,13 +41,16 @@ struct SettingsAppVersionView: View {
} catch {
toastModel.updateToastDescription("Github error: \(error)")
}
withAnimation {
loadedReleases = true
}
}
}
.onDisappear {
viewTask?.cancel()
}
.listStyle(.insetGrouped)
.navigationTitle("Version history")
.navigationTitle("Version History")
.navigationBarTitleDisplayMode(.inline)
}
}

View file

@ -21,39 +21,61 @@ struct SettingsSourceListView: View {
@State private var selectedSourceList: SourceList?
var body: some View {
List {
Section(header: Text("List information")) {
ForEach(sourceLists, id: \.self) { sourceList in
VStack(alignment: .leading, spacing: 5) {
Text(sourceList.name)
ZStack {
if sourceLists.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
VStack(alignment: .leading, spacing: 5) {
Text(sourceList.name)
Text(sourceList.author)
.foregroundColor(.gray)
Text(sourceList.author)
.foregroundColor(.gray)
Text("ID: \(sourceList.id)")
.font(.caption)
.foregroundColor(.gray)
}
.contextMenu {
Button {
navModel.selectedSourceList = sourceList
presentSourceSheet.toggle()
} label: {
Text("Edit")
Image(systemName: "pencil")
Text("ID: \(sourceList.id)")
.font(.caption)
.foregroundColor(.gray)
}
.padding(.vertical, 2)
.contextMenu {
Button {
navModel.selectedSourceList = sourceList
presentSourceSheet.toggle()
} label: {
Text("Edit")
Image(systemName: "pencil")
}
Button {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(sourceList, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
}
}
}
.onDelete { offsets in
for index in offsets {
if let list = sourceLists[safe: index] {
PersistenceController.shared.delete(list, context: backgroundContext)
}
}
}
}
.listStyle(.insetGrouped)
.inlinedList()
}
}
.listStyle(.insetGrouped)
.sheet(isPresented: $presentSourceSheet) {
if #available(iOS 16, *) {
SourceListEditorView()
@ -62,7 +84,7 @@ struct SettingsSourceListView: View {
SourceListEditorView()
}
}
.navigationTitle("Source lists")
.navigationTitle("Source Lists")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {

View file

@ -15,28 +15,29 @@ struct SourceListEditorView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@State private var sourceUrl = ""
@State private var sourceUrlSet = false
@State private var sourceUrl: String = ""
var body: some View {
NavView {
Form {
Section {
TextField("Enter URL", text: $sourceUrl)
.disableAutocorrection(true)
.keyboardType(.URL)
.autocapitalization(.none)
}
TextField("Enter URL", text: $sourceUrl)
.disableAutocorrection(true)
.keyboardType(.URL)
.autocapitalization(.none)
.conditionalId(sourceUrlSet)
}
.onAppear {
sourceUrl = navModel.selectedSourceList?.urlString ?? ""
sourceUrlSet = true
}
.alert(isPresented: $sourceManager.showUrlErrorAlert) {
Alert(
title: Text("Error"),
message: Text(sourceManager.urlErrorAlertText),
dismissButton: .default(Text("OK"))
)
}
.backport.alert(
isPresented: $sourceManager.showUrlErrorAlert,
title: "Error",
message: sourceManager.urlErrorAlertText,
buttons: [AlertButton("OK")]
)
.navigationTitle("Editing source list")
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View file

@ -1,5 +1,5 @@
//
// InstalledSourceView.swift
// InstalledSourceButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 8/5/22.
@ -7,7 +7,7 @@
import SwiftUI
struct InstalledSourceView: View {
struct InstalledSourceButtonView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@ -32,6 +32,7 @@ struct InstalledSourceView: View {
Text("by \(installedSource.author)")
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
.contextMenu {
Button {
@ -42,11 +43,20 @@ struct InstalledSourceView: View {
Image(systemName: "gear")
}
Button {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
if #available(iOS 15.0, *) {
Button(role: .destructive) {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(installedSource, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
}
}
}

View file

@ -33,5 +33,6 @@ struct SourceCatalogButtonView: View {
}
}
}
.padding(.vertical, 2)
}
}

View file

@ -24,6 +24,7 @@ struct SourceUpdateButtonView: View {
Text("by \(updatedSource.author ?? "Unknown")")
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
Spacer()

View file

@ -16,7 +16,7 @@ struct SourceSettingsView: View {
NavView {
List {
if let selectedSource = navModel.selectedSource {
Section(header: "Info") {
Section(header: InlineHeader("Info")) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(selectedSource.name)
@ -40,6 +40,7 @@ struct SourceSettingsView: View {
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 2)
}
if selectedSource.dynamicBaseUrl {
@ -77,7 +78,7 @@ struct SourceSettingsBaseUrlView: View {
@State private var tempBaseUrl: String = ""
var body: some View {
Section(
header: Text("Base URL"),
header: InlineHeader("Base URL"),
footer: Text("Enter the base URL of your server.")
) {
TextField("https://...", text: $tempBaseUrl, onEditingChanged: { isFocused in
@ -109,7 +110,7 @@ struct SourceSettingsApiView: View {
var body: some View {
Section(
header: Text("API credentials"),
header: InlineHeader("API credentials"),
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.")
) {
if let clientId = selectedSourceApi.clientId, clientId.dynamic {
@ -145,8 +146,8 @@ struct SourceSettingsMethodView: View {
@ObservedObject var selectedSource: Source
var body: some View {
Section(header: Text("Fetch method")) {
if selectedSource.api != nil, selectedSource.jsonParser != nil {
Section(header: InlineHeader("Fetch method")) {
if selectedSource.jsonParser != nil {
Button {
selectedSource.preferredParser = SourcePreferredParser.siteApi.rawValue
} label: {
@ -191,6 +192,6 @@ struct SourceSettingsMethodView: View {
}
}
}
.dynamicAccentColor(.primary)
.backport.tint(.primary)
}
}

View file

@ -5,7 +5,9 @@
// Created by Brian Dashore on 7/24/22.
//
import Introspect
import SwiftUI
import SwiftUIX
struct SourcesView: View {
@EnvironmentObject var sourceManager: SourceManager
@ -18,83 +20,112 @@ struct SourcesView: View {
sortDescriptors: []
) var sources: FetchedResults<Source>
private var updatedSources: [SourceJson] {
var tempSources: [SourceJson] = []
for source in sources {
guard let availableSource = sourceManager.availableSources.first(where: {
source.listId == $0.listId && source.name == $0.name && source.author == $0.author
}) else {
continue
}
if availableSource.version > source.version {
tempSources.append(availableSource)
}
}
return tempSources
}
@State private var checkedForSources = false
@State private var isEditing = 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 {
List {
if !updatedSources.isEmpty {
Section(header: "Updates") {
ForEach(updatedSources, id: \.self) { source in
SourceUpdateButtonView(updatedSource: source)
DynamicFetchRequest(predicate: sourcePredicate) { (installedSources: FetchedResults<Source>) in
ZStack {
if !checkedForSources {
ProgressView()
} else if sources.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)
}
}
if !sources.isEmpty {
Section(header: "Installed") {
ForEach(sources, id: \.self) { source in
InstalledSourceView(installedSource: source)
.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
}
}
if sourceManager.availableSources.contains(where: { availableSource in
!sources.contains(
where: {
.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
}
)
}) {
Section(header: "Catalog") {
ForEach(sourceManager.availableSources, id: \.self) { availableSource in
if !sources.contains(
where: {
availableSource.name == $0.name &&
availableSource.listId == $0.listId &&
availableSource.author == $0.author
}
) {
SourceCatalogButtonView(availableSource: availableSource)
}
})
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: $isEditing)
.showsCancelButton(isEditing)
.onCancel {
searchText = ""
}
}
}
.listStyle(.insetGrouped)
.sheet(isPresented: $navModel.showSourceSettings) {
SourceSettingsView()
.environmentObject(navModel)
}
.onAppear {
viewTask = Task {
await sourceManager.fetchSourcesFromUrl()
}
}
.onDisappear {
viewTask?.cancel()
}
.navigationTitle("Sources")
}
}
}