Compare commits
68 commits
debrid-rew
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4184cf1b9 | ||
|
|
cfc4a74afe | ||
|
|
7bb4ed5f7c | ||
|
|
f40f71bca3 | ||
|
|
68a7c60c2d | ||
|
|
8b00d11e44 | ||
|
|
9d7bc9b314 | ||
|
|
25bff02875 | ||
|
|
20dd00fa85 | ||
|
|
f9d2f38329 | ||
|
|
a7e20f30e6 | ||
|
|
ecf92239d2 | ||
|
|
dd54ec027b | ||
|
|
84357ea2c5 | ||
|
|
4fb5f77718 | ||
|
|
e5a872e09f | ||
|
|
1d6ac13e84 | ||
|
|
896efed663 | ||
|
|
a3463948ea | ||
|
|
215cd0feec | ||
|
|
9213b8627b | ||
|
|
6b40bb3ea2 | ||
|
|
dbf12c0a79 | ||
|
|
70b628b608 | ||
|
|
78f2aff25b | ||
|
|
489da8e82e | ||
|
|
078e48d316 | ||
|
|
646c22c9be | ||
|
|
d512d8b88d | ||
|
|
d0728e1a9b | ||
|
|
89367b72da | ||
|
|
c5a08cc725 | ||
|
|
0d39fd481a | ||
|
|
5223c60acd | ||
|
|
80e966512a | ||
|
|
8f7fe94d21 | ||
|
|
3ef041f889 | ||
|
|
e49e37af36 | ||
|
|
d6d731102c | ||
|
|
4beb953596 | ||
|
|
e1eca593f3 | ||
|
|
9b4f31daac | ||
|
|
24e39f9fba | ||
|
|
904b5a74b5 | ||
|
|
ecdd0199f6 | ||
|
|
3b771e5deb | ||
|
|
d8107cb5b6 | ||
|
|
42e202b207 | ||
|
|
afceea7bfb | ||
|
|
4ae1966934 | ||
|
|
796cc65016 | ||
|
|
90f44348b8 | ||
|
|
6192ef1ede | ||
|
|
973fbb4099 | ||
|
|
243a16e3c4 | ||
|
|
44a90b77eb | ||
|
|
59ac719d9a | ||
|
|
02636e0bda | ||
|
|
40b323bd56 | ||
|
|
91f124130c | ||
|
|
ec8455c08d | ||
|
|
0c3648120d | ||
|
|
9650e6deec | ||
|
|
07731e7b00 | ||
|
|
b80f8900b7 | ||
|
|
cf0c5a30f7 | ||
|
|
96a6722e65 | ||
|
|
0caf8a8120 |
|
|
@ -12,6 +12,9 @@
|
|||
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
|
||||
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
|
||||
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
|
||||
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; };
|
||||
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; };
|
||||
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; };
|
||||
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
|
||||
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
|
||||
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
|
||||
|
|
@ -38,7 +41,6 @@
|
|||
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
|
||||
0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; };
|
||||
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.swift */; };
|
||||
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 0C448BE829A135F100F4E266 /* Introspect-Static */; };
|
||||
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; };
|
||||
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; };
|
||||
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; };
|
||||
|
|
@ -66,7 +68,6 @@
|
|||
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
|
||||
0C7075E429D374C50093DB2D /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E329D374C50093DB2D /* Color.swift */; };
|
||||
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E529D3845D0093DB2D /* ShareSheet.swift */; };
|
||||
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; };
|
||||
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; };
|
||||
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
|
||||
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 0C748ED929D9256D0049B8BE /* Yams */; };
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; };
|
||||
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; };
|
||||
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; };
|
||||
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */; };
|
||||
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; };
|
||||
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; };
|
||||
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; };
|
||||
|
|
@ -94,11 +96,14 @@
|
|||
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
|
||||
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; };
|
||||
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; };
|
||||
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */; };
|
||||
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */; };
|
||||
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; };
|
||||
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
|
||||
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
|
||||
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
|
||||
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */; };
|
||||
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */; };
|
||||
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */; };
|
||||
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; };
|
||||
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; };
|
||||
|
|
@ -106,7 +111,6 @@
|
|||
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
|
||||
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
|
||||
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */; };
|
||||
0CA148DB288903F000DE2211 /* NavView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C1288903F000DE2211 /* NavView.swift */; };
|
||||
0CA148DC288903F000DE2211 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C2288903F000DE2211 /* Assets.xcassets */; };
|
||||
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; };
|
||||
0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; };
|
||||
|
|
@ -130,11 +134,9 @@
|
|||
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
|
||||
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */; };
|
||||
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */; };
|
||||
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
|
||||
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
|
||||
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; };
|
||||
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */; };
|
||||
0CB725342C123E760047FC0B /* CloudTorrentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudTorrentView.swift */; };
|
||||
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.swift */; };
|
||||
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; };
|
||||
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
|
||||
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
|
||||
|
|
@ -154,6 +156,8 @@
|
|||
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
|
||||
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; };
|
||||
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; };
|
||||
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */; };
|
||||
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */; };
|
||||
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; };
|
||||
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; };
|
||||
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
|
||||
|
|
@ -165,6 +169,9 @@
|
|||
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
|
||||
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
|
||||
0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = "<group>"; };
|
||||
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = "<group>"; };
|
||||
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = "<group>"; };
|
||||
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = "<group>"; };
|
||||
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
|
||||
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -216,7 +223,6 @@
|
|||
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = "<group>"; };
|
||||
0C7075E329D374C50093DB2D /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
|
||||
0C7075E529D3845D0093DB2D /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
|
||||
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = "<group>"; };
|
||||
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = "<group>"; };
|
||||
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
|
||||
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -242,11 +248,14 @@
|
|||
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = "<group>"; };
|
||||
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAmountLabelView.swift; sourceTree = "<group>"; };
|
||||
0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = "<group>"; };
|
||||
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxWrapper.swift; sourceTree = "<group>"; };
|
||||
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxModels.swift; sourceTree = "<group>"; };
|
||||
0C8AE2472C0FFB6600701675 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
||||
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoView.swift; sourceTree = "<group>"; };
|
||||
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsBaseUrlView.swift; sourceTree = "<group>"; };
|
||||
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsApiView.swift; sourceTree = "<group>"; };
|
||||
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsMethodView.swift; sourceTree = "<group>"; };
|
||||
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDebridLinkView.swift; sourceTree = "<group>"; };
|
||||
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionPickerView.swift; sourceTree = "<group>"; };
|
||||
0CA05456288EE58200850554 /* SettingsPluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPluginListView.swift; sourceTree = "<group>"; };
|
||||
0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -254,7 +263,6 @@
|
|||
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; };
|
||||
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionChoiceView.swift; sourceTree = "<group>"; };
|
||||
0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = "<group>"; };
|
||||
0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = "<group>"; };
|
||||
0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
|
|
@ -278,11 +286,9 @@
|
|||
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
|
||||
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDownloadView.swift; sourceTree = "<group>"; };
|
||||
0CB725332C123E760047FC0B /* CloudTorrentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTorrentView.swift; sourceTree = "<group>"; };
|
||||
0CB725332C123E760047FC0B /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.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>"; };
|
||||
|
|
@ -302,6 +308,8 @@
|
|||
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
|
||||
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = "<group>"; };
|
||||
0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = "<group>"; };
|
||||
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debrid.swift; sourceTree = "<group>"; };
|
||||
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridModels.swift; sourceTree = "<group>"; };
|
||||
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -313,13 +321,13 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */,
|
||||
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */,
|
||||
0C64A4B4288903680079976D /* Base32 in Frameworks */,
|
||||
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
|
||||
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
|
||||
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
|
||||
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */,
|
||||
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
|
||||
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -383,6 +391,8 @@
|
|||
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
|
||||
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
|
||||
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
|
||||
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */,
|
||||
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */,
|
||||
);
|
||||
path = Classes;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -404,6 +414,8 @@
|
|||
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
|
||||
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
|
||||
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
|
||||
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
|
||||
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -412,7 +424,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
|
||||
0CB725332C123E760047FC0B /* CloudTorrentView.swift */,
|
||||
0CB725332C123E760047FC0B /* CloudMagnetView.swift */,
|
||||
);
|
||||
path = Cloud;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -457,6 +469,7 @@
|
|||
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
|
||||
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */,
|
||||
0C8AE2472C0FFB6600701675 /* Store.swift */,
|
||||
0C07C6032C1A859B00808A46 /* FormDataBody.swift */,
|
||||
);
|
||||
path = Utils;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -464,8 +477,6 @@
|
|||
0C44E2A928D4DFC4007711AE /* Modifiers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */,
|
||||
0CB6516228C5A57300DCA721 /* ConditionalId.swift */,
|
||||
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
|
||||
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
|
||||
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
|
||||
|
|
@ -536,6 +547,7 @@
|
|||
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
|
||||
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
|
||||
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
|
||||
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -565,9 +577,7 @@
|
|||
children = (
|
||||
0C44E2A928D4DFC4007711AE /* Modifiers */,
|
||||
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
|
||||
0CA148C1288903F000DE2211 /* NavView.swift */,
|
||||
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
|
||||
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
|
||||
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
|
||||
0C2D9652299316CC00A504B6 /* Tag.swift */,
|
||||
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */,
|
||||
|
|
@ -655,6 +665,8 @@
|
|||
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
|
||||
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
|
||||
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
|
||||
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
|
||||
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -732,8 +744,8 @@
|
|||
0C4CFC452897030D00AD9FAD /* Regex */,
|
||||
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
|
||||
0CDDDE042935235E006810B1 /* BetterSafariView */,
|
||||
0C448BE829A135F100F4E266 /* Introspect-Static */,
|
||||
0C748ED929D9256D0049B8BE /* Yams */,
|
||||
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */,
|
||||
);
|
||||
productName = Torrenter;
|
||||
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
|
||||
|
|
@ -747,7 +759,7 @@
|
|||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1400;
|
||||
LastUpgradeCheck = 1400;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
0CAF1C67286F5C0E00296F86 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
|
|
@ -770,8 +782,8 @@
|
|||
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
|
||||
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
|
||||
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
|
||||
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
|
||||
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */,
|
||||
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
|
||||
);
|
||||
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
|
||||
projectDirPath = "";
|
||||
|
|
@ -823,6 +835,7 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */,
|
||||
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
|
||||
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
|
||||
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
|
||||
|
|
@ -830,13 +843,11 @@
|
|||
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */,
|
||||
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */,
|
||||
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
|
||||
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */,
|
||||
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */,
|
||||
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
|
||||
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */,
|
||||
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */,
|
||||
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */,
|
||||
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
|
||||
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
|
||||
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */,
|
||||
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
|
||||
|
|
@ -863,7 +874,6 @@
|
|||
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
|
||||
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */,
|
||||
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
|
||||
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
|
||||
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
|
||||
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */,
|
||||
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
|
||||
|
|
@ -874,7 +884,7 @@
|
|||
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */,
|
||||
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
|
||||
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
|
||||
0CB725342C123E760047FC0B /* CloudTorrentView.swift in Sources */,
|
||||
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */,
|
||||
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
|
||||
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
|
||||
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
|
||||
|
|
@ -890,7 +900,6 @@
|
|||
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
|
||||
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
|
||||
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
|
||||
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
|
||||
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */,
|
||||
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
|
||||
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
|
||||
|
|
@ -921,17 +930,22 @@
|
|||
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */,
|
||||
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
|
||||
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
|
||||
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */,
|
||||
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
|
||||
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
|
||||
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
|
||||
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
|
||||
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */,
|
||||
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
|
||||
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */,
|
||||
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */,
|
||||
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */,
|
||||
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */,
|
||||
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */,
|
||||
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
|
||||
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
|
||||
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
|
||||
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */,
|
||||
0C7075E429D374C50093DB2D /* Color.swift in Sources */,
|
||||
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
|
||||
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
|
||||
|
|
@ -948,11 +962,13 @@
|
|||
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
|
||||
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
|
||||
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
|
||||
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */,
|
||||
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */,
|
||||
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
|
||||
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
|
||||
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
|
||||
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
|
||||
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */,
|
||||
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
|
||||
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */,
|
||||
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
|
||||
|
|
@ -972,6 +988,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
|
|
@ -1004,6 +1021,7 @@
|
|||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
|
|
@ -1025,6 +1043,7 @@
|
|||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
|
@ -1032,6 +1051,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||
|
|
@ -1064,6 +1084,7 @@
|
|||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
|
|
@ -1078,6 +1099,7 @@
|
|||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
|
|
@ -1088,10 +1110,11 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8A74DBQ6S3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Ferrite/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
|
||||
|
|
@ -1102,12 +1125,12 @@
|
|||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.0;
|
||||
MARKETING_VERSION = 0.7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -1123,10 +1146,11 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8A74DBQ6S3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Ferrite/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
|
||||
|
|
@ -1137,12 +1161,12 @@
|
|||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.7.0;
|
||||
MARKETING_VERSION = 0.7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -1176,14 +1200,6 @@
|
|||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect/";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.2.3;
|
||||
};
|
||||
};
|
||||
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/Regex";
|
||||
|
|
@ -1224,6 +1240,14 @@
|
|||
kind = branch;
|
||||
};
|
||||
};
|
||||
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/siteline/swiftui-introspect";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.3.0;
|
||||
};
|
||||
};
|
||||
0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
||||
|
|
@ -1243,11 +1267,6 @@
|
|||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
0C448BE829A135F100F4E266 /* Introspect-Static */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
|
||||
productName = "Introspect-Static";
|
||||
};
|
||||
0C4CFC452897030D00AD9FAD /* Regex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */;
|
||||
|
|
@ -1273,6 +1292,11 @@
|
|||
package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
|
||||
productName = SwiftyJSON;
|
||||
};
|
||||
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
|
||||
productName = SwiftUIIntrospect;
|
||||
};
|
||||
0CAF1C7A286F5C8600296F86 /* SwiftSoup */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
|||
|
|
@ -7,19 +7,26 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
// TODO: Fix errors
|
||||
public class AllDebrid: PollingDebridSource, ObservableObject {
|
||||
public let id = "AllDebrid"
|
||||
public let abbreviation = "AD"
|
||||
public let website = "https://alldebrid.com"
|
||||
public var authTask: Task<Void, Error>?
|
||||
class AllDebrid: PollingDebridSource, ObservableObject {
|
||||
let id = "AllDebrid"
|
||||
let abbreviation = "AD"
|
||||
let website = "https://alldebrid.com"
|
||||
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " +
|
||||
"You must pay to access this service. \n\n" +
|
||||
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||
"This service does not inform if a magnet link is a batch before downloading."
|
||||
|
||||
public var authProcessing: Bool = false
|
||||
public var isLoggedIn: Bool {
|
||||
let cachedStatus: [String] = ["Ready"]
|
||||
var authTask: Task<Void, Error>?
|
||||
|
||||
@Published var authProcessing: Bool = false
|
||||
var isLoggedIn: Bool {
|
||||
getToken() != nil
|
||||
}
|
||||
|
||||
public var manualToken: String? {
|
||||
var manualToken: String? {
|
||||
if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") {
|
||||
return getToken()
|
||||
} else {
|
||||
|
|
@ -27,20 +34,28 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@Published public var IAValues: [DebridIA] = []
|
||||
@Published public var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published public var cloudTorrents: [DebridCloudTorrent] = []
|
||||
public var cloudTTL: Double = 0.0
|
||||
@Published var IAValues: [DebridIA] = []
|
||||
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||
var cloudTTL: Double = 0.0
|
||||
|
||||
let baseApiUrl = "https://api.alldebrid.com/v4"
|
||||
let appName = "Ferrite"
|
||||
private let baseApiUrl = "https://api.alldebrid.com/v4"
|
||||
private let appName = "Ferrite"
|
||||
|
||||
let jsonDecoder = JSONDecoder()
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
|
||||
init() {
|
||||
// Populate user downloads and magnets
|
||||
Task {
|
||||
try? await getUserDownloads()
|
||||
try? await getUserMagnets()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
// Fetches information for PIN auth
|
||||
public func getAuthUrl() async throws -> URL {
|
||||
func getAuthUrl() async throws -> URL {
|
||||
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
|
|
@ -66,14 +81,14 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Fetches API keys
|
||||
public func getApiKey(checkID: String, pin: String) async throws {
|
||||
func getApiKey(checkID: String, pin: String) async throws {
|
||||
let queryItems = [
|
||||
URLQueryItem(name: "agent", value: appName),
|
||||
URLQueryItem(name: "check", value: checkID),
|
||||
URLQueryItem(name: "pin", value: pin)
|
||||
]
|
||||
|
||||
let request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
|
||||
let request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
|
||||
|
||||
// Timer to poll AD API for key
|
||||
authTask = Task {
|
||||
|
|
@ -109,17 +124,17 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Adds a manual API key instead of web auth
|
||||
public func setApiKey(_ key: String) {
|
||||
func setApiKey(_ key: String) {
|
||||
FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
|
||||
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
|
||||
}
|
||||
|
||||
public func getToken() -> String? {
|
||||
func getToken() -> String? {
|
||||
FerriteKeychain.shared.get("AllDebrid.ApiKey")
|
||||
}
|
||||
|
||||
// Clears tokens. No endpoint to deregister a device
|
||||
public func logout() {
|
||||
func logout() {
|
||||
FerriteKeychain.shared.delete("AllDebrid.ApiKey")
|
||||
UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey")
|
||||
}
|
||||
|
|
@ -150,7 +165,7 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Builds a URL for further requests
|
||||
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||
guard var components = URLComponents(string: urlString) else {
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
|
|
@ -168,7 +183,7 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
|
||||
// MARK: - Instant availability
|
||||
|
||||
public func instantAvailability(magnets: [Magnet]) async throws {
|
||||
func instantAvailability(magnets: [Magnet]) async throws {
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
let sendMagnets = magnets.filter { magnet in
|
||||
|
|
@ -184,65 +199,82 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
if sendMagnets.isEmpty {
|
||||
return
|
||||
}
|
||||
// Fetch the user magnets to the latest version
|
||||
try await getUserMagnets()
|
||||
|
||||
let queryItems = sendMagnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) }
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(ADResponse<InstantAvailabilityResponse>.self, from: data).data
|
||||
|
||||
let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil }
|
||||
let availableHashes = filteredMagnets.map { magnetResp in
|
||||
// Force unwrap is OK here since the filter caught any nil values
|
||||
let files = magnetResp.files!.enumerated().map { index, magnetFile in
|
||||
DebridIAFile(fileId: index, name: magnetFile.name)
|
||||
for cloudMagnet in cloudMagnets {
|
||||
if cachedStatus.contains(cloudMagnet.status),
|
||||
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||
{
|
||||
IAValues.append(
|
||||
DebridIA(
|
||||
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return DebridIA(
|
||||
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
|
||||
source: self.id,
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: files
|
||||
)
|
||||
}
|
||||
|
||||
IAValues += availableHashes
|
||||
}
|
||||
|
||||
// MARK: - Downloading
|
||||
|
||||
// Wrapper function to fetch a download link from the API
|
||||
public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String {
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||
let selectedMagnetId: String
|
||||
|
||||
if let existingMagnet = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "Ready" }) {
|
||||
selectedMagnetId = existingMagnet.torrentId
|
||||
if let existingMagnet = cloudMagnets.first(where: {
|
||||
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||
}) {
|
||||
selectedMagnetId = existingMagnet.id
|
||||
} else {
|
||||
let magnetId = try await addMagnet(magnet: magnet)
|
||||
selectedMagnetId = String(magnetId)
|
||||
}
|
||||
|
||||
let lockedLink = try await fetchMagnetStatus(
|
||||
let rawResponse = try await fetchMagnetStatus(
|
||||
magnetId: selectedMagnetId,
|
||||
selectedIndex: iaFile?.fileId ?? 0
|
||||
selectedIndex: iaFile?.id ?? 0
|
||||
)
|
||||
guard let magnets = rawResponse.magnets[safe: 0] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
try await saveLink(link: lockedLink)
|
||||
let downloadUrl = try await unlockLink(lockedLink: lockedLink)
|
||||
// Batches require an unrestrict from the user
|
||||
if magnets.links.count > 1, iaFile == nil {
|
||||
var copiedIA = ia
|
||||
|
||||
return downloadUrl
|
||||
copiedIA?.files = magnets.links.enumerated().compactMap { index, file in
|
||||
DebridIAFile(
|
||||
id: index,
|
||||
name: file.filename,
|
||||
streamUrlString: file.link
|
||||
)
|
||||
}
|
||||
|
||||
return (nil, copiedIA)
|
||||
}
|
||||
|
||||
if let cloudMagnetFile = magnets.links[safe: iaFile?.id ?? 0] {
|
||||
let restrictedFile = DebridIAFile(
|
||||
id: 0,
|
||||
name: cloudMagnetFile.filename,
|
||||
streamUrlString: cloudMagnetFile.link
|
||||
)
|
||||
|
||||
return (restrictedFile, nil)
|
||||
} else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a magnet link to the user's AD account
|
||||
public func addMagnet(magnet: Magnet) async throws -> Int {
|
||||
func addMagnet(magnet: Magnet) async throws -> Int {
|
||||
guard let magnetLink = magnet.link else {
|
||||
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
|
|
@ -257,67 +289,61 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
|
||||
|
||||
if let magnet = rawResponse.magnets[safe: 0] {
|
||||
if !magnet.ready {
|
||||
throw DebridError.IsCaching
|
||||
}
|
||||
|
||||
return magnet.id
|
||||
} else {
|
||||
throw DebridError.InvalidResponse
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> String {
|
||||
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
|
||||
let queryItems = [
|
||||
URLQueryItem(name: "id", value: magnetId)
|
||||
]
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
||||
|
||||
// Better to fetch no link at all than the wrong link
|
||||
if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] {
|
||||
return linkWrapper.link
|
||||
} else {
|
||||
throw DebridError.EmptyTorrents
|
||||
}
|
||||
return rawResponse
|
||||
}
|
||||
|
||||
public func unlockLink(lockedLink: String) async throws -> String {
|
||||
// Known as unlockLink in AD's API
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||
let queryItems = [
|
||||
URLQueryItem(name: "link", value: lockedLink)
|
||||
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
|
||||
]
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let data = try await performRequest(request: &request, requestName: "unlockLink")
|
||||
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
|
||||
|
||||
return rawResponse.link
|
||||
}
|
||||
|
||||
public func saveLink(link: String) async throws {
|
||||
func saveLink(link: String) async throws {
|
||||
let queryItems = [
|
||||
URLQueryItem(name: "links[]", value: link)
|
||||
]
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
|
||||
|
||||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
|
||||
// MARK: - Cloud methods
|
||||
|
||||
// Referred to as "User magnets" in AllDebrid's API
|
||||
public func getUserTorrents() async throws {
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
|
||||
func getUserMagnets() async throws {
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
||||
|
||||
if rawResponse.magnets.isEmpty {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
cloudTorrents = rawResponse.magnets.map { magnetResponse in
|
||||
DebridCloudTorrent(
|
||||
torrentId: String(magnetResponse.id),
|
||||
source: self.id,
|
||||
cloudMagnets = rawResponse.magnets.map { magnetResponse in
|
||||
DebridCloudMagnet(
|
||||
id: String(magnetResponse.id),
|
||||
fileName: magnetResponse.filename,
|
||||
status: magnetResponse.status,
|
||||
hash: magnetResponse.hash,
|
||||
|
|
@ -326,48 +352,44 @@ public class AllDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func deleteTorrent(torrentId: String?) async throws {
|
||||
guard let torrentId else {
|
||||
throw DebridError.FailedRequest(description: "The torrentID \(String(describing: torrentId)) is invalid")
|
||||
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||
guard let cloudMagnetId else {
|
||||
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid")
|
||||
}
|
||||
|
||||
let queryItems = [
|
||||
URLQueryItem(name: "id", value: torrentId)
|
||||
URLQueryItem(name: "id", value: cloudMagnetId)
|
||||
]
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
|
||||
|
||||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
|
||||
public func getUserDownloads() async throws {
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
|
||||
func getUserDownloads() async throws {
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
|
||||
|
||||
if rawResponse.links.isEmpty {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
// The link is also the ID
|
||||
cloudDownloads = rawResponse.links.map { link in
|
||||
DebridCloudDownload(
|
||||
downloadId: link.link, source: self.id, fileName: link.filename, link: link.link
|
||||
id: link.link, fileName: link.filename, link: link.link
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Not used
|
||||
public func checkUserDownloads(link: String) async throws -> String? {
|
||||
nil
|
||||
func checkUserDownloads(link: String) -> String? {
|
||||
link
|
||||
}
|
||||
|
||||
// The downloadId is actually the download link
|
||||
public func deleteDownload(downloadId: String) async throws {
|
||||
func deleteUserDownload(downloadId: String) async throws {
|
||||
let queryItems = [
|
||||
URLQueryItem(name: "link", value: downloadId)
|
||||
]
|
||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
|
||||
|
||||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Github {
|
||||
public func fetchLatestRelease() async throws -> Release? {
|
||||
class Github {
|
||||
func fetchLatestRelease() async throws -> Release? {
|
||||
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")!
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
|
|
@ -17,7 +17,7 @@ public class Github {
|
|||
return rawResponse
|
||||
}
|
||||
|
||||
public func fetchReleases() async throws -> [Release]? {
|
||||
func fetchReleases() async throws -> [Release]? {
|
||||
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")!
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Kodi {
|
||||
let encoder = JSONEncoder()
|
||||
class Kodi {
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
// Used to add server to CoreData. Not part of API
|
||||
public func addServer(urlString: String,
|
||||
friendlyName: String?,
|
||||
username: String?,
|
||||
password: String?,
|
||||
existingServer: KodiServer? = nil) throws
|
||||
func addServer(urlString: String,
|
||||
friendlyName: String?,
|
||||
username: String?,
|
||||
password: String?,
|
||||
existingServer: KodiServer? = nil) throws
|
||||
{
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ public class Kodi {
|
|||
try backgroundContext.save()
|
||||
}
|
||||
|
||||
public func ping(server: KodiServer) async throws {
|
||||
func ping(server: KodiServer) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
|
@ -94,7 +94,7 @@ public class Kodi {
|
|||
}
|
||||
}
|
||||
|
||||
public func sendVideoUrl(urlString: String, server: KodiServer) async throws {
|
||||
func sendVideoUrl(urlString: String, server: KodiServer) async throws {
|
||||
if URL(string: urlString) == nil {
|
||||
throw KodiError.InvalidPlaybackUrl
|
||||
}
|
||||
|
|
|
|||
277
Ferrite/API/OffCloudWrapper.swift
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//
|
||||
// OffCloudWrapper.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/12/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class OffCloud: DebridSource, ObservableObject {
|
||||
let id = "OffCloud"
|
||||
let abbreviation = "OC"
|
||||
let website = "https://offcloud.com"
|
||||
let description: String? = "OffCloud is a debrid service that is used for downloads and media playback. " +
|
||||
"You must pay to access this service. \n\n" +
|
||||
"This service does not inform if a magnet link is a batch before downloading."
|
||||
let cachedStatus: [String] = ["downloaded"]
|
||||
|
||||
@Published var authProcessing: Bool = false
|
||||
var isLoggedIn: Bool {
|
||||
getToken() != nil
|
||||
}
|
||||
|
||||
var manualToken: String? {
|
||||
if UserDefaults.standard.bool(forKey: "OffCloud.UseManualKey") {
|
||||
return getToken()
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Published var IAValues: [DebridIA] = []
|
||||
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||
var cloudTTL: Double = 0.0
|
||||
|
||||
private let baseApiUrl = "https://offcloud.com/api"
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
private let jsonEncoder = JSONEncoder()
|
||||
|
||||
init() {
|
||||
// Populate user downloads and magnets
|
||||
Task {
|
||||
try? await getUserMagnets()
|
||||
}
|
||||
}
|
||||
|
||||
func setApiKey(_ key: String) {
|
||||
FerriteKeychain.shared.set(key, forKey: "OffCloud.ApiKey")
|
||||
UserDefaults.standard.set(true, forKey: "OffCloud.UseManualKey")
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
FerriteKeychain.shared.delete("OffCloud.ApiKey")
|
||||
UserDefaults.standard.removeObject(forKey: "OffCloud.UseManualKey")
|
||||
}
|
||||
|
||||
private func getToken() -> String? {
|
||||
FerriteKeychain.shared.get("OffCloud.ApiKey")
|
||||
}
|
||||
|
||||
// Wrapper request function which matches the responses and returns data
|
||||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||
}
|
||||
|
||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||
return data
|
||||
} else if response.statusCode == 401 {
|
||||
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.")
|
||||
} else {
|
||||
print(response)
|
||||
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a URL for further requests
|
||||
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||
guard var components = URLComponents(string: urlString) else {
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
|
||||
guard let token = getToken() else {
|
||||
throw DebridError.InvalidToken
|
||||
}
|
||||
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "key", value: token)
|
||||
] + queryItems
|
||||
|
||||
if let url = components.url {
|
||||
return url
|
||||
} else {
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
}
|
||||
|
||||
func instantAvailability(magnets: [Magnet]) async throws {
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
let sendMagnets = magnets.filter { magnet in
|
||||
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||
IAValues.remove(at: IAIndex)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if sendMagnets.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cache"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = InstantAvailabilityRequest(hashes: sendMagnets.compactMap(\.hash))
|
||||
request.httpBody = try jsonEncoder.encode(body)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(InstantAvailabilityResponse.self, from: data)
|
||||
|
||||
let availableHashes = rawResponse.cachedItems.map {
|
||||
DebridIA(
|
||||
magnet: Magnet(hash: $0, link: nil),
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: []
|
||||
)
|
||||
}
|
||||
|
||||
IAValues += availableHashes
|
||||
}
|
||||
|
||||
// Cloud in OffCloud's API
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||
let selectedCloudMagnet: DebridCloudMagnet
|
||||
|
||||
// Don't queue a new job if the magnet already exists in the user's account
|
||||
if let existingCloudMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && cachedStatus.contains($0.status) }) {
|
||||
selectedCloudMagnet = existingCloudMagnet
|
||||
} else {
|
||||
let cloudDownloadResponse = try await offcloudDownload(magnet: magnet)
|
||||
|
||||
guard cachedStatus.contains(cloudDownloadResponse.status) else {
|
||||
throw DebridError.IsCaching
|
||||
}
|
||||
|
||||
selectedCloudMagnet = DebridCloudMagnet(
|
||||
id: cloudDownloadResponse.requestId,
|
||||
fileName: cloudDownloadResponse.fileName,
|
||||
status: cloudDownloadResponse.status,
|
||||
hash: "",
|
||||
links: [cloudDownloadResponse.url]
|
||||
)
|
||||
}
|
||||
|
||||
let cloudExploreResponse = try await cloudExplore(requestId: selectedCloudMagnet.id)
|
||||
|
||||
// Request will error if the file isn't a batch
|
||||
if case let .links(cloudExploreLinks) = cloudExploreResponse {
|
||||
var copiedIA = ia
|
||||
|
||||
copiedIA?.files = cloudExploreLinks.enumerated().compactMap { index, exploreLink in
|
||||
guard let exploreURL = URL(string: exploreLink) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DebridIAFile(
|
||||
id: index,
|
||||
name: exploreURL.lastPathComponent,
|
||||
streamUrlString: exploreLink.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
)
|
||||
}
|
||||
|
||||
return (nil, copiedIA)
|
||||
} else if case let .error(cloudExploreError) = cloudExploreResponse,
|
||||
cloudExploreError.error.lowercased() == "bad archive"
|
||||
{
|
||||
guard let selectedCloudLink = selectedCloudMagnet.links[safe: 0] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
let restrictedFile = DebridIAFile(
|
||||
id: 0,
|
||||
name: selectedCloudMagnet.fileName,
|
||||
streamUrlString: "\(selectedCloudLink)/\(selectedCloudMagnet.fileName)"
|
||||
)
|
||||
|
||||
return (restrictedFile, nil)
|
||||
} else {
|
||||
return (nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Called as "cloud" in offcloud's API
|
||||
private func offcloudDownload(magnet: Magnet) async throws -> CloudDownloadResponse {
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
guard let magnetLink = magnet.link else {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
let body = CloudDownloadRequest(url: magnetLink)
|
||||
request.httpBody = try jsonEncoder.encode(body)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: "cloud")
|
||||
let rawResponse = try jsonDecoder.decode(CloudDownloadResponse.self, from: data)
|
||||
|
||||
return rawResponse
|
||||
}
|
||||
|
||||
private func cloudExplore(requestId: String) async throws -> CloudExploreResponse {
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud/explore/\(requestId)"))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: "cloudExplore")
|
||||
let rawResponse = try jsonDecoder.decode(CloudExploreResponse.self, from: data)
|
||||
|
||||
return rawResponse
|
||||
}
|
||||
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||
guard let streamUrlString = restrictedFile.streamUrlString else {
|
||||
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the OffCloud API")
|
||||
}
|
||||
|
||||
return streamUrlString
|
||||
}
|
||||
|
||||
func getUserDownloads() {}
|
||||
|
||||
func checkUserDownloads(link: String) -> String? {
|
||||
link
|
||||
}
|
||||
|
||||
func deleteUserDownload(downloadId: String) {}
|
||||
|
||||
func getUserMagnets() async throws {
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud/history"))
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: "cloudHistory")
|
||||
let rawResponse = try jsonDecoder.decode([CloudHistoryResponse].self, from: data)
|
||||
|
||||
cloudMagnets = rawResponse.compactMap { cloudHistory in
|
||||
guard let magnetHash = Magnet(hash: nil, link: cloudHistory.originalLink).hash else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DebridCloudMagnet(
|
||||
id: cloudHistory.requestId,
|
||||
fileName: cloudHistory.fileName,
|
||||
status: cloudHistory.status,
|
||||
hash: magnetHash,
|
||||
links: [cloudHistory.originalLink]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Uses the base website because this isn't present in the API path but still works like the API?
|
||||
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||
guard let cloudMagnetId else {
|
||||
throw DebridError.InvalidPostBody
|
||||
}
|
||||
|
||||
var request = try URLRequest(url: buildRequestURL(urlString: "\(website)/cloud/remove/\(cloudMagnetId)"))
|
||||
try await performRequest(request: &request, requestName: "cloudRemove")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,16 +7,19 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Premiumize: OAuthDebridSource, ObservableObject {
|
||||
public let id = "Premiumize"
|
||||
public let abbreviation = "PM"
|
||||
public let website = "https://premiumize.me"
|
||||
@Published public var authProcessing: Bool = false
|
||||
public var isLoggedIn: Bool {
|
||||
class Premiumize: OAuthDebridSource, ObservableObject {
|
||||
let id = "Premiumize"
|
||||
let abbreviation = "PM"
|
||||
let website = "https://premiumize.me"
|
||||
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
|
||||
"You must pay to access the service."
|
||||
|
||||
@Published var authProcessing: Bool = false
|
||||
var isLoggedIn: Bool {
|
||||
getToken() != nil
|
||||
}
|
||||
|
||||
public var manualToken: String? {
|
||||
var manualToken: String? {
|
||||
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
|
||||
return getToken()
|
||||
} else {
|
||||
|
|
@ -24,20 +27,27 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@Published public var IAValues: [DebridIA] = []
|
||||
@Published public var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published public var cloudTorrents: [DebridCloudTorrent] = []
|
||||
public var cloudTTL: Double = 0.0
|
||||
@Published var IAValues: [DebridIA] = []
|
||||
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||
var cloudTTL: Double = 0.0
|
||||
|
||||
let baseAuthUrl = "https://www.premiumize.me/authorize"
|
||||
let baseApiUrl = "https://www.premiumize.me/api"
|
||||
let clientId = "791565696"
|
||||
private let baseAuthUrl = "https://www.premiumize.me/authorize"
|
||||
private let baseApiUrl = "https://www.premiumize.me/api"
|
||||
private let clientId = "791565696"
|
||||
|
||||
let jsonDecoder = JSONDecoder()
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
|
||||
init() {
|
||||
// Populate user downloads and magnets
|
||||
Task {
|
||||
try? await getUserDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
public func getAuthUrl() throws -> URL {
|
||||
func getAuthUrl() throws -> URL {
|
||||
var urlComponents = URLComponents(string: baseAuthUrl)!
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
|
|
@ -52,7 +62,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func handleAuthCallback(url: URL) throws {
|
||||
func handleAuthCallback(url: URL) throws {
|
||||
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
|
||||
guard let callbackFragment = callbackComponents?.fragment else {
|
||||
|
|
@ -70,17 +80,17 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Adds a manual API key instead of web auth
|
||||
public func setApiKey(_ key: String) {
|
||||
func setApiKey(_ key: String) {
|
||||
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
|
||||
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
|
||||
}
|
||||
|
||||
public func getToken() -> String? {
|
||||
func getToken() -> String? {
|
||||
FerriteKeychain.shared.get("Premiumize.AccessToken")
|
||||
}
|
||||
|
||||
// Clears tokens. No endpoint to deregister a device
|
||||
public func logout() {
|
||||
func logout() {
|
||||
FerriteKeychain.shared.delete("Premiumize.AccessToken")
|
||||
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
|
||||
}
|
||||
|
|
@ -132,7 +142,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
// MARK: - Instant availability
|
||||
|
||||
public func instantAvailability(magnets: [Magnet]) async throws {
|
||||
func instantAvailability(magnets: [Magnet]) async throws {
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
// Remove magnets that don't have an associated link for PM along with existing TTL logic
|
||||
|
|
@ -168,7 +178,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
// Function to divide and execute DDL endpoint requests in parallel
|
||||
// Calls this for 10 requests at a time to not overwhelm API servers
|
||||
public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
|
||||
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
|
||||
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
|
||||
for magnet in magnetChunk {
|
||||
group.addTask {
|
||||
|
|
@ -187,7 +197,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Grabs DDL links
|
||||
func fetchDDL(magnet: Magnet) async throws -> DebridIA {
|
||||
private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
|
||||
if magnet.hash == nil {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
|
@ -208,7 +218,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
if !content.isEmpty {
|
||||
let files = content.map { file in
|
||||
DebridIAFile(
|
||||
fileId: 0,
|
||||
id: 0,
|
||||
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
|
||||
streamUrlString: file.link
|
||||
)
|
||||
|
|
@ -216,7 +226,6 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
return DebridIA(
|
||||
magnet: magnet,
|
||||
source: id,
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: files
|
||||
)
|
||||
|
|
@ -227,7 +236,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
// Function to divide and execute cache endpoint requests in parallel
|
||||
// Calls this for 100 hashes at a time due to API limits
|
||||
public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
|
||||
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
|
||||
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
|
||||
for chunk in magnets.chunked(into: 100) {
|
||||
group.addTask {
|
||||
|
|
@ -247,7 +256,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Parent function for initial checking of the cache
|
||||
func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
|
||||
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
|
||||
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
|
||||
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
|
||||
guard let url = urlComponents.url else {
|
||||
|
|
@ -276,21 +285,28 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
// MARK: - Downloading
|
||||
|
||||
// Wrapper function to fetch a DDL link from the API
|
||||
public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String {
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||
// Store the item in PM cloud for later use
|
||||
try await createTransfer(magnet: magnet)
|
||||
|
||||
if let iaFile, let streamUrlString = iaFile.streamUrlString {
|
||||
return streamUrlString
|
||||
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0], let streamUrlString = firstFile.streamUrlString {
|
||||
return streamUrlString
|
||||
if let iaFile {
|
||||
return (iaFile, nil)
|
||||
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
|
||||
return (firstFile, nil)
|
||||
} else {
|
||||
throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
|
||||
}
|
||||
}
|
||||
|
||||
func createTransfer(magnet: Magnet) async throws {
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||
guard let streamUrlString = restrictedFile.streamUrlString else {
|
||||
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
|
||||
}
|
||||
|
||||
return streamUrlString
|
||||
}
|
||||
|
||||
private func createTransfer(magnet: Magnet) async throws {
|
||||
guard let magnetLink = magnet.link else {
|
||||
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||
}
|
||||
|
|
@ -309,7 +325,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
// MARK: - Cloud methods
|
||||
|
||||
public func getUserDownloads() async throws {
|
||||
func getUserDownloads() async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
|
|
@ -321,11 +337,11 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
|
||||
// The "link" is the ID for Premiumize
|
||||
cloudDownloads = rawResponse.files.map { file in
|
||||
DebridCloudDownload(downloadId: file.id, source: self.id, fileName: file.name, link: file.id)
|
||||
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
|
||||
}
|
||||
}
|
||||
|
||||
func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
|
||||
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
|
||||
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
|
||||
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
|
||||
guard let url = urlComponents.url else {
|
||||
|
|
@ -340,12 +356,12 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
return rawResponse
|
||||
}
|
||||
|
||||
public func checkUserDownloads(link: String) async throws -> String? {
|
||||
func checkUserDownloads(link: String) async throws -> String? {
|
||||
// Link is the cloud item ID
|
||||
try await itemDetails(itemID: link).link
|
||||
}
|
||||
|
||||
public func deleteDownload(downloadId: String) async throws {
|
||||
func deleteUserDownload(downloadId: String) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
|
@ -358,8 +374,8 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
|
|||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
|
||||
// No user torrents for Premiumize
|
||||
public func getUserTorrents() async throws {}
|
||||
// No user magnets for Premiumize
|
||||
func getUserMagnets() {}
|
||||
|
||||
public func deleteTorrent(torrentId: String?) async throws {}
|
||||
func deleteUserMagnet(cloudMagnetId: String?) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,20 +7,28 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class RealDebrid: PollingDebridSource, ObservableObject {
|
||||
public let id = "RealDebrid"
|
||||
public let abbreviation = "RD"
|
||||
public let website = "https://real-debrid.com"
|
||||
public var authTask: Task<Void, Error>?
|
||||
class RealDebrid: PollingDebridSource, ObservableObject {
|
||||
let id = "RealDebrid"
|
||||
let abbreviation = "RD"
|
||||
let website = "https://real-debrid.com"
|
||||
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
|
||||
"You must pay to access this service. \n\n" +
|
||||
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||
"This service does not inform if a magnet link is a batch before downloading."
|
||||
|
||||
@Published public var authProcessing: Bool = false
|
||||
let cachedStatus: [String] = ["downloaded"]
|
||||
var authTask: Task<Void, Error>?
|
||||
|
||||
@Published var authProcessing: Bool = false
|
||||
|
||||
// Check the manual token since getTokens() is async
|
||||
public var isLoggedIn: Bool {
|
||||
var isLoggedIn: Bool {
|
||||
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
|
||||
}
|
||||
|
||||
public var manualToken: String? {
|
||||
var manualToken: String? {
|
||||
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
|
||||
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||
} else {
|
||||
|
|
@ -28,31 +36,39 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@Published public var IAValues: [DebridIA] = []
|
||||
@Published public var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published public var cloudTorrents: [DebridCloudTorrent] = []
|
||||
public var cloudTTL: Double = 0.0
|
||||
@Published var IAValues: [DebridIA] = []
|
||||
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||
var cloudTTL: Double = 0.0
|
||||
|
||||
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
||||
let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
||||
let openSourceClientId = "X245A4XAIBGVM"
|
||||
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
||||
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
||||
private let openSourceClientId = "X245A4XAIBGVM"
|
||||
|
||||
let jsonDecoder = JSONDecoder()
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
|
||||
@MainActor
|
||||
func setUserDefaultsValue(_ value: Any, forKey: String) {
|
||||
private func setUserDefaultsValue(_ value: Any, forKey: String) {
|
||||
UserDefaults.standard.set(value, forKey: forKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func removeUserDefaultsValue(forKey: String) {
|
||||
private func removeUserDefaultsValue(forKey: String) {
|
||||
UserDefaults.standard.removeObject(forKey: forKey)
|
||||
}
|
||||
|
||||
init() {
|
||||
// Populate user downloads and magnets
|
||||
Task {
|
||||
try? await getUserDownloads()
|
||||
try? await getUserMagnets()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
// Fetches the device code from RD
|
||||
public func getAuthUrl() async throws -> URL {
|
||||
func getAuthUrl() async throws -> URL {
|
||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||
|
|
@ -86,7 +102,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Fetches the user's client ID and secret
|
||||
public func getDeviceCredentials(deviceCode: String) async throws {
|
||||
func getDeviceCredentials(deviceCode: String) async throws {
|
||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||
|
|
@ -130,7 +146,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Fetch all tokens for the user and store in FerriteKeychain.shared
|
||||
public func getApiTokens(deviceCode: String) async throws {
|
||||
func getApiTokens(deviceCode: String) async throws {
|
||||
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
|
@ -164,7 +180,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
||||
}
|
||||
|
||||
public func getToken() async -> String? {
|
||||
func getToken() async -> String? {
|
||||
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
||||
|
||||
if Date().timeIntervalSince1970 > accessTokenStamp {
|
||||
|
|
@ -183,7 +199,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
|
||||
// Adds a manual API key instead of web auth
|
||||
// Clear out existing refresh tokens and timestamps
|
||||
public func setApiKey(_ key: String) {
|
||||
func setApiKey(_ key: String) {
|
||||
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
|
||||
|
|
@ -192,7 +208,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Deletes tokens from device and RD's servers
|
||||
public func logout() async {
|
||||
func logout() async {
|
||||
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
|
||||
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
|
||||
|
|
@ -236,8 +252,9 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
|
||||
// MARK: - Instant availability
|
||||
|
||||
// Checks if the magnet is streamable on RD
|
||||
public func instantAvailability(magnets: [Magnet]) async throws {
|
||||
// Post-API changes
|
||||
// Use user magnets to check for IA instead
|
||||
func instantAvailability(magnets: [Magnet]) async throws {
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
let sendMagnets = magnets.filter { magnet in
|
||||
|
|
@ -253,70 +270,16 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
if sendMagnets.isEmpty {
|
||||
return
|
||||
}
|
||||
// Fetch the user magnets to the latest version
|
||||
try await getUserMagnets()
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(sendMagnets.compactMap(\.hash).joined(separator: "/"))")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
|
||||
// Does not account for torrent packs at the moment
|
||||
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
|
||||
|
||||
for (hash, response) in rawResponseDict {
|
||||
guard let data = response.data else {
|
||||
continue
|
||||
}
|
||||
|
||||
if data.rd.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
// Is this a batch?
|
||||
if data.rd.count > 1 || data.rd[0].count > 1 {
|
||||
// Batch array
|
||||
let batches = data.rd.map { fileDict in
|
||||
let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in
|
||||
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
|
||||
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
|
||||
}.sorted(by: { $0.id < $1.id })
|
||||
|
||||
return RealDebrid.IABatch(files: batchFiles)
|
||||
}
|
||||
|
||||
var files: [DebridIAFile] = []
|
||||
|
||||
for batch in batches {
|
||||
let batchFileIds = batch.files.map(\.id)
|
||||
|
||||
for batchFile in batch.files {
|
||||
if !files.contains(where: { $0.fileId == batchFile.id }) {
|
||||
files.append(
|
||||
DebridIAFile(
|
||||
fileId: batchFile.id,
|
||||
name: batchFile.fileName,
|
||||
batchIds: batchFileIds
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TTL: 5 minutes
|
||||
for cloudMagnet in cloudMagnets {
|
||||
if cachedStatus.contains(cloudMagnet.status),
|
||||
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||
{
|
||||
IAValues.append(
|
||||
DebridIA(
|
||||
magnet: Magnet(hash: hash, link: nil),
|
||||
source: id,
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: files
|
||||
)
|
||||
)
|
||||
} else {
|
||||
IAValues.append(
|
||||
DebridIA(
|
||||
magnet: Magnet(hash: hash, link: nil),
|
||||
source: id,
|
||||
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: []
|
||||
)
|
||||
|
|
@ -328,30 +291,52 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
// MARK: - Downloading
|
||||
|
||||
// Wrapper function to fetch a download link from the API
|
||||
public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String {
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||
var selectedMagnetId = ""
|
||||
|
||||
do {
|
||||
// Don't queue a new job if the torrent already exists
|
||||
if let existingTorrent = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) {
|
||||
selectedMagnetId = existingTorrent.torrentId
|
||||
// Don't queue a new job if the magnet already exists in the user's library
|
||||
if let existingCloudMagnet = cloudMagnets.first(where: {
|
||||
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||
}) {
|
||||
selectedMagnetId = existingCloudMagnet.id
|
||||
} else {
|
||||
selectedMagnetId = try await addMagnet(magnet: magnet)
|
||||
|
||||
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
|
||||
}
|
||||
|
||||
// RealDebrid has 1 as the first ID for a file
|
||||
let torrentLink = try await torrentInfo(
|
||||
debridID: selectedMagnetId,
|
||||
selectedFileId: iaFile?.fileId ?? 1
|
||||
)
|
||||
let downloadLink = try await unrestrictLink(debridDownloadLink: torrentLink)
|
||||
let response = try await torrentInfo(debridID: selectedMagnetId)
|
||||
let filteredFiles = response.files.filter { $0.selected == 1 }
|
||||
|
||||
return downloadLink
|
||||
// Need to return this to the user
|
||||
if filteredFiles.count > 1, iaFile == nil {
|
||||
var copiedIA = ia
|
||||
|
||||
copiedIA?.files = response.files.enumerated().compactMap { index, file in
|
||||
DebridIAFile(
|
||||
id: index,
|
||||
name: file.path,
|
||||
streamUrlString: response.links[safe: index]
|
||||
)
|
||||
}
|
||||
|
||||
return (nil, copiedIA)
|
||||
}
|
||||
|
||||
// RealDebrid has 1 as the first ID for a file
|
||||
let selectedFileId = iaFile?.id ?? 1
|
||||
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
|
||||
|
||||
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
|
||||
return (restrictedFile, nil)
|
||||
} catch {
|
||||
if case DebridError.EmptyTorrents = error, !selectedMagnetId.isEmpty {
|
||||
try? await deleteTorrent(torrentId: selectedMagnetId)
|
||||
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
|
||||
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
|
||||
}
|
||||
|
||||
// Re-raise the error to the calling function
|
||||
|
|
@ -360,7 +345,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Adds a magnet link to the user's RD account
|
||||
public func addMagnet(magnet: Magnet) async throws -> String {
|
||||
func addMagnet(magnet: Magnet) async throws -> String {
|
||||
guard let magnetLink = magnet.link else {
|
||||
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||
}
|
||||
|
|
@ -381,7 +366,7 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Queues the magnet link for downloading
|
||||
public func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
||||
func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
|
@ -401,32 +386,31 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Gets the info of a torrent from a given ID
|
||||
public func torrentInfo(debridID: String, selectedFileId: Int?) async throws -> String {
|
||||
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
|
||||
let filteredFiles = rawResponse.files.filter { $0.selected == 1 }
|
||||
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
|
||||
|
||||
// Let the user know if a torrent is downloading
|
||||
if let torrentLink = rawResponse.links[safe: linkIndex ?? -1], rawResponse.status == "downloaded" {
|
||||
return torrentLink
|
||||
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
|
||||
// Let the user know if a magnet is downloading
|
||||
switch rawResponse.status {
|
||||
case "downloaded":
|
||||
return rawResponse
|
||||
case "downloading", "queued":
|
||||
throw DebridError.IsCaching
|
||||
} else {
|
||||
throw DebridError.EmptyTorrents
|
||||
default:
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
}
|
||||
|
||||
// Downloads link from selectFiles for playback
|
||||
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var bodyComponents = URLComponents()
|
||||
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
|
||||
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)]
|
||||
|
||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||
|
||||
|
|
@ -438,39 +422,38 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
|
||||
// MARK: - Cloud methods
|
||||
|
||||
// Gets the user's torrent library
|
||||
public func getUserTorrents() async throws {
|
||||
// Gets the user's cloud magnet library
|
||||
func getUserMagnets() async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
|
||||
cloudTorrents = rawResponse.map { response in
|
||||
DebridCloudTorrent(
|
||||
torrentId: response.id,
|
||||
source: self.id,
|
||||
cloudMagnets = rawResponse.map { response in
|
||||
DebridCloudMagnet(
|
||||
id: response.id,
|
||||
fileName: response.filename,
|
||||
status: response.status,
|
||||
hash: response.hash,
|
||||
links: response.links
|
||||
links: [response.id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes a torrent download from RD
|
||||
public func deleteTorrent(torrentId: String?) async throws {
|
||||
// Deletes a magnet download from RD
|
||||
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||
let deleteId: String
|
||||
|
||||
if let torrentId {
|
||||
deleteId = torrentId
|
||||
if let cloudMagnetId {
|
||||
deleteId = cloudMagnetId
|
||||
} else {
|
||||
// Refresh the torrent cloud
|
||||
// Refresh the user magnet list
|
||||
// The first file is the currently caching one
|
||||
let _ = try await getUserTorrents()
|
||||
guard let firstTorrent = cloudTorrents[safe: -1] else {
|
||||
throw DebridError.EmptyTorrents
|
||||
let _ = try await getUserMagnets()
|
||||
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
deleteId = firstTorrent.torrentId
|
||||
deleteId = firstCloudMagnet.id
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
|
||||
|
|
@ -480,22 +463,22 @@ public class RealDebrid: PollingDebridSource, ObservableObject {
|
|||
}
|
||||
|
||||
// Gets the user's downloads
|
||||
public func getUserDownloads() async throws {
|
||||
func getUserDownloads() async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
|
||||
cloudDownloads = rawResponse.map { response in
|
||||
DebridCloudDownload(downloadId: response.id, source: self.id, fileName: response.filename, link: response.download)
|
||||
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
|
||||
}
|
||||
}
|
||||
|
||||
// Not used
|
||||
public func checkUserDownloads(link: String) -> String? {
|
||||
nil
|
||||
func checkUserDownloads(link: String) -> String? {
|
||||
link
|
||||
}
|
||||
|
||||
public func deleteDownload(downloadId: String) async throws {
|
||||
func deleteUserDownload(downloadId: String) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
|
||||
request.httpMethod = "DELETE"
|
||||
|
||||
|
|
|
|||
270
Ferrite/API/TorBoxWrapper.swift
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//
|
||||
// TorBoxWrapper.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/11/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class TorBox: DebridSource, ObservableObject {
|
||||
let id = "TorBox"
|
||||
let abbreviation = "TB"
|
||||
let website = "https://torbox.app"
|
||||
let description: String? = "TorBox is a debrid service that is used for downloads and media playback with seeding. " +
|
||||
"Both free and paid plans are available."
|
||||
let cachedStatus: [String] = ["cached", "completed"]
|
||||
|
||||
@Published var authProcessing: Bool = false
|
||||
var isLoggedIn: Bool {
|
||||
getToken() != nil
|
||||
}
|
||||
|
||||
var manualToken: String? {
|
||||
if UserDefaults.standard.bool(forKey: "TorBox.UseManualKey") {
|
||||
return getToken()
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Published var IAValues: [DebridIA] = []
|
||||
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||
var cloudTTL: Double = 0.0
|
||||
|
||||
private let baseApiUrl = "https://api.torbox.app/v1/api"
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
private let jsonEncoder = JSONEncoder()
|
||||
|
||||
init() {
|
||||
// Populate user downloads and magnets
|
||||
Task {
|
||||
try? await getUserMagnets()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func setApiKey(_ key: String) {
|
||||
FerriteKeychain.shared.set(key, forKey: "TorBox.ApiKey")
|
||||
UserDefaults.standard.set(true, forKey: "TorBox.UseManualKey")
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
FerriteKeychain.shared.delete("TorBox.ApiKey")
|
||||
UserDefaults.standard.removeObject(forKey: "TorBox.UseManualKey")
|
||||
}
|
||||
|
||||
private func getToken() -> String? {
|
||||
FerriteKeychain.shared.get("TorBox.ApiKey")
|
||||
}
|
||||
|
||||
// MARK: - Common request
|
||||
|
||||
// Wrapper request function which matches the responses and returns data
|
||||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||
guard let token = getToken() else {
|
||||
throw DebridError.InvalidToken
|
||||
}
|
||||
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||
}
|
||||
|
||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||
return data
|
||||
} else if response.statusCode == 401 {
|
||||
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.")
|
||||
} else {
|
||||
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instant availability
|
||||
|
||||
func instantAvailability(magnets: [Magnet]) async throws {
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
let sendMagnets = magnets.filter { magnet in
|
||||
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||
IAValues.remove(at: IAIndex)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if sendMagnets.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
var components = URLComponents(string: "\(baseApiUrl)/torrents/checkcached")!
|
||||
components.queryItems = sendMagnets.map { URLQueryItem(name: "hash", value: $0.hash) }
|
||||
components.queryItems?.append(URLQueryItem(name: "format", value: "list"))
|
||||
components.queryItems?.append(URLQueryItem(name: "list_files", value: "true"))
|
||||
|
||||
guard let url = components.url else {
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(TBResponse<InstantAvailabilityData>.self, from: data)
|
||||
|
||||
// If the data is a failure, return
|
||||
guard case let .links(iaObjects) = rawResponse.data else {
|
||||
return
|
||||
}
|
||||
|
||||
let availableHashes = iaObjects.map { iaObject in
|
||||
DebridIA(
|
||||
magnet: Magnet(hash: iaObject.hash, link: nil),
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: iaObject.files.enumerated().compactMap { index, iaFile in
|
||||
guard let fileName = iaFile.name.split(separator: "/").last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DebridIAFile(
|
||||
id: index,
|
||||
name: String(fileName)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
IAValues += availableHashes
|
||||
}
|
||||
|
||||
// MARK: - Downloading
|
||||
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||
let cloudMagnetId = try await createTorrent(magnet: magnet)
|
||||
let cloudMagnetList = try await myTorrentList()
|
||||
guard let filteredCloudMagnet = cloudMagnetList.first(where: { $0.id == cloudMagnetId }) else {
|
||||
throw DebridError.FailedRequest(description: "Could not find a cached magnet. Are you sure it's cached?")
|
||||
}
|
||||
|
||||
// If the user magnet isn't saved, it's considered as caching
|
||||
guard cachedStatus.contains(filteredCloudMagnet.downloadState) else {
|
||||
throw DebridError.IsCaching
|
||||
}
|
||||
|
||||
guard let cloudMagnetFile = filteredCloudMagnet.files[safe: iaFile?.id ?? 0] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
let restrictedFile = DebridIAFile(id: cloudMagnetFile.id, name: cloudMagnetFile.name, streamUrlString: String(cloudMagnetId))
|
||||
return (restrictedFile, nil)
|
||||
}
|
||||
|
||||
private func createTorrent(magnet: Magnet) async throws -> Int {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/createtorrent")!)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
guard let magnetLink = magnet.link else {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
let formData = FormDataBody(params: ["magnet": magnetLink])
|
||||
request.setValue("multipart/form-data; boundary=\(formData.boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = formData.body
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(TBResponse<CreateTorrentResponse>.self, from: data)
|
||||
|
||||
guard let torrentId = rawResponse.data?.torrentId else {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
return torrentId
|
||||
}
|
||||
|
||||
private func myTorrentList() async throws -> [MyTorrentListResponse] {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/mylist")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(TBResponse<[MyTorrentListResponse]>.self, from: data)
|
||||
|
||||
guard let torrentList = rawResponse.data else {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
return torrentList
|
||||
}
|
||||
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||
var components = URLComponents(string: "\(baseApiUrl)/torrents/requestdl")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "token", value: getToken()),
|
||||
URLQueryItem(name: "torrent_id", value: restrictedFile.streamUrlString),
|
||||
URLQueryItem(name: "file_id", value: String(restrictedFile.id))
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(TBResponse<RequestDLResponse>.self, from: data)
|
||||
|
||||
guard let unrestrictedLink = rawResponse.data else {
|
||||
throw DebridError.FailedRequest(description: "Could not get an unrestricted URL from TorBox.")
|
||||
}
|
||||
|
||||
return unrestrictedLink
|
||||
}
|
||||
|
||||
// MARK: - Cloud methods
|
||||
|
||||
// Unused
|
||||
func getUserDownloads() {}
|
||||
|
||||
func checkUserDownloads(link: String) -> String? {
|
||||
link
|
||||
}
|
||||
|
||||
func deleteUserDownload(downloadId: String) {}
|
||||
|
||||
func getUserMagnets() async throws {
|
||||
let cloudMagnetList = try await myTorrentList()
|
||||
cloudMagnets = cloudMagnetList.map { cloudMagnet in
|
||||
|
||||
// Only need one link to force a green badge
|
||||
DebridCloudMagnet(
|
||||
id: String(cloudMagnet.id),
|
||||
fileName: cloudMagnet.name,
|
||||
status: cloudMagnet.downloadState,
|
||||
hash: cloudMagnet.hash,
|
||||
links: cloudMagnet.files.map { String($0.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||
guard let cloudMagnetId else {
|
||||
throw DebridError.InvalidPostBody
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/controltorrent")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = ControlTorrentRequest(torrentId: cloudMagnetId, operation: "Delete")
|
||||
request.httpBody = try jsonEncoder.encode(body)
|
||||
|
||||
try await performRequest(request: &request, requestName: "controltorrent")
|
||||
}
|
||||
}
|
||||
|
|
@ -10,4 +10,4 @@ import CoreData
|
|||
import Foundation
|
||||
|
||||
@objc(Bookmark)
|
||||
public class Bookmark: NSManagedObject {}
|
||||
class Bookmark: NSManagedObject {}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public extension Bookmark {
|
||||
extension Bookmark {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
|
||||
NSFetchRequest<Bookmark>(entityName: "Bookmark")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public extension SourceHtmlParser {
|
|||
|
||||
@NSManaged var rows: String
|
||||
@NSManaged var searchUrl: String?
|
||||
@NSManaged var request: SourceRequest?
|
||||
@NSManaged var magnetHash: SourceMagnetHash?
|
||||
@NSManaged var magnetLink: SourceMagnetLink?
|
||||
@NSManaged var parentSource: Source?
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public extension SourceJsonParser {
|
|||
@NSManaged var results: String?
|
||||
@NSManaged var subResults: String?
|
||||
@NSManaged var searchUrl: String
|
||||
@NSManaged var request: SourceRequest?
|
||||
@NSManaged var magnetHash: SourceMagnetHash?
|
||||
@NSManaged var magnetLink: SourceMagnetLink?
|
||||
@NSManaged var parentSource: Source?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// SourceRequest+CoreDataClass.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/10/24.
|
||||
//
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
@objc(SourceRequest)
|
||||
public class SourceRequest: NSManagedObject {}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// SourceRequest+CoreDataProperties.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/10/24.
|
||||
//
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public extension SourceRequest {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceRequest> {
|
||||
NSFetchRequest<SourceRequest>(entityName: "SourceRequest")
|
||||
}
|
||||
|
||||
@NSManaged var method: String?
|
||||
@NSManaged var headers: [String: String]?
|
||||
@NSManaged var body: String?
|
||||
@NSManaged var parentHtmlParser: SourceHtmlParser?
|
||||
@NSManaged var parentRssParser: SourceRssParser?
|
||||
@NSManaged var parentJsonParser: SourceJsonParser?
|
||||
}
|
||||
|
||||
extension SourceRequest: Identifiable {}
|
||||
|
|
@ -17,6 +17,7 @@ public extension SourceRssParser {
|
|||
@NSManaged var items: String
|
||||
@NSManaged var rssUrl: String?
|
||||
@NSManaged var searchUrl: String
|
||||
@NSManaged var request: SourceRequest?
|
||||
@NSManaged var magnetHash: SourceMagnetHash?
|
||||
@NSManaged var magnetLink: SourceMagnetLink?
|
||||
@NSManaged var parentSource: Source?
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Action" representedClassName="Action" syncable="YES">
|
||||
<attribute name="about" optional="YES" attributeType="String"/>
|
||||
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||
|
|
@ -106,6 +106,7 @@
|
|||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
|
||||
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
|
||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
|
||||
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentHtmlParser" inverseEntity="SourceRequest"/>
|
||||
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
|
||||
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
|
||||
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
|
||||
|
|
@ -118,6 +119,7 @@
|
|||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
|
||||
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
|
||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
|
||||
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentJsonParser" inverseEntity="SourceRequest"/>
|
||||
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
|
||||
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
|
||||
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
|
||||
|
|
@ -134,6 +136,14 @@
|
|||
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
|
||||
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
|
||||
</entity>
|
||||
<entity name="SourceRequest" representedClassName="SourceRequest" syncable="YES">
|
||||
<attribute name="body" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData"/>
|
||||
<attribute name="headers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String: String]"/>
|
||||
<attribute name="method" optional="YES" attributeType="String"/>
|
||||
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="request" inverseEntity="SourceHtmlParser"/>
|
||||
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="request" inverseEntity="SourceJsonParser"/>
|
||||
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="request" inverseEntity="SourceRssParser"/>
|
||||
</entity>
|
||||
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
|
||||
<attribute name="items" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="rssUrl" optional="YES" attributeType="String"/>
|
||||
|
|
@ -141,6 +151,7 @@
|
|||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
|
||||
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
|
||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
|
||||
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentRssParser" inverseEntity="SourceRequest"/>
|
||||
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
|
||||
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
|
||||
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
public extension Color {
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Array.swift
|
||||
// Set.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 11/26/22.
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ import UIKit
|
|||
|
||||
extension UIDevice {
|
||||
var hasNotch: Bool {
|
||||
if #available(iOS 11.0, *) {
|
||||
return UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@
|
|||
// Created by Brian Dashore on 8/15/22.
|
||||
//
|
||||
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
// Modifies properties of a view. Works the same way as a ViewModifier
|
||||
// From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
|
||||
public func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
|
||||
func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
|
||||
var result = self
|
||||
body(&result)
|
||||
|
||||
|
|
@ -20,16 +19,6 @@ extension View {
|
|||
|
||||
// MARK: Modifiers
|
||||
|
||||
func conditionalContextMenu(id: some Hashable,
|
||||
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
|
||||
{
|
||||
modifier(ConditionalContextMenuModifier(internalContent, id: id))
|
||||
}
|
||||
|
||||
func conditionalId(_ id: some Hashable) -> some View {
|
||||
modifier(ConditionalIdModifier(id: id))
|
||||
}
|
||||
|
||||
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
|
||||
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<string>Ferrite</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ferrite://</string>
|
||||
<string>ferrite</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
|
|
|||
|
|
@ -7,30 +7,30 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct ActionJson: Codable, Hashable, PluginJson {
|
||||
public let name: String
|
||||
public let version: Int16
|
||||
struct ActionJson: Codable, Hashable, PluginJson {
|
||||
let name: String
|
||||
let version: Int16
|
||||
let minVersion: String?
|
||||
let about: String?
|
||||
let website: String?
|
||||
let requires: [ActionRequirement]
|
||||
let deeplink: [DeeplinkActionJson]?
|
||||
public let author: String?
|
||||
public let listId: UUID?
|
||||
public let listName: String?
|
||||
public let tags: [PluginTagJson]?
|
||||
let author: String?
|
||||
let listId: UUID?
|
||||
let listName: String?
|
||||
let tags: [PluginTagJson]?
|
||||
|
||||
public init(name: String,
|
||||
version: Int16,
|
||||
minVersion: String?,
|
||||
about: String?,
|
||||
website: String?,
|
||||
requires: [ActionRequirement],
|
||||
deeplink: [DeeplinkActionJson]?,
|
||||
author: String?,
|
||||
listId: UUID?,
|
||||
listName: String?,
|
||||
tags: [PluginTagJson]?)
|
||||
init(name: String,
|
||||
version: Int16,
|
||||
minVersion: String?,
|
||||
about: String?,
|
||||
website: String?,
|
||||
requires: [ActionRequirement],
|
||||
deeplink: [DeeplinkActionJson]?,
|
||||
author: String?,
|
||||
listId: UUID?,
|
||||
listName: String?,
|
||||
tags: [PluginTagJson]?)
|
||||
{
|
||||
self.name = name
|
||||
self.version = version
|
||||
|
|
@ -45,7 +45,7 @@ public struct ActionJson: Codable, Hashable, PluginJson {
|
|||
self.tags = tags
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
version = try container.decode(Int16.self, forKey: .version)
|
||||
|
|
@ -68,7 +68,7 @@ public struct ActionJson: Codable, Hashable, PluginJson {
|
|||
}
|
||||
}
|
||||
|
||||
public struct DeeplinkActionJson: Codable, Hashable {
|
||||
struct DeeplinkActionJson: Codable, Hashable {
|
||||
let os: [String]
|
||||
let scheme: String
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ public struct DeeplinkActionJson: Codable, Hashable {
|
|||
self.scheme = scheme
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let os = try? container.decode(String.self, forKey: .os) {
|
||||
|
|
@ -92,7 +92,7 @@ public struct DeeplinkActionJson: Codable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
public extension ActionJson {
|
||||
extension ActionJson {
|
||||
// Fetches all tags without optional requirement
|
||||
// Avoids the need for extra tag additions in DB
|
||||
func getTags() -> [PluginTagJson] {
|
||||
|
|
@ -100,7 +100,7 @@ public extension ActionJson {
|
|||
}
|
||||
}
|
||||
|
||||
public enum ActionRequirement: String, Codable {
|
||||
enum ActionRequirement: String, Codable {
|
||||
case magnet
|
||||
case debrid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public extension AllDebrid {
|
||||
extension AllDebrid {
|
||||
// MARK: - Generic AllDebrid response
|
||||
|
||||
// Uses a generic parametr for whatever underlying response is present
|
||||
|
|
@ -53,7 +53,7 @@ public extension AllDebrid {
|
|||
|
||||
// MARK: - AddMagnetData
|
||||
|
||||
internal struct AddMagnetData: Codable {
|
||||
struct AddMagnetData: Codable {
|
||||
let magnet, hash, name, filenameOriginal: String
|
||||
let size: Int
|
||||
let ready: Bool
|
||||
|
|
@ -71,7 +71,7 @@ public extension AllDebrid {
|
|||
struct MagnetStatusResponse: Codable {
|
||||
let magnets: [MagnetStatusData]
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
|
||||
|
|
@ -103,7 +103,7 @@ public extension AllDebrid {
|
|||
// MARK: - MagnetStatusLink
|
||||
|
||||
// Abridged for required parameters
|
||||
internal struct MagnetStatusLink: Codable {
|
||||
struct MagnetStatusLink: Codable {
|
||||
let link: String
|
||||
let filename: String
|
||||
let size: Int
|
||||
|
|
@ -137,7 +137,7 @@ public extension AllDebrid {
|
|||
|
||||
// MARK: - IAMagnetResponse
|
||||
|
||||
internal struct InstantAvailabilityMagnet: Codable {
|
||||
struct InstantAvailabilityMagnet: Codable {
|
||||
let magnet, hash: String
|
||||
let instant: Bool
|
||||
let files: [InstantAvailabilityFile]?
|
||||
|
|
@ -145,7 +145,7 @@ public extension AllDebrid {
|
|||
|
||||
// MARK: - IAFileResponse
|
||||
|
||||
internal struct InstantAvailabilityFile: Codable {
|
||||
struct InstantAvailabilityFile: Codable {
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
|
||||
// Version is optional until v1 is phased out
|
||||
public struct Backup: Codable {
|
||||
struct Backup: Codable {
|
||||
let version: Int?
|
||||
var bookmarks: [BookmarkJson]?
|
||||
var history: [HistoryJson]?
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
|
||||
// MARK: - Universal IA enum (IA = InstantAvailability)
|
||||
|
||||
public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case full = "Cached"
|
||||
case partial = "Batch"
|
||||
case none = "Uncached"
|
||||
|
|
@ -18,7 +18,7 @@ public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
|||
|
||||
// MARK: - Enum for debrid differentiation. 0 is nil
|
||||
|
||||
public enum DebridType: Int, Codable, Hashable, CaseIterable {
|
||||
enum DebridType: Int, Codable, Hashable, CaseIterable {
|
||||
case realDebrid = 1
|
||||
case allDebrid = 2
|
||||
case premiumize = 3
|
||||
|
|
@ -47,7 +47,7 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable {
|
|||
}
|
||||
|
||||
// Wrapper struct for magnet links to contain both the link and hash for easy access
|
||||
public struct Magnet: Codable, Hashable, Sendable {
|
||||
struct Magnet: Codable, Hashable, Sendable {
|
||||
var hash: String?
|
||||
var link: String?
|
||||
|
||||
|
|
@ -55,12 +55,14 @@ public struct Magnet: Codable, Hashable, Sendable {
|
|||
if let hash, link == nil {
|
||||
self.hash = parseHash(hash)
|
||||
self.link = generateLink(hash: hash, title: title, trackers: trackers)
|
||||
} else if let parsedLink = parseLink(link), hash == nil {
|
||||
self.link = parsedLink
|
||||
self.hash = parseHash(extractHash(link: parsedLink))
|
||||
} else if let link, hash == nil {
|
||||
let (link, hash) = parseLink(link)
|
||||
|
||||
self.link = link
|
||||
self.hash = hash
|
||||
} else {
|
||||
self.hash = parseHash(hash)
|
||||
self.link = parseLink(link)
|
||||
self.link = parseLink(link).link
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,19 +110,35 @@ public struct Magnet: Codable, Hashable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
func parseLink(_ link: String?) -> String? {
|
||||
if let decodedLink = link?.removingPercentEncoding {
|
||||
let separator = "magnet:?xt=urn:btih:"
|
||||
if decodedLink.starts(with: separator) {
|
||||
return decodedLink
|
||||
} else if decodedLink.contains(separator) {
|
||||
let splitLink = decodedLink.components(separatedBy: separator)
|
||||
return splitLink.last.map { separator + $0 } ?? nil
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) {
|
||||
let separator = "magnet:?xt=urn:btih:"
|
||||
|
||||
// Remove percent encoding from the link and ensure it's a magnet
|
||||
guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
// Isolate the magnet link if it's bundled with another protocol
|
||||
let isolatedLink: String?
|
||||
if decodedLink.starts(with: separator) {
|
||||
isolatedLink = decodedLink
|
||||
} else {
|
||||
return nil
|
||||
let splitLink = decodedLink.components(separatedBy: separator)
|
||||
isolatedLink = splitLink.last.map { separator + $0 }
|
||||
}
|
||||
|
||||
guard let isolatedLink else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
// If the hash can be extracted, decrypt it if necessary and return the revised link + hash
|
||||
if let originalHash = extractHash(link: isolatedLink),
|
||||
let parsedHash = parseHash(originalHash)
|
||||
{
|
||||
let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash)
|
||||
return (replacedLink, parsedHash)
|
||||
} else {
|
||||
return (decodedLink, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,51 +7,49 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct DebridIA: Hashable, Sendable {
|
||||
struct DebridIA: Hashable, Sendable {
|
||||
let magnet: Magnet
|
||||
let source: String
|
||||
let expiryTimeStamp: Double
|
||||
var files: [DebridIAFile]
|
||||
}
|
||||
|
||||
public struct DebridIAFile: Hashable, Sendable {
|
||||
let fileId: Int
|
||||
struct DebridIAFile: Hashable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let streamUrlString: String?
|
||||
let batchIds: [Int]
|
||||
|
||||
init(fileId: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) {
|
||||
self.fileId = fileId
|
||||
init(id: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.streamUrlString = streamUrlString
|
||||
self.batchIds = batchIds
|
||||
}
|
||||
}
|
||||
|
||||
public struct DebridCloudDownload: Hashable, Sendable {
|
||||
let downloadId: String
|
||||
let source: String
|
||||
struct DebridCloudDownload: Hashable, Sendable {
|
||||
let id: String
|
||||
let fileName: String
|
||||
let link: String
|
||||
}
|
||||
|
||||
public struct DebridCloudTorrent: Hashable, Sendable {
|
||||
let torrentId: String
|
||||
let source: String
|
||||
struct DebridCloudMagnet: Hashable, Sendable {
|
||||
let id: String
|
||||
let fileName: String
|
||||
let status: String
|
||||
let hash: String
|
||||
let links: [String]
|
||||
}
|
||||
|
||||
public enum DebridError: Error {
|
||||
enum DebridError: Error {
|
||||
case InvalidUrl
|
||||
case InvalidPostBody
|
||||
case InvalidResponse
|
||||
case InvalidToken
|
||||
case EmptyData
|
||||
case EmptyTorrents
|
||||
case EmptyUserMagnets
|
||||
case IsCaching
|
||||
case FailedRequest(description: String)
|
||||
case AuthQuery(description: String)
|
||||
case NotImplemented
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public extension Github {
|
||||
extension Github {
|
||||
struct Release: Codable, Hashable, Sendable {
|
||||
let htmlUrl: String
|
||||
let tagName: String
|
||||
|
|
|
|||
70
Ferrite/Models/OffCloudModels.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// OffCloudModels.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/12/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension OffCloud {
|
||||
struct ErrorResponse: Codable, Sendable {
|
||||
let error: String
|
||||
}
|
||||
|
||||
struct InstantAvailabilityRequest: Codable, Sendable {
|
||||
let hashes: [String]
|
||||
}
|
||||
|
||||
struct InstantAvailabilityResponse: Codable, Sendable {
|
||||
let cachedItems: [String]
|
||||
}
|
||||
|
||||
struct CloudDownloadRequest: Codable, Sendable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
struct CloudDownloadResponse: Codable, Sendable {
|
||||
let requestId: String
|
||||
let fileName: String
|
||||
let status: String
|
||||
let originalLink: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
enum CloudExploreResponse: Codable {
|
||||
case links([String])
|
||||
case error(ErrorResponse)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
// Only continue if the data is a List which indicates a success
|
||||
if let linkArray = try? container.decode([String].self) {
|
||||
self = .links(linkArray)
|
||||
} else {
|
||||
let value = try container.decode(ErrorResponse.self)
|
||||
self = .error(value)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case let .links(array):
|
||||
try container.encode(array)
|
||||
case let .error(value):
|
||||
try container.encode(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CloudHistoryResponse: Codable, Sendable {
|
||||
let requestId: String
|
||||
let fileName: String
|
||||
let status: String
|
||||
let originalLink: String
|
||||
let isDirectory: Bool
|
||||
let server: String
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct PluginListJson: Codable {
|
||||
struct PluginListJson: Codable {
|
||||
let name: String
|
||||
let author: String
|
||||
var sources: [SourceJson]?
|
||||
|
|
@ -16,8 +16,8 @@ public struct PluginListJson: Codable {
|
|||
|
||||
// Color: Hex value
|
||||
public struct PluginTagJson: Codable, Hashable, Sendable {
|
||||
public let name: String
|
||||
public let colorHex: String?
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public extension Premiumize {
|
||||
extension Premiumize {
|
||||
// MARK: - CacheCheckResponse
|
||||
|
||||
struct CacheCheckResponse: Codable {
|
||||
|
|
@ -20,7 +20,6 @@ public extension Premiumize {
|
|||
struct DDLResponse: Codable {
|
||||
let status: String
|
||||
let content: [DDLData]?
|
||||
let location: String
|
||||
let filename: String
|
||||
let filesize: Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public extension RealDebrid {
|
||||
extension RealDebrid {
|
||||
// MARK: - device code endpoint
|
||||
|
||||
struct DeviceCodeResponse: Codable, Sendable {
|
||||
|
|
@ -58,7 +58,7 @@ public extension RealDebrid {
|
|||
struct InstantAvailabilityResponse: Codable, Sendable {
|
||||
var data: InstantAvailabilityData?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if let data = try? container.decode(InstantAvailabilityData.self) {
|
||||
|
|
@ -67,11 +67,11 @@ public extension RealDebrid {
|
|||
}
|
||||
}
|
||||
|
||||
internal struct InstantAvailabilityData: Codable, Sendable {
|
||||
struct InstantAvailabilityData: Codable, Sendable {
|
||||
var rd: [[String: InstantAvailabilityInfo]]
|
||||
}
|
||||
|
||||
internal struct InstantAvailabilityInfo: Codable, Sendable {
|
||||
struct InstantAvailabilityInfo: Codable, Sendable {
|
||||
var filename: String
|
||||
var filesize: Int
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ public extension RealDebrid {
|
|||
|
||||
// MARK: - torrentInfo endpoint
|
||||
|
||||
internal struct TorrentInfoResponse: Codable, Sendable {
|
||||
struct TorrentInfoResponse: Codable, Sendable {
|
||||
let id, filename, originalFilename, hash: String
|
||||
let bytes, originalBytes: Int
|
||||
let host: String
|
||||
|
|
@ -117,7 +117,7 @@ public extension RealDebrid {
|
|||
}
|
||||
}
|
||||
|
||||
internal struct TorrentInfoFile: Codable, Sendable {
|
||||
struct TorrentInfoFile: Codable, Sendable {
|
||||
let id: Int
|
||||
let path: String
|
||||
let bytes, selected: Int
|
||||
|
|
@ -136,7 +136,7 @@ public extension RealDebrid {
|
|||
|
||||
// MARK: - unrestrictLink endpoint
|
||||
|
||||
internal struct UnrestrictLinkResponse: Codable, Sendable {
|
||||
struct UnrestrictLinkResponse: Codable, Sendable {
|
||||
let id, filename: String
|
||||
let mimeType: String?
|
||||
let filesize: Int
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
|
||||
// A raw search result structure displayed on the UI
|
||||
public struct SearchResult: Codable, Hashable, Sendable {
|
||||
struct SearchResult: Codable, Hashable, Sendable {
|
||||
let title: String?
|
||||
let source: String
|
||||
let size: String?
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
||||
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
||||
case json
|
||||
case text
|
||||
}
|
||||
|
||||
public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
||||
public let name: String
|
||||
public let version: Int16
|
||||
struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
||||
let name: String
|
||||
let version: Int16
|
||||
let minVersion: String?
|
||||
let about: String?
|
||||
let website: String?
|
||||
|
|
@ -25,33 +25,33 @@ public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
|||
let jsonParser: SourceJsonParserJson?
|
||||
let rssParser: SourceRssParserJson?
|
||||
let htmlParser: SourceHtmlParserJson?
|
||||
public let author: String?
|
||||
public let listId: UUID?
|
||||
public let listName: String?
|
||||
public let tags: [PluginTagJson]?
|
||||
let author: String?
|
||||
let listId: UUID?
|
||||
let listName: String?
|
||||
let tags: [PluginTagJson]?
|
||||
}
|
||||
|
||||
public extension SourceJson {
|
||||
extension SourceJson {
|
||||
// Fetches all tags without optional requirement
|
||||
func getTags() -> [PluginTagJson] {
|
||||
tags ?? []
|
||||
}
|
||||
}
|
||||
|
||||
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
||||
enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
||||
// case none = 0
|
||||
case scraping = 1
|
||||
case rss = 2
|
||||
case siteApi = 3
|
||||
}
|
||||
|
||||
public struct SourceApiJson: Codable, Hashable, Sendable {
|
||||
struct SourceApiJson: Codable, Hashable, Sendable {
|
||||
let apiUrl: String?
|
||||
let clientId: SourceApiCredentialJson?
|
||||
let clientSecret: SourceApiCredentialJson?
|
||||
}
|
||||
|
||||
public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||
struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||
let query: String?
|
||||
let value: String?
|
||||
let dynamic: Bool?
|
||||
|
|
@ -60,8 +60,9 @@ public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
|||
let expiryLength: Double?
|
||||
}
|
||||
|
||||
public struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||
struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||
let searchUrl: String
|
||||
let request: SourceRequestJson?
|
||||
let results: String?
|
||||
let subResults: String?
|
||||
let title: SourceComplexQueryJson
|
||||
|
|
@ -72,9 +73,10 @@ public struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
|||
let sl: SourceSLJson?
|
||||
}
|
||||
|
||||
public struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||
struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||
let rssUrl: String?
|
||||
let searchUrl: String
|
||||
let request: SourceRequestJson?
|
||||
let items: String
|
||||
let title: SourceComplexQueryJson
|
||||
let magnetHash: SourceComplexQueryJson?
|
||||
|
|
@ -84,8 +86,9 @@ public struct SourceRssParserJson: Codable, Hashable, Sendable {
|
|||
let sl: SourceSLJson?
|
||||
}
|
||||
|
||||
public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||
struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||
let searchUrl: String?
|
||||
let request: SourceRequestJson?
|
||||
let rows: String
|
||||
let title: SourceComplexQueryJson
|
||||
let magnet: SourceMagnetJson
|
||||
|
|
@ -94,21 +97,21 @@ public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
|||
let sl: SourceSLJson?
|
||||
}
|
||||
|
||||
public struct SourceComplexQueryJson: Codable, Hashable, Sendable {
|
||||
struct SourceComplexQueryJson: Codable, Hashable, Sendable {
|
||||
let query: String
|
||||
let discriminator: String?
|
||||
let attribute: String?
|
||||
let regex: String?
|
||||
}
|
||||
|
||||
public struct SourceMagnetJson: Codable, Hashable, Sendable {
|
||||
struct SourceMagnetJson: Codable, Hashable, Sendable {
|
||||
let query: String
|
||||
let attribute: String
|
||||
let regex: String?
|
||||
let externalLinkQuery: String?
|
||||
}
|
||||
|
||||
public struct SourceSLJson: Codable, Hashable, Sendable {
|
||||
struct SourceSLJson: Codable, Hashable, Sendable {
|
||||
let seeders: String?
|
||||
let leechers: String?
|
||||
let combined: String?
|
||||
|
|
@ -117,3 +120,9 @@ public struct SourceSLJson: Codable, Hashable, Sendable {
|
|||
let seederRegex: String?
|
||||
let leecherRegex: String?
|
||||
}
|
||||
|
||||
struct SourceRequestJson: Codable, Hashable, Sendable {
|
||||
let method: String?
|
||||
let headers: [String: String]?
|
||||
let body: String?
|
||||
}
|
||||
|
|
|
|||
110
Ferrite/Models/TorBoxModels.swift
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//
|
||||
// TorBoxModels.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/11/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TorBox {
|
||||
struct TBResponse<TBData: Codable>: Codable {
|
||||
let success: Bool
|
||||
let detail: String
|
||||
let data: TBData?
|
||||
}
|
||||
|
||||
// MARK: - InstantAvailability
|
||||
|
||||
enum InstantAvailabilityData: Codable {
|
||||
case links([InstantAvailabilityDataObject])
|
||||
case failure(InstantAvailabilityDataFailure)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
// Only continue if the data is a List which indicates a success
|
||||
if let linkArray = try? container.decode([InstantAvailabilityDataObject].self) {
|
||||
self = .links(linkArray)
|
||||
} else {
|
||||
let value = try container.decode(InstantAvailabilityDataFailure.self)
|
||||
self = .failure(value)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case let .links(array):
|
||||
try container.encode(array)
|
||||
case let .failure(value):
|
||||
try container.encode(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InstantAvailabilityDataObject: Codable, Sendable {
|
||||
let name: String
|
||||
let size: Int
|
||||
let hash: String
|
||||
let files: [InstantAvailabilityFile]
|
||||
}
|
||||
|
||||
struct InstantAvailabilityFile: Codable, Sendable {
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
struct InstantAvailabilityDataFailure: Codable, Sendable {
|
||||
let data: Bool
|
||||
}
|
||||
|
||||
struct CreateTorrentResponse: Codable, Sendable {
|
||||
let hash: String
|
||||
let torrentId: Int
|
||||
let authId: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hash
|
||||
case torrentId = "torrent_id"
|
||||
case authId = "auth_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct MyTorrentListResponse: Codable, Sendable {
|
||||
let id: Int
|
||||
let hash: String
|
||||
let name: String
|
||||
let downloadState: String
|
||||
let files: [MyTorrentListFile]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, hash, name, files
|
||||
case downloadState = "download_state"
|
||||
}
|
||||
}
|
||||
|
||||
struct MyTorrentListFile: Codable, Sendable {
|
||||
let id: Int
|
||||
let hash: String
|
||||
let name: String
|
||||
let shortName: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, hash, name
|
||||
case shortName = "short_name"
|
||||
}
|
||||
}
|
||||
|
||||
typealias RequestDLResponse = String
|
||||
|
||||
struct ControlTorrentRequest: Codable, Sendable {
|
||||
let torrentId: String
|
||||
let operation: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case operation
|
||||
case torrentId = "torrent_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,14 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public protocol DebridSource: AnyObservableObject {
|
||||
protocol DebridSource: AnyObservableObject {
|
||||
// ID of the service
|
||||
// var id: DebridInfo { get }
|
||||
var id: String { get }
|
||||
var abbreviation: String { get }
|
||||
var website: String { get }
|
||||
var description: String? { get }
|
||||
var cachedStatus: [String] { get }
|
||||
|
||||
// Auth variables
|
||||
var authProcessing: Bool { get set }
|
||||
|
|
@ -21,37 +23,50 @@ public protocol DebridSource: AnyObservableObject {
|
|||
// Manual API key
|
||||
var manualToken: String? { get }
|
||||
|
||||
// Instant availability variables
|
||||
var IAValues: [DebridIA] { get set }
|
||||
|
||||
// Cloud variables
|
||||
var cloudDownloads: [DebridCloudDownload] { get set }
|
||||
var cloudMagnets: [DebridCloudMagnet] { get set }
|
||||
var cloudTTL: Double { get set }
|
||||
|
||||
// Common authentication functions
|
||||
func setApiKey(_ key: String)
|
||||
func logout() async
|
||||
|
||||
// Instant availability variables
|
||||
var IAValues: [DebridIA] { get set }
|
||||
|
||||
// Instant availability functions
|
||||
func instantAvailability(magnets: [Magnet]) async throws
|
||||
|
||||
// Fetches a download link from a source
|
||||
// Include the instant availability information with the args
|
||||
// Torrents also checked here
|
||||
func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String
|
||||
// Cloud magnets also checked here
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?)
|
||||
|
||||
// Cloud variables
|
||||
var cloudDownloads: [DebridCloudDownload] { get set }
|
||||
var cloudTorrents: [DebridCloudTorrent] { get set }
|
||||
var cloudTTL: Double { get set }
|
||||
// Unrestricts a locked file
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String
|
||||
|
||||
// User downloads functions
|
||||
func getUserDownloads() async throws
|
||||
func checkUserDownloads(link: String) async throws -> String?
|
||||
func deleteDownload(downloadId: String) async throws
|
||||
func deleteUserDownload(downloadId: String) async throws
|
||||
|
||||
// User torrent functions
|
||||
func getUserTorrents() async throws
|
||||
func deleteTorrent(torrentId: String?) async throws
|
||||
// User magnet functions
|
||||
func getUserMagnets() async throws
|
||||
func deleteUserMagnet(cloudMagnetId: String?) async throws
|
||||
}
|
||||
|
||||
public protocol PollingDebridSource: DebridSource {
|
||||
extension DebridSource {
|
||||
var description: String? {
|
||||
nil
|
||||
}
|
||||
|
||||
var cachedStatus: [String] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
protocol PollingDebridSource: DebridSource {
|
||||
// Task reference for polling
|
||||
var authTask: Task<Void, Error>? { get set }
|
||||
|
||||
|
|
@ -59,7 +74,7 @@ public protocol PollingDebridSource: DebridSource {
|
|||
func getAuthUrl() async throws -> URL
|
||||
}
|
||||
|
||||
public protocol OAuthDebridSource: DebridSource {
|
||||
protocol OAuthDebridSource: DebridSource {
|
||||
// Fetches the auth URL
|
||||
func getAuthUrl() throws -> URL
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public protocol Plugin: ObservableObject, NSManagedObject {
|
||||
protocol Plugin: ObservableObject, NSManagedObject {
|
||||
var id: UUID { get set }
|
||||
var listId: UUID? { get set }
|
||||
var name: String { get set }
|
||||
|
|
@ -27,7 +27,7 @@ extension Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
public protocol PluginJson: Hashable {
|
||||
protocol PluginJson: Hashable {
|
||||
var name: String { get }
|
||||
var version: Int16 { get }
|
||||
var author: String? { get }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Application {
|
||||
class Application {
|
||||
static let shared = Application()
|
||||
|
||||
// OS name for Plugins to read. Lowercase for ease of use
|
||||
|
|
|
|||
27
Ferrite/Utils/FormDataBody.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
// FormDataBody.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/12/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FormDataBody {
|
||||
let boundary: String = UUID().uuidString
|
||||
let body: Data
|
||||
|
||||
init(params: [String: String]) {
|
||||
var body = Data()
|
||||
|
||||
for (key, value) in params {
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append("\(value)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
self.body = body
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ class ErasedObservableObject: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public protocol AnyObservableObject: AnyObject {
|
||||
protocol AnyObservableObject: AnyObject {
|
||||
var objectWillChange: ObservableObjectPublisher { get }
|
||||
}
|
||||
|
||||
|
|
@ -59,14 +59,14 @@ public protocol AnyObservableObject: AnyObject {
|
|||
/// Not all injected objects need this property wrapper. See the example projects for examples each
|
||||
/// way.
|
||||
@propertyWrapper
|
||||
public struct Store<ObjectType> {
|
||||
struct Store<ObjectType> {
|
||||
/// The underlying object being stored.
|
||||
public let wrappedValue: ObjectType
|
||||
let wrappedValue: ObjectType
|
||||
|
||||
// See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38
|
||||
fileprivate var _observableObject: ObservedObject<ErasedObservableObject>
|
||||
|
||||
@MainActor internal var observableObject: ErasedObservableObject {
|
||||
@MainActor var observableObject: ErasedObservableObject {
|
||||
_observableObject.wrappedValue
|
||||
}
|
||||
|
||||
|
|
@ -83,16 +83,16 @@ public struct Store<ObjectType> {
|
|||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
public var projectedValue: Wrapper {
|
||||
var projectedValue: Wrapper {
|
||||
Wrapper(self)
|
||||
}
|
||||
|
||||
/// Create a stored value on a custom scheduler.
|
||||
///
|
||||
/// Use this init to schedule updates on a specific scheduler other than `DispatchQueue.main`.
|
||||
public init<S: Scheduler>(wrappedValue: ObjectType,
|
||||
on scheduler: S,
|
||||
schedulerOptions: S.SchedulerOptions? = nil)
|
||||
init<S: Scheduler>(wrappedValue: ObjectType,
|
||||
on scheduler: S,
|
||||
schedulerOptions: S.SchedulerOptions? = nil)
|
||||
{
|
||||
self.wrappedValue = wrappedValue
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ public struct Store<ObjectType> {
|
|||
/// Create a stored value which publishes on the main thread.
|
||||
///
|
||||
/// To control when updates are published, see ``init(wrappedValue:on:schedulerOptions:)``.
|
||||
public init(wrappedValue: ObjectType) {
|
||||
init(wrappedValue: ObjectType) {
|
||||
self.init(wrappedValue: wrappedValue, on: DispatchQueue.main)
|
||||
}
|
||||
|
||||
|
|
@ -120,15 +120,15 @@ public struct Store<ObjectType> {
|
|||
/// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper)
|
||||
/// type.
|
||||
@dynamicMemberLookup
|
||||
public struct Wrapper {
|
||||
struct Wrapper {
|
||||
private var store: Store
|
||||
|
||||
internal init(_ store: Store<ObjectType>) {
|
||||
init(_ store: Store<ObjectType>) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// Returns a binding to the resulting value of a given key path.
|
||||
public subscript<Subject>(
|
||||
subscript<Subject>(
|
||||
dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>
|
||||
) -> Binding<Subject> {
|
||||
Binding {
|
||||
|
|
@ -141,7 +141,7 @@ public struct Store<ObjectType> {
|
|||
}
|
||||
|
||||
extension Store: DynamicProperty {
|
||||
public nonisolated mutating func update() {
|
||||
nonisolated mutating func update() {
|
||||
_observableObject.update()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class BackupManager: ObservableObject {
|
||||
class BackupManager: ObservableObject {
|
||||
// Constant variable for backup versions
|
||||
let latestBackupVersion: Int = 2
|
||||
private let latestBackupVersion: Int = 2
|
||||
|
||||
var logManager: LoggingManager?
|
||||
|
||||
|
|
@ -21,17 +21,17 @@ public class BackupManager: ObservableObject {
|
|||
@Published var selectedBackupUrl: URL?
|
||||
|
||||
@MainActor
|
||||
func updateRestoreCompletedMessage(newString: String) {
|
||||
private func updateRestoreCompletedMessage(newString: String) {
|
||||
restoreCompletedMessage.append(newString)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func toggleRestoreCompletedAlert() {
|
||||
private func toggleRestoreCompletedAlert() {
|
||||
showRestoreCompletedAlert.toggle()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateBackupUrls(newUrl: URL) {
|
||||
private func updateBackupUrls(newUrl: URL) {
|
||||
backupUrls.append(newUrl)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,26 +9,21 @@ import Foundation
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public class DebridManager: ObservableObject {
|
||||
class DebridManager: ObservableObject {
|
||||
// Linked classes
|
||||
var logManager: LoggingManager?
|
||||
@Published var realDebrid: RealDebrid = .init()
|
||||
@Published var allDebrid: AllDebrid = .init()
|
||||
@Published var premiumize: Premiumize = .init()
|
||||
@Published var torbox: TorBox = .init()
|
||||
@Published var offcloud: OffCloud = .init()
|
||||
|
||||
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize]
|
||||
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
|
||||
|
||||
// UI Variables
|
||||
@Published var showWebView: Bool = false
|
||||
@Published var showAuthSession: Bool = false
|
||||
|
||||
var hasEnabledDebrids: Bool {
|
||||
debridSources.contains { $0.isLoggedIn }
|
||||
}
|
||||
|
||||
var enabledDebridCount: Int {
|
||||
debridSources.filter(\.isLoggedIn).count
|
||||
}
|
||||
@Published var enabledDebrids: [DebridSource] = []
|
||||
|
||||
@Published var selectedDebridSource: DebridSource? {
|
||||
didSet {
|
||||
|
|
@ -38,9 +33,10 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
var selectedDebridItem: DebridIA?
|
||||
var selectedDebridFile: DebridIAFile?
|
||||
var requiresUnrestrict: Bool = false
|
||||
|
||||
// TODO: Figure out a way to remove this var
|
||||
var selectedOAuthDebridSource: OAuthDebridSource?
|
||||
private var selectedOAuthDebridSource: OAuthDebridSource?
|
||||
|
||||
@Published var filteredIAStatus: Set<IAStatus> = []
|
||||
|
||||
|
|
@ -48,18 +44,15 @@ public class DebridManager: ObservableObject {
|
|||
var downloadUrl: String = ""
|
||||
var authUrl: URL?
|
||||
|
||||
// RealDebrid auth variables
|
||||
var realDebridAuthProcessing: Bool = false
|
||||
|
||||
@Published var showDeleteAlert: Bool = false
|
||||
|
||||
// AllDebrid auth variables
|
||||
var allDebridAuthProcessing: Bool = false
|
||||
|
||||
// Premiumize auth variables
|
||||
var premiumizeAuthProcessing: Bool = false
|
||||
@Published var showWebLoginAlert: Bool = false
|
||||
@Published var showNotImplementedAlert: Bool = false
|
||||
@Published var notImplementedMessage: String = ""
|
||||
|
||||
init() {
|
||||
// Update the UI for debrid services that are enabled
|
||||
enabledDebrids = debridSources.filter(\.isLoggedIn)
|
||||
|
||||
// Set the preferred service. Contains migration logic for earlier versions
|
||||
if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") {
|
||||
let debridServiceId: String?
|
||||
|
|
@ -83,7 +76,7 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
// TODO: Remove after v0.8.0
|
||||
// Function to migrate the preferred service to the new string ID format
|
||||
public func migratePreferredService(_ idInt: Int) -> String? {
|
||||
private func migratePreferredService(_ idInt: Int) -> String? {
|
||||
// Undo the EnabledDebrids key
|
||||
UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray")
|
||||
|
||||
|
|
@ -92,7 +85,7 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
// Wrapper function to match error descriptions
|
||||
// Error can be suppressed to end user but must be printed in logs
|
||||
func sendDebridError(
|
||||
private func sendDebridError(
|
||||
_ error: Error,
|
||||
prefix: String,
|
||||
presentError: Bool = true,
|
||||
|
|
@ -119,20 +112,20 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Cleans all cached IA values in the event of a full IA refresh
|
||||
public func clearIAValues() {
|
||||
func clearIAValues() {
|
||||
for debridSource in debridSources {
|
||||
debridSource.IAValues = []
|
||||
}
|
||||
}
|
||||
|
||||
// Clears all selected files and items
|
||||
public func clearSelectedDebridItems() {
|
||||
func clearSelectedDebridItems() {
|
||||
selectedDebridItem = nil
|
||||
selectedDebridFile = nil
|
||||
}
|
||||
|
||||
// Common function to populate hashes for debrid services
|
||||
public func populateDebridIA(_ resultMagnets: [Magnet]) async {
|
||||
func populateDebridIA(_ resultMagnets: [Magnet]) async {
|
||||
for debridSource in debridSources {
|
||||
if !debridSource.isLoggedIn {
|
||||
continue
|
||||
|
|
@ -148,7 +141,7 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Common function to match a magnet hash with a provided debrid service
|
||||
public func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
|
||||
func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
|
||||
guard let magnetHash = magnet.hash else {
|
||||
return .none
|
||||
}
|
||||
|
|
@ -162,9 +155,9 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func selectDebridResult(magnet: Magnet) -> Bool {
|
||||
func selectDebridResult(magnet: Magnet) -> Bool {
|
||||
guard let magnetHash = magnet.hash else {
|
||||
logManager?.error("DebridManager: Could not find the torrent magnet hash")
|
||||
logManager?.error("DebridManager: Could not find the magnet hash")
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -174,9 +167,14 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) {
|
||||
selectedDebridItem = IAItem
|
||||
|
||||
if IAItem.files.count == 1 {
|
||||
selectedDebridFile = IAItem.files[safe: 0]
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
logManager?.error("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
|
||||
logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -184,14 +182,14 @@ public class DebridManager: ObservableObject {
|
|||
// MARK: - Authentication UI linked functions
|
||||
|
||||
// Common function to delegate what debrid service to authenticate with
|
||||
public func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
|
||||
func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
|
||||
defer {
|
||||
// Don't cancel processing if using OAuth
|
||||
if !(debridSource is OAuthDebridSource) {
|
||||
debridSource.authProcessing = false
|
||||
}
|
||||
|
||||
if enabledDebridCount == 1 {
|
||||
if enabledDebrids.count == 1 {
|
||||
selectedDebridSource = debridSource
|
||||
}
|
||||
}
|
||||
|
|
@ -199,6 +197,8 @@ public class DebridManager: ObservableObject {
|
|||
// Set an API key if manually provided
|
||||
if let apiKey {
|
||||
debridSource.setApiKey(apiKey)
|
||||
enabledDebrids.append(debridSource)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +211,7 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
if validateAuthUrl(authUrl) {
|
||||
try await pollingSource.authTask?.value
|
||||
enabledDebrids.append(debridSource)
|
||||
} else {
|
||||
throw DebridError.AuthQuery(description: "The authentication URL was invalid")
|
||||
}
|
||||
|
|
@ -229,8 +230,12 @@ public class DebridManager: ObservableObject {
|
|||
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
|
||||
}
|
||||
} else {
|
||||
// Let the user know that a traditional auth method doesn't exist
|
||||
showWebLoginAlert.toggle()
|
||||
|
||||
logManager?.error(
|
||||
"DebridManager: Auth: Could not figure out the authentication type for \(debridSource.id). Is this configured properly?"
|
||||
"DebridManager: Auth: \(debridSource.id) does not have a login portal.",
|
||||
showToast: false
|
||||
)
|
||||
|
||||
return
|
||||
|
|
@ -253,7 +258,7 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Wrapper function to validate and present an auth URL to the user
|
||||
@discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
|
||||
@discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
|
||||
guard let url else {
|
||||
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
|
||||
return false
|
||||
|
|
@ -270,9 +275,9 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Currently handles Premiumize callback
|
||||
public func handleAuthCallback(url: URL?, error: Error?) async {
|
||||
func handleAuthCallback(url: URL?, error: Error?) async {
|
||||
defer {
|
||||
if enabledDebridCount == 1 {
|
||||
if enabledDebrids.count == 1 {
|
||||
selectedDebridSource = selectedOAuthDebridSource
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +295,7 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
if let callbackUrl = url {
|
||||
try oauthDebridSource.handleAuthCallback(url: callbackUrl)
|
||||
enabledDebrids.append(oauthDebridSource)
|
||||
} else {
|
||||
throw DebridError.AuthQuery(description: "The callback URL was invalid")
|
||||
}
|
||||
|
|
@ -300,22 +306,29 @@ public class DebridManager: ObservableObject {
|
|||
|
||||
// MARK: - Logout UI functions
|
||||
|
||||
public func logout(_ debridSource: some DebridSource) async {
|
||||
func logout(_ debridSource: some DebridSource) async {
|
||||
await debridSource.logout()
|
||||
|
||||
if selectedDebridSource?.id == debridSource.id {
|
||||
selectedDebridSource = nil
|
||||
}
|
||||
|
||||
enabledDebrids.removeAll { $0.id == debridSource.id }
|
||||
}
|
||||
|
||||
// MARK: - Debrid fetch UI linked functions
|
||||
|
||||
// Common function to delegate what debrid service to fetch from
|
||||
// Cloudinfo is used for any extra information provided by debrid cloud
|
||||
public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
|
||||
func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
|
||||
defer {
|
||||
currentDebridTask = nil
|
||||
logManager?.hideIndeterminateToast()
|
||||
|
||||
if !requiresUnrestrict {
|
||||
clearSelectedDebridItems()
|
||||
}
|
||||
|
||||
currentDebridTask = nil
|
||||
}
|
||||
|
||||
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||
|
|
@ -328,18 +341,37 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
|
||||
do {
|
||||
// Cleanup beforehand
|
||||
requiresUnrestrict = false
|
||||
|
||||
if let cloudInfo {
|
||||
downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? ""
|
||||
return
|
||||
}
|
||||
|
||||
if let magnet {
|
||||
let downloadLink = try await debridSource.getDownloadLink(
|
||||
let (restrictedFile, newIA) = try await debridSource.getRestrictedFile(
|
||||
magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile
|
||||
)
|
||||
|
||||
// Indicate that a link needs to be selected (batch)
|
||||
if let newIA {
|
||||
if newIA.files.isEmpty {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
selectedDebridItem = newIA
|
||||
requiresUnrestrict = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
guard let restrictedFile else {
|
||||
throw DebridError.FailedRequest(description: "No files found for your request")
|
||||
}
|
||||
|
||||
// Update the UI
|
||||
downloadUrl = downloadLink
|
||||
downloadUrl = try await debridSource.unrestrictFile(restrictedFile)
|
||||
} else {
|
||||
throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API")
|
||||
}
|
||||
|
|
@ -353,22 +385,48 @@ public class DebridManager: ObservableObject {
|
|||
default:
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unrestrictDownload() async {
|
||||
defer {
|
||||
logManager?.hideIndeterminateToast()
|
||||
requiresUnrestrict = false
|
||||
clearSelectedDebridItems()
|
||||
currentDebridTask = nil
|
||||
}
|
||||
|
||||
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||
self.currentDebridTask?.cancel()
|
||||
self.currentDebridTask = nil
|
||||
})
|
||||
|
||||
guard let debridFile = selectedDebridFile, let debridSource = selectedDebridSource else {
|
||||
logManager?.error("DebridManager: Could not unrestrict the selected debrid file.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let downloadLink = try await debridSource.unrestrictFile(debridFile)
|
||||
|
||||
downloadUrl = downloadLink
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) unrestrict error", cancelString: "Unrestrict cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to handle cloud fetching
|
||||
public func fetchDebridCloud(bypassTTL: Bool = false) async {
|
||||
func fetchDebridCloud(bypassTTL: Bool = false) async {
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL {
|
||||
do {
|
||||
// Populates the inner downloads and torrent arrays
|
||||
// Populates the inner downloads and magnet arrays
|
||||
try await selectedSource.getUserDownloads()
|
||||
try await selectedSource.getUserTorrents()
|
||||
try await selectedSource.getUserMagnets()
|
||||
|
||||
// Update the TTL to 5 minutes from now
|
||||
selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300
|
||||
|
|
@ -381,31 +439,55 @@ public class DebridManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func deleteCloudDownload(_ download: DebridCloudDownload) async {
|
||||
func deleteCloudDownload(_ download: DebridCloudDownload) async {
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await selectedSource.deleteDownload(downloadId: download.downloadId)
|
||||
try await selectedSource.deleteUserDownload(downloadId: download.id)
|
||||
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
|
||||
switch error {
|
||||
case DebridError.NotImplemented:
|
||||
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
|
||||
|
||||
notImplementedMessage = message
|
||||
showNotImplementedAlert.toggle()
|
||||
logManager?.error(
|
||||
"DebridManager: \(message)",
|
||||
showToast: false
|
||||
)
|
||||
default:
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteCloudTorrent(_ torrent: DebridCloudTorrent) async {
|
||||
func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async {
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await selectedSource.deleteTorrent(torrentId: torrent.torrentId)
|
||||
try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id)
|
||||
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error")
|
||||
switch error {
|
||||
case DebridError.NotImplemented:
|
||||
let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website."
|
||||
|
||||
notImplementedMessage = message
|
||||
showNotImplementedAlert.toggle()
|
||||
logManager?.error(
|
||||
"DebridManager: \(message)",
|
||||
showToast: false
|
||||
)
|
||||
default:
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ToastViewModel.swift
|
||||
// LoggingManager.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/19/22.
|
||||
|
|
@ -70,8 +70,8 @@ class LoggingManager: ObservableObject {
|
|||
|
||||
// TODO: Maybe append to a constant logfile?
|
||||
|
||||
public func info(_ message: String,
|
||||
description: String? = nil)
|
||||
func info(_ message: String,
|
||||
description: String? = nil)
|
||||
{
|
||||
let log = Log(
|
||||
level: .info,
|
||||
|
|
@ -88,8 +88,8 @@ class LoggingManager: ObservableObject {
|
|||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
public func warn(_ message: String,
|
||||
description: String? = nil)
|
||||
func warn(_ message: String,
|
||||
description: String? = nil)
|
||||
{
|
||||
let log = Log(
|
||||
level: .warn,
|
||||
|
|
@ -106,9 +106,9 @@ class LoggingManager: ObservableObject {
|
|||
print("LOG: \(log.toMessage())")
|
||||
}
|
||||
|
||||
public func error(_ message: String,
|
||||
description: String? = nil,
|
||||
showToast: Bool = true)
|
||||
func error(_ message: String,
|
||||
description: String? = nil,
|
||||
showToast: Bool = true)
|
||||
{
|
||||
let log = Log(
|
||||
level: .error,
|
||||
|
|
@ -132,7 +132,7 @@ class LoggingManager: ObservableObject {
|
|||
|
||||
// MARK: - Indeterminate functions
|
||||
|
||||
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
||||
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
||||
indeterminateToastDescription = description
|
||||
|
||||
if let cancelAction {
|
||||
|
|
@ -144,13 +144,13 @@ class LoggingManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func hideIndeterminateToast() {
|
||||
func hideIndeterminateToast() {
|
||||
showIndeterminateToast = false
|
||||
indeterminateToastDescription = ""
|
||||
indeterminateCancelAction = nil
|
||||
}
|
||||
|
||||
public func exportLogs() {
|
||||
func exportLogs() {
|
||||
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
|
||||
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public class NavigationViewModel: ObservableObject {
|
||||
class NavigationViewModel: ObservableObject {
|
||||
var logManager: LoggingManager?
|
||||
|
||||
// Used between SearchResultsView and MagnetChoiceView
|
||||
public enum ChoiceSheetType: Identifiable {
|
||||
public var id: Int {
|
||||
enum ChoiceSheetType: Identifiable {
|
||||
var id: Int {
|
||||
hashValue
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ public class NavigationViewModel: ObservableObject {
|
|||
@Published var currentSortFilter: SortFilter?
|
||||
@Published var currentSortOrder: SortOrder = .forward
|
||||
|
||||
public func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||
switch currentSortFilter {
|
||||
case .leechers:
|
||||
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
|
||||
|
|
@ -97,7 +97,7 @@ public class NavigationViewModel: ObservableObject {
|
|||
|
||||
@Published var searchPrompt: String = "Search"
|
||||
@Published var lastSearchPromptIndex: Int = -1
|
||||
let searchBarTextArray: [String] = [
|
||||
private let searchBarTextArray: [String] = [
|
||||
"What's on your mind?",
|
||||
"Discover something interesting",
|
||||
"Find an engaging show",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SourceManager.swift
|
||||
// PluginManager.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/25/22.
|
||||
|
|
@ -9,7 +9,7 @@ import Foundation
|
|||
import SwiftUI
|
||||
import Yams
|
||||
|
||||
public class PluginManager: ObservableObject {
|
||||
class PluginManager: ObservableObject {
|
||||
var logManager: LoggingManager?
|
||||
let kodi: Kodi = .init()
|
||||
|
||||
|
|
@ -25,18 +25,18 @@ public class PluginManager: ObservableObject {
|
|||
@Published var actionSuccessAlertMessage: String = ""
|
||||
|
||||
@MainActor
|
||||
func cleanAvailablePlugins() {
|
||||
private func cleanAvailablePlugins() {
|
||||
availableSources = []
|
||||
availableActions = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
|
||||
private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
|
||||
availableSources += newPlugins.availableSources
|
||||
availableActions += newPlugins.availableActions
|
||||
}
|
||||
|
||||
public func fetchPluginsFromUrl() async {
|
||||
func fetchPluginsFromUrl() async {
|
||||
let pluginListRequest = PluginList.fetchRequest()
|
||||
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
|
||||
await logManager?.error("PluginManager: No plugin lists found")
|
||||
|
|
@ -97,7 +97,7 @@ public class PluginManager: ObservableObject {
|
|||
await logManager?.info("Plugin list fetch finished")
|
||||
}
|
||||
|
||||
func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
|
||||
private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
|
||||
var tempSources: [SourceJson] = []
|
||||
var tempActions: [ActionJson] = []
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
|
||||
func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
|
||||
private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
|
||||
let osArray = deeplinks.filter { deeplink in
|
||||
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
|
||||
}
|
||||
|
|
@ -244,7 +244,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
||||
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
||||
switch String(describing: PJ.self) {
|
||||
case "SourceJson":
|
||||
return availableSources as? [PJ] ?? []
|
||||
|
|
@ -256,7 +256,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Checks if the current app version is supported by the source
|
||||
func checkAppVersion(minVersion: String?) -> Bool {
|
||||
private func checkAppVersion(minVersion: String?) -> Bool {
|
||||
// If there's no min version, assume that every version is supported
|
||||
guard let minVersion else {
|
||||
return true
|
||||
|
|
@ -266,7 +266,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
|
||||
// Fetches sources using the background context
|
||||
public func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
|
||||
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
|
||||
|
|
@ -279,7 +279,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
public func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
|
||||
func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
|
||||
let context = PersistenceController.shared.backgroundContext
|
||||
|
||||
guard let urlString else {
|
||||
|
|
@ -332,7 +332,7 @@ public class PluginManager: ObservableObject {
|
|||
|
||||
// The iOS version of Ferrite only runs deeplink actions
|
||||
@MainActor
|
||||
public func runDeeplinkAction(_ action: Action, urlString: String?) {
|
||||
func runDeeplinkAction(_ action: Action, urlString: String?) {
|
||||
guard let deeplink = action.deeplink, let urlString else {
|
||||
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
|
||||
showActionErrorAlert.toggle()
|
||||
|
|
@ -355,7 +355,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
public func sendToKodi(urlString: String?, server: KodiServer) async {
|
||||
func sendToKodi(urlString: String?, server: KodiServer) async {
|
||||
guard let urlString else {
|
||||
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
|
||||
showActionErrorAlert.toggle()
|
||||
|
|
@ -380,7 +380,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
||||
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
||||
guard let actionJson else {
|
||||
await logManager?.error("Action addition: No action present. Contact the app dev!")
|
||||
return
|
||||
|
|
@ -448,7 +448,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
||||
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
||||
guard let sourceJson else {
|
||||
await logManager?.error("Source addition: No source present. Contact the app dev!")
|
||||
return
|
||||
|
|
@ -535,7 +535,7 @@ public class PluginManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
||||
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceApi = SourceApi(context: backgroundContext)
|
||||
|
|
@ -570,7 +570,8 @@ public class PluginManager: ObservableObject {
|
|||
newSource.api = newSourceApi
|
||||
}
|
||||
|
||||
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
|
||||
// TODO: Migrate parser addition to a common protocol
|
||||
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
|
||||
|
|
@ -578,6 +579,13 @@ public class PluginManager: ObservableObject {
|
|||
newSourceJsonParser.results = jsonParserJson.results
|
||||
newSourceJsonParser.subResults = jsonParserJson.subResults
|
||||
|
||||
if let requestJson = newSourceJsonParser.request {
|
||||
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||
newParserRequest.method = requestJson.method
|
||||
newParserRequest.headers = requestJson.headers
|
||||
newParserRequest.body = requestJson.body
|
||||
}
|
||||
|
||||
// Tune these complex queries to the final JSON parser format
|
||||
if let magnetLinkJson = jsonParserJson.magnetLink {
|
||||
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||
|
|
@ -638,7 +646,7 @@ public class PluginManager: ObservableObject {
|
|||
newSource.jsonParser = newSourceJsonParser
|
||||
}
|
||||
|
||||
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
|
||||
private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceRssParser = SourceRssParser(context: backgroundContext)
|
||||
|
|
@ -646,6 +654,13 @@ public class PluginManager: ObservableObject {
|
|||
newSourceRssParser.searchUrl = rssParserJson.searchUrl
|
||||
newSourceRssParser.items = rssParserJson.items
|
||||
|
||||
if let requestJson = newSourceRssParser.request {
|
||||
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||
newParserRequest.method = requestJson.method
|
||||
newParserRequest.headers = requestJson.headers
|
||||
newParserRequest.body = requestJson.body
|
||||
}
|
||||
|
||||
if let magnetLinkJson = rssParserJson.magnetLink {
|
||||
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||
newSourceMagnetLink.query = magnetLinkJson.query
|
||||
|
|
@ -710,7 +725,7 @@ public class PluginManager: ObservableObject {
|
|||
newSource.rssParser = newSourceRssParser
|
||||
}
|
||||
|
||||
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
|
||||
private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
|
||||
|
|
@ -726,6 +741,16 @@ public class PluginManager: ObservableObject {
|
|||
newSourceHtmlParser.subName = newSourceSubName
|
||||
}
|
||||
|
||||
if let requestJson = htmlParserJson.request {
|
||||
print(requestJson)
|
||||
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||
newParserRequest.method = requestJson.method
|
||||
newParserRequest.headers = requestJson.headers
|
||||
newParserRequest.body = requestJson.body
|
||||
|
||||
newSourceHtmlParser.request = newParserRequest
|
||||
}
|
||||
|
||||
// Adds a title complex query
|
||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||
newSourceTitle.query = htmlParserJson.title.query
|
||||
|
|
@ -770,7 +795,7 @@ public class PluginManager: ObservableObject {
|
|||
|
||||
// Adds a plugin list
|
||||
// Can move this to PersistenceController if needed
|
||||
public func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
||||
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
|
||||
|
|
|
|||
|
|
@ -27,18 +27,18 @@ class ScrapingViewModel: ObservableObject {
|
|||
|
||||
// Only add results with valid magnet hashes to the search results array
|
||||
@MainActor
|
||||
func updateSearchResults(newResults: [SearchResult]) {
|
||||
private func updateSearchResults(newResults: [SearchResult]) {
|
||||
searchResults += newResults
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func clearSearchResults() {
|
||||
private func clearSearchResults() {
|
||||
searchResults = []
|
||||
}
|
||||
|
||||
@Published var currentSourceNames: Set<String> = []
|
||||
@MainActor
|
||||
func updateCurrentSourceNames(_ newName: String) {
|
||||
private func updateCurrentSourceNames(_ newName: String) {
|
||||
currentSourceNames.insert(newName)
|
||||
logManager?.updateIndeterminateToast(
|
||||
"Loading \(currentSourceNames.joined(separator: ", "))",
|
||||
|
|
@ -47,7 +47,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
func removeCurrentSourceName(_ removedName: String) {
|
||||
private func removeCurrentSourceName(_ removedName: String) {
|
||||
currentSourceNames.remove(removedName)
|
||||
logManager?.updateIndeterminateToast(
|
||||
"Loading \(currentSourceNames.joined(separator: ", "))",
|
||||
|
|
@ -56,17 +56,39 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
func clearCurrentSourceNames() {
|
||||
private func clearCurrentSourceNames() {
|
||||
currentSourceNames = []
|
||||
logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil)
|
||||
}
|
||||
|
||||
// Utility function to print source specific errors
|
||||
func sendSourceError(_ description: String) async {
|
||||
private func sendSourceError(_ description: String) async {
|
||||
await logManager?.error(description, showToast: false)
|
||||
}
|
||||
|
||||
public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
|
||||
// Substitutes the given string with an arbitrary parameter dictionary
|
||||
private func substituteParams(_ input: String, with params: [String: String]) -> String {
|
||||
let replaced = params.reduce(input) { result, param -> String in
|
||||
result.replacingOccurrences(of: "{\(param.key)}", with: param.value)
|
||||
}
|
||||
|
||||
return replaced
|
||||
}
|
||||
|
||||
// Cleans a SourceRequest's body and headers to be substituted
|
||||
private func cleanRequest(request: SourceRequest, params: [String: String]) -> SourceRequest {
|
||||
if let body = request.body {
|
||||
request.body = substituteParams(body, with: params)
|
||||
}
|
||||
|
||||
if let headers = request.headers {
|
||||
request.headers = headers.mapValues { substituteParams($0, with: params) }
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
|
||||
await logManager?.info("Started scanning sources for query \"\(searchText)\"")
|
||||
|
||||
if sources.isEmpty {
|
||||
|
|
@ -80,7 +102,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
|
||||
cleanedSearchText = searchText.lowercased()
|
||||
|
||||
if await !debridManager.hasEnabledDebrids {
|
||||
if await !debridManager.enabledDebrids.isEmpty {
|
||||
await debridManager.clearIAValues()
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +136,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
var failedSourceNames: [String] = []
|
||||
for await (requestResult, sourceName) in group {
|
||||
if let requestResult {
|
||||
if await debridManager.hasEnabledDebrids {
|
||||
if await !debridManager.enabledDebrids.isEmpty {
|
||||
await debridManager.populateDebridIA(requestResult.magnets)
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +166,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func executeParser(source: Source) async -> SearchRequestResult? {
|
||||
private func executeParser(source: Source) async -> SearchRequestResult? {
|
||||
guard let website = source.website else {
|
||||
await logManager?.error("Scraping: The base URL could not be found for source \(source.name)")
|
||||
|
||||
|
|
@ -160,19 +182,26 @@ class ScrapingViewModel: ObservableObject {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Initial params dict to reference
|
||||
// More params are added here as needed
|
||||
var params: [String: String] = [
|
||||
"query": encodedQuery,
|
||||
"queryFirstLetter": encodedQuery.first.map { String($0).lowercased() } ?? ""
|
||||
]
|
||||
|
||||
switch preferredParser {
|
||||
case .scraping:
|
||||
if let htmlParser = source.htmlParser {
|
||||
let replacedSearchUrl = htmlParser.searchUrl.map {
|
||||
$0
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
substituteParams($0, with: params)
|
||||
}
|
||||
|
||||
let data = await handleUrls(
|
||||
website: website,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls,
|
||||
sourceName: source.name
|
||||
sourceName: source.name,
|
||||
requestParams: htmlParser.request.map { cleanRequest(request: $0, params: params) }
|
||||
)
|
||||
|
||||
if let data,
|
||||
|
|
@ -183,23 +212,25 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
case .rss:
|
||||
if let rssParser = source.rssParser {
|
||||
let replacedSearchUrl = rssParser.searchUrl
|
||||
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
params.updateValue(source.api?.clientSecret?.value ?? "", forKey: "secret")
|
||||
|
||||
let replacedSearchUrl = substituteParams(rssParser.searchUrl, with: params)
|
||||
|
||||
// Do not use fallback URLs if the base URL isn't used
|
||||
let data: Data?
|
||||
if let rssUrl = rssParser.rssUrl {
|
||||
data = await fetchWebsiteData(
|
||||
urlString: rssUrl + replacedSearchUrl,
|
||||
sourceName: source.name
|
||||
sourceName: source.name,
|
||||
requestParams: rssParser.request
|
||||
)
|
||||
} else {
|
||||
data = await handleUrls(
|
||||
website: website,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls,
|
||||
sourceName: source.name
|
||||
sourceName: source.name,
|
||||
requestParams: rssParser.request
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -211,8 +242,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
case .siteApi:
|
||||
if let jsonParser = source.jsonParser {
|
||||
var replacedSearchUrl = jsonParser.searchUrl
|
||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
||||
var replacedSearchUrl = substituteParams(jsonParser.searchUrl, with: params)
|
||||
|
||||
// Handle anything API related including tokens, client IDs, and appending the API URL
|
||||
// The source API key is for APIs that require extra credentials or use a different URL
|
||||
|
|
@ -248,7 +278,8 @@ class ScrapingViewModel: ObservableObject {
|
|||
website: passedUrl,
|
||||
replacedSearchUrl: replacedSearchUrl,
|
||||
fallbackUrls: source.fallbackUrls,
|
||||
sourceName: source.name
|
||||
sourceName: source.name,
|
||||
requestParams: jsonParser.request
|
||||
)
|
||||
|
||||
if let data {
|
||||
|
|
@ -263,16 +294,16 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// Checks the base URL for any website data then iterates through the fallback URLs
|
||||
func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String) async -> Data? {
|
||||
private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? {
|
||||
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
|
||||
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
|
||||
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
|
||||
return data
|
||||
}
|
||||
|
||||
if let fallbackUrls {
|
||||
for fallbackUrl in fallbackUrls {
|
||||
let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
|
||||
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
|
||||
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
@ -281,12 +312,12 @@ class ScrapingViewModel: ObservableObject {
|
|||
return nil
|
||||
}
|
||||
|
||||
public func handleApiCredential(_ credential: SourceApiCredential,
|
||||
replacement: String,
|
||||
searchUrl: String,
|
||||
apiUrl: String?,
|
||||
website: String,
|
||||
sourceName: String) async -> String?
|
||||
private func handleApiCredential(_ credential: SourceApiCredential,
|
||||
replacement: String,
|
||||
searchUrl: String,
|
||||
apiUrl: String?,
|
||||
website: String,
|
||||
sourceName: String) async -> String?
|
||||
{
|
||||
// Is the credential expired
|
||||
var isExpired = false
|
||||
|
|
@ -298,8 +329,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
|
||||
// Fetch a new credential if it's expired or doesn't exist yet
|
||||
if let value = credential.value, !isExpired {
|
||||
return searchUrl
|
||||
.replacingOccurrences(of: replacement, with: value)
|
||||
return substituteParams(searchUrl, with: [replacement: value])
|
||||
} else if
|
||||
credential.value == nil || isExpired,
|
||||
let credentialUrl = credential.urlString,
|
||||
|
|
@ -323,9 +353,9 @@ class ScrapingViewModel: ObservableObject {
|
|||
return nil
|
||||
}
|
||||
|
||||
public func fetchApiCredential(urlString: String,
|
||||
credential: SourceApiCredential,
|
||||
sourceName: String) async -> String?
|
||||
private func fetchApiCredential(urlString: String,
|
||||
credential: SourceApiCredential,
|
||||
sourceName: String) async -> String?
|
||||
{
|
||||
guard let url = URL(string: urlString) else {
|
||||
await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.")
|
||||
|
|
@ -369,7 +399,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// Fetches the data for a URL
|
||||
public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? {
|
||||
private func fetchWebsiteData(urlString: String, sourceName: String, requestParams: SourceRequest?) async -> Data? {
|
||||
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!")
|
||||
|
||||
|
|
@ -388,7 +418,12 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
let request = URLRequest(url: url, timeoutInterval: timeout)
|
||||
var request = URLRequest(url: url, timeoutInterval: timeout)
|
||||
request.httpMethod = requestParams?.method
|
||||
request.httpBody = requestParams?.body?.data(using: .utf8)
|
||||
requestParams?.headers?.forEach { field, value in
|
||||
request.addValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
|
@ -411,7 +446,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
||||
private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
||||
guard let jsonParser = source.jsonParser else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -486,10 +521,10 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// TODO: Add regex parsing for API
|
||||
public func parseJsonResult(_ result: JSON,
|
||||
jsonParser: SourceJsonParser,
|
||||
source: Source,
|
||||
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
||||
private func parseJsonResult(_ result: JSON,
|
||||
jsonParser: SourceJsonParser,
|
||||
source: Source,
|
||||
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
||||
{
|
||||
// Enforce these parsers
|
||||
guard let titleParser = jsonParser.title else {
|
||||
|
|
@ -580,7 +615,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// RSS feed scraper
|
||||
public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
|
||||
private func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
|
||||
guard let rssParser = source.rssParser else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -715,11 +750,11 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// Complex query parsing for RSS scraping
|
||||
func runRssComplexQuery(item: Element,
|
||||
query: String,
|
||||
attribute: String,
|
||||
discriminator: String?,
|
||||
regexString: String?) throws -> String?
|
||||
private func runRssComplexQuery(item: Element,
|
||||
query: String,
|
||||
attribute: String,
|
||||
discriminator: String?,
|
||||
regexString: String?) throws -> String?
|
||||
{
|
||||
var parsedValue: String?
|
||||
|
||||
|
|
@ -748,7 +783,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// HTML scraper
|
||||
public func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
|
||||
private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
|
||||
guard let htmlParser = source.htmlParser else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -800,7 +835,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
|
||||
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
|
||||
guard
|
||||
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name),
|
||||
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request),
|
||||
let magnetHtml = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
continue
|
||||
|
|
@ -885,7 +920,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
if let leecherQuery = seederLeecher.seeders {
|
||||
if let leecherQuery = seederLeecher.leechers {
|
||||
leechers = try? runHtmlComplexQuery(
|
||||
row: row,
|
||||
query: leecherQuery,
|
||||
|
|
@ -920,10 +955,10 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// Complex query parsing for HTML scraping
|
||||
func runHtmlComplexQuery(row: Element,
|
||||
query: String,
|
||||
attribute: String,
|
||||
regexString: String?) throws -> String?
|
||||
private func runHtmlComplexQuery(row: Element,
|
||||
query: String,
|
||||
attribute: String,
|
||||
regexString: String?) throws -> String?
|
||||
{
|
||||
var parsedValue: String?
|
||||
|
||||
|
|
@ -945,7 +980,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func runRegex(parsedValue: String, regexString: String) -> String? {
|
||||
private func runRegex(parsedValue: String, regexString: String) -> String? {
|
||||
// TODO: Maybe dynamically parse flags
|
||||
let replacedRegexString = regexString
|
||||
.replacingOccurrences(of: "{query}", with: cleanedSearchText)
|
||||
|
|
@ -968,7 +1003,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func parseSizeString(sizeString: String) -> String? {
|
||||
private func parseSizeString(sizeString: String) -> String? {
|
||||
// Test if the string can be a full integer
|
||||
guard let size = Int(sizeString) else {
|
||||
return nil
|
||||
|
|
@ -990,7 +1025,7 @@ class ScrapingViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
||||
private func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let hasCredentials = api.clientId != nil || api.clientSecret != nil
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ struct HybridSecureField: View {
|
|||
}
|
||||
|
||||
extension HybridSecureField {
|
||||
public func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
|
||||
func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
|
||||
modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,12 @@ struct IndeterminateProgressView: View {
|
|||
.foregroundColor(Color.accentColor)
|
||||
.frame(width: reader.size.width * 0.26, height: 6)
|
||||
.clipShape(Capsule())
|
||||
|
||||
.offset(x: -reader.size.width * 0.6, y: 0)
|
||||
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
|
||||
.animation(.default.repeatForever().speed(0.5), value: self.offset)
|
||||
.offset(x: reader.size.width * 1.2 * offset, y: 0)
|
||||
.animation(.default.repeatForever().speed(0.5), value: offset)
|
||||
.onAppear {
|
||||
withAnimation {
|
||||
self.offset = 1
|
||||
offset = 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
//
|
||||
// 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 {
|
||||
Text(title)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
//
|
||||
// ConditionalContextMenu.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/3/22.
|
||||
//
|
||||
// Used as a workaround for iOS 15 not updating context views with conditional variables
|
||||
// A stateful ID is required for the contextMenu to update itself.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ConditionalContextMenuModifier<InternalContent: View, ID: Hashable>: ViewModifier {
|
||||
let internalContent: () -> InternalContent
|
||||
let id: ID
|
||||
|
||||
init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) {
|
||||
self.internalContent = internalContent
|
||||
self.id = id
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.contextMenu {
|
||||
internalContent()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
Color.clear
|
||||
.contextMenu {
|
||||
internalContent()
|
||||
}
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
//
|
||||
// ConditionalId.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/4/22.
|
||||
//
|
||||
// Applies an ID below iOS 16
|
||||
// This is due to ID workarounds making iOS 16 apps crash
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ConditionalIdModifier<ID: Hashable>: ViewModifier {
|
||||
let id: ID
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,26 +5,18 @@
|
|||
// Created by Brian Dashore on 9/4/22.
|
||||
//
|
||||
// Removes the top padding on unsectioned lists
|
||||
// If a list is sectioned, see InlineHeader
|
||||
//
|
||||
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
struct InlinedListModifier: ViewModifier {
|
||||
let inset: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.introspectCollectionView { collectionView in
|
||||
collectionView.contentInset.top = inset
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.introspectTableView { tableView in
|
||||
tableView.contentInset.top = inset
|
||||
}
|
||||
}
|
||||
content
|
||||
.introspect(.list, on: .iOS(.v16, .v17, .v18)) { collectionView in
|
||||
collectionView.contentInset.top = inset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// NavView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/4/22.
|
||||
// Contributed by Mantton
|
||||
//
|
||||
// A wrapper that switches between NavigationStack and the legacy NavigationView
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NavView<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
// NavigationStack issues are fixed on iOS 17
|
||||
if #available(iOS 17, *) {
|
||||
NavigationStack {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
NavigationView {
|
||||
content
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,20 +56,27 @@ struct BookmarksView: View {
|
|||
.frame(height: 15)
|
||||
}
|
||||
.task {
|
||||
if debridManager.hasEnabledDebrids {
|
||||
let magnets = bookmarks.compactMap {
|
||||
if let magnetHash = $0.magnetHash {
|
||||
return Magnet(hash: magnetHash, link: $0.magnetLink)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
await debridManager.populateDebridIA(magnets)
|
||||
}
|
||||
await matchAgainstIA()
|
||||
}
|
||||
.refreshable {
|
||||
await matchAgainstIA()
|
||||
}
|
||||
}
|
||||
|
||||
func fetchPredicate() {
|
||||
bookmarks.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
|
||||
}
|
||||
|
||||
func matchAgainstIA() async {
|
||||
if !debridManager.enabledDebrids.isEmpty {
|
||||
let magnets = bookmarks.compactMap {
|
||||
if let magnetHash = $0.magnetHash {
|
||||
return Magnet(hash: magnetHash, link: $0.magnetLink)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
await debridManager.populateDebridIA(magnets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,21 +24,24 @@ struct CloudDownloadView: View {
|
|||
Button(cloudDownload.fileName) {
|
||||
navModel.resultFromCloud = true
|
||||
navModel.selectedTitle = cloudDownload.fileName
|
||||
debridManager.downloadUrl = cloudDownload.link
|
||||
|
||||
PersistenceController.shared.createHistory(
|
||||
HistoryEntryJson(
|
||||
name: cloudDownload.fileName,
|
||||
url: cloudDownload.link,
|
||||
source: debridSource.id
|
||||
),
|
||||
performSave: true
|
||||
var historyEntry = HistoryEntryJson(
|
||||
name: cloudDownload.fileName,
|
||||
source: debridSource.id
|
||||
)
|
||||
|
||||
pluginManager.runDefaultAction(
|
||||
urlString: debridManager.downloadUrl,
|
||||
navModel: navModel
|
||||
)
|
||||
debridManager.currentDebridTask = Task {
|
||||
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link)
|
||||
|
||||
if !debridManager.downloadUrl.isEmpty {
|
||||
historyEntry.url = debridManager.downloadUrl
|
||||
PersistenceController.shared.createHistory(historyEntry, performSave: true)
|
||||
|
||||
pluginManager.runDefaultAction(
|
||||
urlString: debridManager.downloadUrl,
|
||||
navModel: navModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
||||
.tint(.primary)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// CloudTorrentView.swift
|
||||
// CloudMagnetView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 6/6/24.
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct CloudTorrentView: View {
|
||||
struct CloudMagnetView: View {
|
||||
@EnvironmentObject var navModel: NavigationViewModel
|
||||
@EnvironmentObject var debridManager: DebridManager
|
||||
@EnvironmentObject var pluginManager: PluginManager
|
||||
|
|
@ -17,29 +17,37 @@ struct CloudTorrentView: View {
|
|||
@Binding var searchText: String
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup("Torrents") {
|
||||
ForEach(debridSource.cloudTorrents.filter {
|
||||
DisclosureGroup("Magnets") {
|
||||
ForEach(debridSource.cloudMagnets.filter {
|
||||
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
|
||||
}, id: \.self) { cloudTorrent in
|
||||
}, id: \.self) { cloudMagnet in
|
||||
Button {
|
||||
if cloudTorrent.status == "downloaded", !cloudTorrent.links.isEmpty {
|
||||
if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty {
|
||||
navModel.resultFromCloud = true
|
||||
navModel.selectedTitle = cloudTorrent.fileName
|
||||
navModel.selectedTitle = cloudMagnet.fileName
|
||||
|
||||
var historyInfo = HistoryEntryJson(
|
||||
name: cloudTorrent.fileName,
|
||||
name: cloudMagnet.fileName,
|
||||
source: debridSource.id
|
||||
)
|
||||
|
||||
Task {
|
||||
let magnet = Magnet(hash: cloudTorrent.hash, link: nil)
|
||||
let magnet = Magnet(hash: cloudMagnet.hash, link: nil)
|
||||
await debridManager.populateDebridIA([magnet])
|
||||
if debridManager.selectDebridResult(magnet: magnet) {
|
||||
// Is this a batch?
|
||||
|
||||
if cloudTorrent.links.count == 1 {
|
||||
if cloudMagnet.links.count == 1 {
|
||||
await debridManager.fetchDebridDownload(magnet: magnet)
|
||||
|
||||
// Bump to batch
|
||||
if debridManager.requiresUnrestrict {
|
||||
navModel.selectedHistoryInfo = historyInfo
|
||||
navModel.currentChoiceSheet = .batch
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !debridManager.downloadUrl.isEmpty {
|
||||
historyInfo.url = debridManager.downloadUrl
|
||||
PersistenceController.shared.createHistory(historyInfo, performSave: true)
|
||||
|
|
@ -59,15 +67,15 @@ struct CloudTorrentView: View {
|
|||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(cloudTorrent.fileName)
|
||||
Text(cloudMagnet.fileName)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(4)
|
||||
|
||||
HStack {
|
||||
Text(cloudTorrent.status.capitalizingFirstLetter())
|
||||
Text(cloudMagnet.status.capitalizingFirstLetter())
|
||||
Spacer()
|
||||
DebridLabelView(debridSource: debridSource, cloudLinks: cloudTorrent.links)
|
||||
DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
|
@ -77,9 +85,9 @@ struct CloudTorrentView: View {
|
|||
}
|
||||
.onDelete { offsets in
|
||||
for index in offsets {
|
||||
if let cloudTorrent = debridSource.cloudTorrents[safe: index] {
|
||||
if let cloudMagnet = debridSource.cloudMagnets[safe: index] {
|
||||
Task {
|
||||
await debridManager.deleteCloudTorrent(cloudTorrent)
|
||||
await debridManager.deleteUserMagnet(cloudMagnet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,13 +16,8 @@ struct DebridCloudView: View {
|
|||
|
||||
var body: some View {
|
||||
List {
|
||||
if !debridSource.cloudDownloads.isEmpty {
|
||||
CloudDownloadView(debridSource: debridSource, searchText: $searchText)
|
||||
}
|
||||
|
||||
if !debridSource.cloudTorrents.isEmpty {
|
||||
CloudTorrentView(debridSource: debridSource, searchText: $searchText)
|
||||
}
|
||||
CloudDownloadView(debridSource: debridSource, searchText: $searchText)
|
||||
CloudMagnetView(debridSource: debridSource, searchText: $searchText)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.task {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ struct HistorySectionView: View {
|
|||
|
||||
var body: some View {
|
||||
if compareGroup(historyGroup) > 0 {
|
||||
Section(header: InlineHeader(formatter.string(from: historyGroup[0].date ?? Date()))) {
|
||||
Section(formatter.string(from: historyGroup[0].date ?? Date())) {
|
||||
ForEach(historyGroup, id: \.self) { history in
|
||||
ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in
|
||||
HistoryButtonView(entry: entry)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ struct LibraryPickerView: View {
|
|||
Text("Bookmarks").tag(NavigationViewModel.LibraryPickerSegment.bookmarks)
|
||||
Text("History").tag(NavigationViewModel.LibraryPickerSegment.history)
|
||||
|
||||
if debridManager.hasEnabledDebrids {
|
||||
if !debridManager.enabledDebrids.isEmpty {
|
||||
Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// InstalledSourceButtonView.swift
|
||||
// InstalledPluginButtonView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 8/5/22.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SourceCatalogButtonView.swift
|
||||
// PluginCatalogButtonView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 8/5/22.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ struct PluginInfoAboutView<P: Plugin>: View {
|
|||
@ObservedObject var selectedPlugin: P
|
||||
|
||||
var body: some View {
|
||||
Section(header: InlineHeader("Description")) {
|
||||
Section("Description") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let pluginAbout = selectedPlugin.about {
|
||||
if pluginAbout.last == "\n" {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ struct PluginInfoMetaView<P: Plugin>: View {
|
|||
) var pluginLists: FetchedResults<PluginList>
|
||||
|
||||
var body: some View {
|
||||
Section(header: InlineHeader("Metadata")) {
|
||||
Section("Metadata") {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
|
|
@ -32,8 +32,7 @@ struct PluginInfoMetaView<P: Plugin>: View {
|
|||
Group {
|
||||
Text("ID: \(selectedPlugin.id)")
|
||||
|
||||
if let pluginList = pluginLists.first(where: { $0.id == selectedPlugin.listId })
|
||||
{
|
||||
if let pluginList = pluginLists.first(where: { $0.id == selectedPlugin.listId }) {
|
||||
Text("List: \(pluginList.name)")
|
||||
Text("List ID: \(pluginList.id.uuidString)")
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
|
|||
searchText: searchText
|
||||
)
|
||||
if !filteredUpdatedPlugins.isEmpty {
|
||||
Section(header: InlineHeader("Updates")) {
|
||||
Section("Updates") {
|
||||
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
|
||||
PluginCatalogButtonView(availablePlugin: updatedPlugin, needsUpdate: true)
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
|
|||
}
|
||||
|
||||
if !installedPlugins.isEmpty {
|
||||
Section(header: InlineHeader("Installed")) {
|
||||
Section("Installed") {
|
||||
ForEach(installedPlugins, id: \.self) { installedPlugin in
|
||||
InstalledPluginButtonView(
|
||||
installedPlugin: installedPlugin,
|
||||
|
|
@ -64,7 +64,7 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
|
|||
searchText: searchText
|
||||
)
|
||||
if !filteredAvailablePlugins.isEmpty {
|
||||
Section(header: InlineHeader("Catalog")) {
|
||||
Section("Catalog") {
|
||||
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
|
||||
PluginCatalogButtonView(availablePlugin: availablePlugin, needsUpdate: false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ struct PluginInfoView<P: Plugin>: View {
|
|||
@Binding var selectedPlugin: P?
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
List {
|
||||
if let selectedPlugin {
|
||||
PluginInfoMetaView(selectedPlugin: selectedPlugin)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// PluginTagView.swift
|
||||
// PluginTagsView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/7/23.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ struct SourceSettingsApiView: View {
|
|||
|
||||
var body: some View {
|
||||
Section(
|
||||
header: InlineHeader("API credentials"),
|
||||
header: Text("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 {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ struct SourceSettingsBaseUrlView: View {
|
|||
@State private var tempSite: String = ""
|
||||
var body: some View {
|
||||
Section(
|
||||
header: InlineHeader("Base URL"),
|
||||
header: Text("Base URL"),
|
||||
footer: Text("Enter the base URL of your server.")
|
||||
) {
|
||||
TextField("https://...", text: $tempSite, onEditingChanged: { isFocused in
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ struct SourceSettingsMethodView: View {
|
|||
@ObservedObject var selectedSource: Source
|
||||
|
||||
var body: some View {
|
||||
Section(header: InlineHeader("Fetch method")) {
|
||||
Section("Fetch method") {
|
||||
Picker("", selection: $selectedSource.preferredParser) {
|
||||
if selectedSource.jsonParser != nil {
|
||||
Text("Website API").tag(SourcePreferredParser.siteApi.rawValue)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ struct SearchFilterHeaderView: View {
|
|||
|
||||
// MARK: - Cache status picker
|
||||
|
||||
if debridManager.hasEnabledDebrids {
|
||||
if !debridManager.enabledDebrids.isEmpty {
|
||||
IAFilterView()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,50 +28,26 @@ struct SearchResultButtonView: View {
|
|||
navModel.selectedTitle = result.title ?? ""
|
||||
navModel.resultFromCloud = false
|
||||
|
||||
var historyEntry = HistoryEntryJson(
|
||||
name: result.title,
|
||||
source: result.source
|
||||
)
|
||||
|
||||
switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) {
|
||||
case .full:
|
||||
if debridManager.selectDebridResult(magnet: result.magnet) {
|
||||
debridManager.currentDebridTask = Task {
|
||||
await debridManager.fetchDebridDownload(magnet: result.magnet)
|
||||
|
||||
if !debridManager.downloadUrl.isEmpty {
|
||||
PersistenceController.shared.createHistory(
|
||||
HistoryEntryJson(
|
||||
name: result.title,
|
||||
url: debridManager.downloadUrl,
|
||||
source: result.source
|
||||
),
|
||||
performSave: true
|
||||
)
|
||||
|
||||
pluginManager.runDefaultAction(
|
||||
urlString: debridManager.downloadUrl,
|
||||
navModel: navModel
|
||||
)
|
||||
|
||||
if navModel.currentChoiceSheet != .action {
|
||||
debridManager.downloadUrl = ""
|
||||
}
|
||||
}
|
||||
await downloadToDebrid()
|
||||
}
|
||||
}
|
||||
case .partial:
|
||||
if debridManager.selectDebridResult(magnet: result.magnet) {
|
||||
navModel.selectedHistoryInfo = HistoryEntryJson(
|
||||
name: result.title,
|
||||
source: result.source
|
||||
)
|
||||
navModel.selectedHistoryInfo = historyEntry
|
||||
navModel.currentChoiceSheet = .batch
|
||||
}
|
||||
case .none:
|
||||
PersistenceController.shared.createHistory(
|
||||
HistoryEntryJson(
|
||||
name: result.title,
|
||||
url: result.magnet.link,
|
||||
source: result.source
|
||||
),
|
||||
performSave: true
|
||||
)
|
||||
historyEntry.url = result.magnet.link
|
||||
PersistenceController.shared.createHistory(historyEntry, performSave: true)
|
||||
|
||||
pluginManager.runDefaultAction(
|
||||
urlString: result.magnet.link,
|
||||
|
|
@ -92,7 +68,7 @@ struct SearchResultButtonView: View {
|
|||
}
|
||||
.disableInteraction(navModel.currentChoiceSheet != nil)
|
||||
.tint(.primary)
|
||||
.conditionalContextMenu(id: existingBookmark) {
|
||||
.contextMenu {
|
||||
ZStack {
|
||||
if let bookmark = existingBookmark {
|
||||
Button {
|
||||
|
|
@ -123,19 +99,46 @@ struct SearchResultButtonView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
if debridManager.currentDebridTask == nil {
|
||||
let foundIAResult = debridManager.selectDebridResult(magnet: result.magnet)
|
||||
|
||||
// Add a fake IA because we don't know if the magnet is cached at this point
|
||||
if !foundIAResult {
|
||||
debridManager.selectedDebridItem = DebridIA(
|
||||
magnet: result.magnet,
|
||||
expiryTimeStamp: Date().timeIntervalSince1970,
|
||||
files: []
|
||||
)
|
||||
}
|
||||
|
||||
debridManager.currentDebridTask = Task {
|
||||
await downloadToDebrid()
|
||||
|
||||
// Re-populate the IA cache if a result wasn't initially found
|
||||
if !foundIAResult {
|
||||
await debridManager.populateDebridIA([result.magnet])
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Download to Debrid")
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
.alert("Caching file", isPresented: $debridManager.showDeleteAlert) {
|
||||
Button("Yes", role: .destructive) {
|
||||
Task {
|
||||
try? await debridManager.selectedDebridSource?.deleteTorrent(torrentId: nil)
|
||||
try? await debridManager.selectedDebridSource?.deleteUserMagnet(cloudMagnetId: nil)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"\(String(describing: debridManager.selectedDebridSource?.id)) is currently caching this file. " +
|
||||
"\(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") is currently caching this file. " +
|
||||
"Would you like to delete it? \n\n" +
|
||||
"Progress can be checked on the RealDebrid website."
|
||||
"Progress can be checked on the \(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") website."
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in
|
||||
|
|
@ -168,4 +171,35 @@ struct SearchResultButtonView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common function to download
|
||||
func downloadToDebrid() async {
|
||||
var historyEntry = HistoryEntryJson(
|
||||
name: result.title,
|
||||
source: result.source
|
||||
)
|
||||
|
||||
await debridManager.fetchDebridDownload(magnet: result.magnet)
|
||||
navModel.selectedTitle = result.title ?? ""
|
||||
|
||||
if debridManager.requiresUnrestrict {
|
||||
navModel.currentChoiceSheet = .batch
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !debridManager.downloadUrl.isEmpty {
|
||||
historyEntry.url = debridManager.downloadUrl
|
||||
PersistenceController.shared.createHistory(historyEntry, performSave: true)
|
||||
|
||||
pluginManager.runDefaultAction(
|
||||
urlString: debridManager.downloadUrl,
|
||||
navModel: navModel
|
||||
)
|
||||
|
||||
if navModel.currentChoiceSheet != .action {
|
||||
debridManager.downloadUrl = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SearchResultRDView.swift
|
||||
// SearchResultInfoView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/26/22.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// DefaultActionsPickerViews.swift
|
||||
// DefaultActionPickerView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 8/11/22.
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ struct KodiEditorView: View {
|
|||
@State private var errorAlertText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Group {
|
||||
Section(
|
||||
header: InlineHeader("URL"),
|
||||
header: Text("URL"),
|
||||
footer: Text("Must follow the format http(s)://<ip>:<port>")
|
||||
) {
|
||||
TextField("Enter URL", text: $serverUrl)
|
||||
|
|
@ -37,14 +37,14 @@ struct KodiEditorView: View {
|
|||
}
|
||||
|
||||
Section(
|
||||
header: InlineHeader("Friendly name"),
|
||||
header: Text("Friendly name"),
|
||||
footer: Text("Defaults to the URL if not provided")
|
||||
) {
|
||||
TextField("Friendly name", text: $friendlyName)
|
||||
}
|
||||
|
||||
Section(
|
||||
header: InlineHeader("Credentials"),
|
||||
header: Text("Credentials"),
|
||||
footer: Text("Only use for clients with authentication")
|
||||
) {
|
||||
TextField("Username", text: $username)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ struct SettingsKodiView: View {
|
|||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: InlineHeader("Description")) {
|
||||
Section("Description") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Kodi is an external application that is used to manage a local media library and playback.")
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ struct SettingsKodiView: View {
|
|||
}
|
||||
|
||||
Section(
|
||||
header: InlineHeader("Servers"),
|
||||
header: Text("Servers"),
|
||||
footer: Text("Edit a server by holding it and accessing the context menu")
|
||||
) {
|
||||
if kodiServers.isEmpty {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SourceListEditorView.swift
|
||||
// PluginListEditorView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/25/22.
|
||||
|
|
@ -25,7 +25,7 @@ struct PluginListEditorView: View {
|
|||
@State private var loadedSelectedList = false
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
Form {
|
||||
TextField("Enter URL", text: $pluginListUrl)
|
||||
.disableAutocorrection(true)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// SettingsSourceListView.swift
|
||||
// SettingsPluginListView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/25/22.
|
||||
|
|
@ -69,12 +69,8 @@ struct SettingsPluginListView: View {
|
|||
}
|
||||
}
|
||||
.sheet(isPresented: $presentEditSheet) {
|
||||
if #available(iOS 16, *) {
|
||||
PluginListEditorView()
|
||||
.presentationDetents([.medium])
|
||||
} else {
|
||||
PluginListEditorView()
|
||||
}
|
||||
PluginListEditorView()
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.navigationTitle("Plugin Lists")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ struct SettingsAppVersionView: View {
|
|||
ProgressView()
|
||||
} else if !releases.isEmpty {
|
||||
List {
|
||||
Section(header: InlineHeader("GitHub links")) {
|
||||
Section("GitHub links") {
|
||||
ForEach(releases, id: \.self) { release in
|
||||
ListRowLinkView(text: release.tagName, link: release.htmlUrl)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// DebridInfoView.swift
|
||||
// SettingsDebridInfoView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 3/5/23.
|
||||
|
|
@ -16,16 +16,18 @@ struct SettingsDebridInfoView: View {
|
|||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: InlineHeader("Description")) {
|
||||
Section("Description") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("\(debridSource.id) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.")
|
||||
Text(debridSource.description ??
|
||||
"\(debridSource.id) is a debrid service that is used for downloads and media playback. You must pay to access the service."
|
||||
)
|
||||
|
||||
Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!)
|
||||
}
|
||||
}
|
||||
|
||||
Section(
|
||||
header: InlineHeader("Login status"),
|
||||
header: Text("Login status"),
|
||||
footer: Text("A WebView will show up to prompt you for credentials")
|
||||
) {
|
||||
Button {
|
||||
|
|
@ -46,10 +48,17 @@ struct SettingsDebridInfoView: View {
|
|||
)
|
||||
.foregroundColor(debridSource.isLoggedIn ? .red : .blue)
|
||||
}
|
||||
.alert("Invalid web login", isPresented: $debridManager.showWebLoginAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"\(debridSource.id) does not have a login portal. Please use an API key to login."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section(
|
||||
header: InlineHeader("API key"),
|
||||
header: Text("API key"),
|
||||
footer: Text("Add a permanent API key here. Only use this if web authentication does not work!")
|
||||
) {
|
||||
HybridSecureField(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// SettingsDebridLinkView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 11/27/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsDebridLinkView: View {
|
||||
var debridSource: DebridSource
|
||||
|
||||
// TODO: Use a roundabout state for now
|
||||
@State private var isLoggedIn = false
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
SettingsDebridInfoView(debridSource: debridSource)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(debridSource.id)
|
||||
Spacer()
|
||||
Text(isLoggedIn ? "Enabled" : "Disabled")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isLoggedIn = debridSource.isLoggedIn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ struct ContentView: View {
|
|||
@State private var dismissAction: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
List {
|
||||
SearchResultsView(searchText: $searchText)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ struct LibraryView: View {
|
|||
@State private var searchText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
switch navModel.libraryPickerSelection {
|
||||
case .bookmarks:
|
||||
|
|
@ -96,6 +96,11 @@ struct LibraryView: View {
|
|||
.esAutocapitalization(autocorrectSearch ? .sentences : .none)
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
.alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(debridManager.notImplementedMessage)
|
||||
}
|
||||
.onChange(of: navModel.libraryPickerSelection) { _ in
|
||||
editMode = .inactive
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ struct LoginWebView: View {
|
|||
var url: URL
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
WebView(url: url)
|
||||
.navigationTitle("Sign in")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
|
|||
|
|
@ -54,12 +54,8 @@ struct MainView: View {
|
|||
case .batch:
|
||||
BatchChoiceView()
|
||||
case .activity:
|
||||
if #available(iOS 16, *) {
|
||||
ShareSheet(activityItems: navModel.activityItems)
|
||||
.presentationDetents([.medium, .large])
|
||||
} else {
|
||||
ShareSheet(activityItems: navModel.activityItems)
|
||||
}
|
||||
ShareSheet(activityItems: navModel.activityItems)
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ struct PluginsView: View {
|
|||
@State private var searchText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
if checkedForPlugins {
|
||||
switch navModel.pluginPickerSelection {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
extension View {
|
||||
// A dismissAction must be added in the parent view struct due to lifecycle issues
|
||||
func expandedSearchable(text: Binding<String>,
|
||||
isSearching: Binding<Bool>? = nil,
|
||||
|
|
@ -212,10 +212,7 @@ struct SearchBar<ScopeContent: View>: UIViewControllerRepresentable {
|
|||
private func setup() {
|
||||
parent?.navigationItem.searchController = searchController
|
||||
parent?.navigationItem.hidesSearchBarWhenScrolling = false
|
||||
|
||||
if #available(iOS 16, *) {
|
||||
parent?.navigationItem.preferredSearchBarPlacement = .stacked
|
||||
}
|
||||
parent?.navigationItem.preferredSearchBarPlacement = .stacked
|
||||
|
||||
// Makes search bar appear when application starts
|
||||
parent?.navigationController?.navigationBar.sizeToFit()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
//
|
||||
|
||||
import BetterSafariView
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
|
|
@ -43,24 +42,15 @@ struct SettingsView: View {
|
|||
@FocusState private var focusedField: Field?
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: InlineHeader("Debrid services")) {
|
||||
Section("Debrid services") {
|
||||
ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in
|
||||
NavigationLink {
|
||||
SettingsDebridInfoView(debridSource: debridSource)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(debridSource.id)
|
||||
Spacer()
|
||||
Text(debridSource.isLoggedIn ? "Enabled" : "Disabled")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
SettingsDebridLinkView(debridSource: debridSource)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Playback services")) {
|
||||
Section("Playback services") {
|
||||
NavigationLink {
|
||||
SettingsKodiView(kodiServers: kodiServers)
|
||||
} label: {
|
||||
|
|
@ -74,7 +64,7 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
Section(
|
||||
header: InlineHeader("Behavior"),
|
||||
header: Text("Behavior"),
|
||||
footer: VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Temporarily disable ephemeral auth if you cannot log into a service")
|
||||
Text("Only disable search timeout if results are slow to fetch")
|
||||
|
|
@ -121,14 +111,14 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Plugin management")) {
|
||||
Section("Plugin management") {
|
||||
NavigationLink("Plugin lists") {
|
||||
SettingsPluginListView()
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Default actions")) {
|
||||
if debridManager.hasEnabledDebrids {
|
||||
Section("Default actions") {
|
||||
if !debridManager.enabledDebrids.isEmpty {
|
||||
NavigationLink {
|
||||
DefaultActionPickerView(
|
||||
actionRequirement: .debrid,
|
||||
|
|
@ -185,13 +175,13 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Backups")) {
|
||||
Section("Backups") {
|
||||
NavigationLink("Backups") {
|
||||
BackupsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Updates")) {
|
||||
Section("Updates") {
|
||||
Toggle(isOn: $autoUpdateNotifs) {
|
||||
Text("Show update alerts")
|
||||
}
|
||||
|
|
@ -201,7 +191,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Information")) {
|
||||
Section("Information") {
|
||||
ListRowLinkView(text: "Donate", link: "https://ko-fi.com/kingbri")
|
||||
ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues")
|
||||
|
||||
|
|
@ -210,7 +200,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section(header: InlineHeader("Debug")) {
|
||||
Section("Debug") {
|
||||
NavigationLink("Logs") {
|
||||
SettingsLogView()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// MagnetChoiceView.swift
|
||||
// ActionChoiceView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/20/22.
|
||||
|
|
@ -29,9 +29,9 @@ struct ActionChoiceView: View {
|
|||
@State private var showMagnetCopyAlert = false
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: InlineHeader("Now Playing")) {
|
||||
Section("Now Playing") {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(navModel.selectedTitle)
|
||||
.font(.callout)
|
||||
|
|
@ -46,7 +46,7 @@ struct ActionChoiceView: View {
|
|||
}
|
||||
|
||||
if !debridManager.downloadUrl.isEmpty {
|
||||
Section(header: InlineHeader("Debrid options")) {
|
||||
Section("Debrid options") {
|
||||
ForEach(actions, id: \.id) { action in
|
||||
if action.requires.contains(ActionRequirement.debrid.rawValue) {
|
||||
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
|
||||
|
|
@ -91,7 +91,7 @@ struct ActionChoiceView: View {
|
|||
}
|
||||
|
||||
if !navModel.resultFromCloud {
|
||||
Section(header: InlineHeader("Magnet options")) {
|
||||
Section("Magnet options") {
|
||||
ForEach(actions, id: \.id) { action in
|
||||
if action.requires.contains(ActionRequirement.magnet.rawValue) {
|
||||
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
|
||||
|
|
@ -123,13 +123,8 @@ struct ActionChoiceView: View {
|
|||
}
|
||||
.tint(.primary)
|
||||
.sheet(isPresented: $navModel.showLocalActivitySheet) {
|
||||
// TODO: Fix share sheet
|
||||
if #available(iOS 16, *) {
|
||||
ShareSheet(activityItems: navModel.activityItems)
|
||||
.presentationDetents([.medium, .large])
|
||||
} else {
|
||||
ShareSheet(activityItems: navModel.activityItems)
|
||||
}
|
||||
ShareSheet(activityItems: navModel.activityItems)
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.alert("Action successful", isPresented: $pluginManager.showActionSuccessAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
|
|
@ -143,6 +138,8 @@ struct ActionChoiceView: View {
|
|||
}
|
||||
.onDisappear {
|
||||
debridManager.downloadUrl = ""
|
||||
debridManager.clearSelectedDebridItems()
|
||||
debridManager.requiresUnrestrict = false
|
||||
navModel.selectedTitle = ""
|
||||
navModel.selectedBatchTitle = ""
|
||||
navModel.resultFromCloud = false
|
||||
|
|
@ -153,8 +150,11 @@ struct ActionChoiceView: View {
|
|||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
debridManager.downloadUrl = ""
|
||||
debridManager.clearSelectedDebridItems()
|
||||
debridManager.requiresUnrestrict = false
|
||||
navModel.selectedTitle = ""
|
||||
navModel.selectedBatchTitle = ""
|
||||
navModel.resultFromCloud = false
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,8 @@ struct BatchChoiceView: View {
|
|||
|
||||
@State private var searchText: String = ""
|
||||
|
||||
// TODO: Make this generic for an IA protocol
|
||||
var body: some View {
|
||||
NavView {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(debridManager.selectedDebridItem?.files ?? [], id: \.self) { file in
|
||||
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
|
||||
|
|
@ -39,6 +38,10 @@ struct BatchChoiceView: View {
|
|||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.autocorrectionDisabled(!autocorrectSearch)
|
||||
.textInputAutocapitalization(autocorrectSearch ? .sentences : .never)
|
||||
.onDisappear {
|
||||
debridManager.clearSelectedDebridItems()
|
||||
debridManager.requiresUnrestrict = false
|
||||
}
|
||||
.navigationTitle("Select a file")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
|
@ -50,6 +53,7 @@ struct BatchChoiceView: View {
|
|||
try? await Task.sleep(seconds: 1)
|
||||
|
||||
debridManager.clearSelectedDebridItems()
|
||||
debridManager.requiresUnrestrict = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +64,11 @@ struct BatchChoiceView: View {
|
|||
// Common function to communicate betwen VMs and queue/display a download
|
||||
func queueCommonDownload(fileName: String) {
|
||||
debridManager.currentDebridTask = Task {
|
||||
await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet)
|
||||
if debridManager.requiresUnrestrict {
|
||||
await debridManager.unrestrictDownload()
|
||||
} else {
|
||||
await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet)
|
||||
}
|
||||
|
||||
if !debridManager.downloadUrl.isEmpty {
|
||||
try? await Task.sleep(seconds: 1)
|
||||
|
|
|
|||
BIN
Misc/Media/Demo/Dark/Bookmarks.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
Misc/Media/Demo/Dark/Cloud.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
Misc/Media/Demo/Dark/History.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
Misc/Media/Demo/Dark/Plugins.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
Misc/Media/Demo/Dark/Search.png
Normal file
|
After Width: | Height: | Size: 539 KiB |
BIN
Misc/Media/Demo/Light/Bookmarks.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
Misc/Media/Demo/Light/Cloud.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
Misc/Media/Demo/Light/History.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
Misc/Media/Demo/Light/Plugins.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
Misc/Media/Demo/Light/Search.png
Normal file
|
After Width: | Height: | Size: 475 KiB |
5
Misc/Referrals/TorBox.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Enter the following code on [TorBox's subscription page](https://torbox.app/subscription)
|
||||
|
||||
bb2d4f54-61bf-4d64-af08-8db0a900485a
|
||||
|
||||
Thanks for the referral!
|
||||