Compare commits

..

20 commits

Author SHA1 Message Date
kingbri
1ce37f9a49 Actions: Update to latest
Bump actions and macos build versions.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-09 20:53:10 -04:00
kingbri
ea4a4350ba Debrid: Fix UI updates for IA
Hook to the published variable to push updates.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-09 20:41:08 -04:00
kingbri
26b9bdd702 Premiumize: Fix service-specific errors
This parameter should be optional and errors if it isn't.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-09 20:28:02 -04:00
kingbri
9b936778b0 Logging: Improve generic error message
Point the user to settings logs rather than giving no extra information.
It would be a good idea to give the type of error in the future.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-08 12:28:19 -04:00
kingbri
75291c4396 Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-08 12:27:36 -04:00
kingbri
2c25bd98db Debrid: Migrate auth to protocol
Unify authentication to the new protocol. Also remove logout on
invalid requests. This became annoying and didn't update the UI
properly.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-08 01:09:18 -04:00
kingbri
447d8b5bd0 Debrid: Unify cloud views
Cloud torrents and downloads are unified with the new protocol.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-06 20:39:09 -04:00
kingbri
f9b2959ae2 Debrid: Remove more redundant vars
the IA vars are no longer needed since that's unified.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-06 11:54:31 -04:00
kingbri
01fce90d6f Debrid: Migrate preferred service setter
PreferredService is now the debrid ID.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-06 11:53:56 -04:00
kingbri
cb9231d3e7 Debrid: Remove separated download functions
No longer needed due to the common type.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-06 10:48:23 -04:00
kingbri
9fe9241ca3 Debrid: Remove redundant logout functions
Logout is now handled in the debrid class itself.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-06 10:46:18 -04:00
kingbri
554b72857b Debrid: Fix cache alert
Change the returned error to one that's unique to caching. Also
make deleteTorrents optional to delete the first torrent if necessary
since that's always being cached.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-06 10:44:44 -04:00
kingbri
3cb8a979b1 Debrid: Swap to common DebridError
Removes the redundant error types.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-05 22:50:40 -04:00
kingbri
13988b3c6c Debrid: Refactor IA and download functions
Use the common protocol to handle these.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-05 22:16:40 -04:00
kingbri
4e6cfee608 Debrid: Remove ID storage
Storing an ID reference is redundant. Store a class reference
instead.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-05 13:05:26 -04:00
kingbri
46e66ab457 Debrid: Migrate more components to the protocol
Protocols can't be used in ObservedObjects. Observable in iOS 17
and up solves this, but Ferrite targets iOS 16 and up, so add a
type-erased StateObject which supports protocols.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-05 12:33:11 -04:00
kingbri
449b0eaa7e Debrid: Allow for UI updates
Mark as an ObservableObject so the UI can see parameters that are
being updated in the class.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-04 14:02:59 -04:00
kingbri
3137d20656 Debrid: Migrate common arrays to their API classes
Add convenience vars which makes the API classes the source of truth
for any interaction.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-04 12:09:19 -04:00
kingbri
1d8a965bb7 Debrid: Fix RealDebrid download handling
The torrent ID is no longer stored in the DebridManager.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-04 10:52:35 -04:00
kingbri
853b956105 Debrid: Add common functions for existing torrents/downloads
This fixes cloud torrent fetching and also doesn't duplicate torrents
inside the cloud service. Unrestricted links don't get duplicated,
so no need to check against those.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 23:56:56 -04:00
101 changed files with 949 additions and 2010 deletions

View file

@ -12,9 +12,6 @@
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; }; 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; }; 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.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 */; }; 0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; }; 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; }; 0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
@ -41,6 +38,7 @@
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; }; 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; }; 0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; };
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.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 */; }; 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; };
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; }; 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; };
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; }; 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; };
@ -68,6 +66,7 @@
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; }; 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
0C7075E429D374C50093DB2D /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E329D374C50093DB2D /* Color.swift */; }; 0C7075E429D374C50093DB2D /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E329D374C50093DB2D /* Color.swift */; };
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E529D3845D0093DB2D /* ShareSheet.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 */; }; 0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; };
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; }; 0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 0C748ED929D9256D0049B8BE /* Yams */; }; 0C748EDA29D9256D0049B8BE /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 0C748ED929D9256D0049B8BE /* Yams */; };
@ -80,7 +79,6 @@
0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; }; 0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; };
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; }; 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 */; }; 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 */; }; 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; };
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; }; 0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; };
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; }; 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; };
@ -96,14 +94,11 @@
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; }; 0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; }; 0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; };
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.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 */; }; 0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; };
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; }; 0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; }; 0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; }; 0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.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 */; }; 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */; };
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; }; 0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; };
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; }; 0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; };
@ -111,6 +106,7 @@
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; }; 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; }; 0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* ActionChoiceView.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 */; }; 0CA148DC288903F000DE2211 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C2288903F000DE2211 /* Assets.xcassets */; };
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; }; 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; };
0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; }; 0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; };
@ -134,9 +130,11 @@
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; }; 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */; }; 0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */; };
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.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 */; }; 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 */; }; 0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */; };
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.swift */; }; 0CB725342C123E760047FC0B /* CloudTorrentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudTorrentView.swift */; };
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; }; 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; };
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; }; 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; }; 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
@ -156,8 +154,6 @@
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; }; 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; }; 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; };
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.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 */; }; 0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; };
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; }; 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; };
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; }; 0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
@ -169,9 +165,6 @@
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; }; 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
@ -223,6 +216,7 @@
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = "<group>"; }; 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = "<group>"; };
0C7075E329D374C50093DB2D /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
@ -248,14 +242,11 @@
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = "<group>"; }; 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = "<group>"; };
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAmountLabelView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
@ -263,6 +254,7 @@
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
@ -286,9 +278,11 @@
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDownloadView.swift; sourceTree = "<group>"; };
0CB725332C123E760047FC0B /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.swift; sourceTree = "<group>"; }; 0CB725332C123E760047FC0B /* CloudTorrentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTorrentView.swift; sourceTree = "<group>"; };
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.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>"; }; 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>"; }; 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
@ -308,8 +302,6 @@
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
@ -321,13 +313,13 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */, 0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */,
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */,
0C64A4B4288903680079976D /* Base32 in Frameworks */, 0C64A4B4288903680079976D /* Base32 in Frameworks */,
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */, 0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */, 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */, 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */, 0C748EDA29D9256D0049B8BE /* Yams in Frameworks */,
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */, 0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -391,8 +383,6 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */, 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */, 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */, 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */,
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */,
); );
path = Classes; path = Classes;
sourceTree = "<group>"; sourceTree = "<group>";
@ -414,8 +404,6 @@
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */, 0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */, 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */, 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@ -424,7 +412,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */, 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
0CB725332C123E760047FC0B /* CloudMagnetView.swift */, 0CB725332C123E760047FC0B /* CloudTorrentView.swift */,
); );
path = Cloud; path = Cloud;
sourceTree = "<group>"; sourceTree = "<group>";
@ -469,7 +457,6 @@
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */, 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */, 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */,
0C8AE2472C0FFB6600701675 /* Store.swift */, 0C8AE2472C0FFB6600701675 /* Store.swift */,
0C07C6032C1A859B00808A46 /* FormDataBody.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@ -477,6 +464,8 @@
0C44E2A928D4DFC4007711AE /* Modifiers */ = { 0C44E2A928D4DFC4007711AE /* Modifiers */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */,
0CB6516228C5A57300DCA721 /* ConditionalId.swift */,
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */, 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */, 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
0CB6516428C5A5D700DCA721 /* InlinedList.swift */, 0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
@ -547,7 +536,6 @@
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */, 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */, 0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */, 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */,
); );
path = Settings; path = Settings;
sourceTree = "<group>"; sourceTree = "<group>";
@ -577,7 +565,9 @@
children = ( children = (
0C44E2A928D4DFC4007711AE /* Modifiers */, 0C44E2A928D4DFC4007711AE /* Modifiers */,
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */, 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
0CA148C1288903F000DE2211 /* NavView.swift */,
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */, 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */, 0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
0C2D9652299316CC00A504B6 /* Tag.swift */, 0C2D9652299316CC00A504B6 /* Tag.swift */,
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */, 0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */,
@ -665,8 +655,6 @@
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */, 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */, 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */, 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
); );
path = API; path = API;
sourceTree = "<group>"; sourceTree = "<group>";
@ -744,8 +732,8 @@
0C4CFC452897030D00AD9FAD /* Regex */, 0C4CFC452897030D00AD9FAD /* Regex */,
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */, 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
0CDDDE042935235E006810B1 /* BetterSafariView */, 0CDDDE042935235E006810B1 /* BetterSafariView */,
0C448BE829A135F100F4E266 /* Introspect-Static */,
0C748ED929D9256D0049B8BE /* Yams */, 0C748ED929D9256D0049B8BE /* Yams */,
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */,
); );
productName = Torrenter; productName = Torrenter;
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */; productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
@ -759,7 +747,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1400; LastSwiftUpdateCheck = 1400;
LastUpgradeCheck = 1600; LastUpgradeCheck = 1400;
TargetAttributes = { TargetAttributes = {
0CAF1C67286F5C0E00296F86 = { 0CAF1C67286F5C0E00296F86 = {
CreatedOnToolsVersion = 14.0; CreatedOnToolsVersion = 14.0;
@ -782,8 +770,8 @@
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */, 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */, 0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */, 0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */,
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
); );
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */; productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -835,7 +823,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */,
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */, 0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */, 0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */, 0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
@ -843,11 +830,13 @@
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */, 0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */,
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */, 0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */,
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */, 0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */,
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */, 0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */,
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */, 0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */, 0C2D9653299316CC00A504B6 /* Tag.swift in Sources */,
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */, 0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */,
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */, 0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */,
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */, 0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */, 0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */, 0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
@ -874,6 +863,7 @@
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */, 0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */, 0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */, 0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */, 0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */, 0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */, 0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
@ -884,7 +874,7 @@
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */, 0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */, 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */, 0CB725342C123E760047FC0B /* CloudTorrentView.swift in Sources */,
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */, 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */, 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */, 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
@ -900,6 +890,7 @@
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */, 0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */, 0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */, 0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */, 0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */,
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */, 0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */, 0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
@ -930,22 +921,17 @@
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */, 0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */,
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */, 0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */, 0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */,
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */, 0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */, 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */, 0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */, 0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */, 0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */,
0C871BDF29994D9D005279AC /* FilterLabelView.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 */, 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */,
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */,
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */, 0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */,
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */, 0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */, 0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */, 0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */,
0C7075E429D374C50093DB2D /* Color.swift in Sources */, 0C7075E429D374C50093DB2D /* Color.swift in Sources */,
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */, 0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */, 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
@ -962,13 +948,11 @@
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */, 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */, 0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */, 0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */,
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */, 0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */,
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */, 0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */, 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */, 0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */, 0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */,
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */, 0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */, 0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */,
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */, 0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
@ -988,7 +972,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@ -1021,7 +1004,6 @@
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@ -1043,7 +1025,6 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = minimal;
}; };
name = Debug; name = Debug;
}; };
@ -1051,7 +1032,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@ -1084,7 +1064,6 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@ -1099,7 +1078,6 @@
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = minimal;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@ -1110,11 +1088,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3; DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist; INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite; INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
@ -1125,12 +1102,12 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportsDocumentBrowser = YES; INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.3; MARKETING_VERSION = 0.7.0;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -1146,11 +1123,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 21; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3; DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist; INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite; INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
@ -1161,12 +1137,12 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportsDocumentBrowser = YES; INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.3; MARKETING_VERSION = 0.7.0;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite; PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -1200,6 +1176,14 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference 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" */ = { 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/Regex"; repositoryURL = "https://github.com/sindresorhus/Regex";
@ -1240,14 +1224,6 @@
kind = branch; 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" */ = { 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
@ -1267,6 +1243,11 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
0C448BE829A135F100F4E266 /* Introspect-Static */ = {
isa = XCSwiftPackageProductDependency;
package = 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = "Introspect-Static";
};
0C4CFC452897030D00AD9FAD /* Regex */ = { 0C4CFC452897030D00AD9FAD /* Regex */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */; package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */;
@ -1292,11 +1273,6 @@
package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
productName = SwiftyJSON; productName = SwiftyJSON;
}; };
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */ = {
isa = XCSwiftPackageProductDependency;
package = 0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
productName = SwiftUIIntrospect;
};
0CAF1C7A286F5C8600296F86 /* SwiftSoup */ = { 0CAF1C7A286F5C8600296F86 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */; package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;

View file

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

View file

@ -7,26 +7,19 @@
import Foundation import Foundation
class AllDebrid: PollingDebridSource, ObservableObject { // TODO: Fix errors
let id = "AllDebrid" public class AllDebrid: PollingDebridSource, ObservableObject {
let abbreviation = "AD" public let id = "AllDebrid"
let website = "https://alldebrid.com" public let abbreviation = "AD"
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " + public let website = "https://alldebrid.com"
"You must pay to access this service. \n\n" + public var authTask: Task<Void, Error>?
"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."
let cachedStatus: [String] = ["Ready"] public var authProcessing: Bool = false
var authTask: Task<Void, Error>? public var isLoggedIn: Bool {
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil getToken() != nil
} }
var manualToken: String? { public var manualToken: String? {
if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") { if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") {
return getToken() return getToken()
} else { } else {
@ -34,28 +27,20 @@ class AllDebrid: PollingDebridSource, ObservableObject {
} }
} }
@Published var IAValues: [DebridIA] = [] @Published public var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = [] @Published public var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = [] @Published public var cloudTorrents: [DebridCloudTorrent] = []
var cloudTTL: Double = 0.0 public var cloudTTL: Double = 0.0
private let baseApiUrl = "https://api.alldebrid.com/v4" let baseApiUrl = "https://api.alldebrid.com/v4"
private let appName = "Ferrite" let appName = "Ferrite"
private let jsonDecoder = JSONDecoder() let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth // MARK: - Auth
// Fetches information for PIN auth // Fetches information for PIN auth
func getAuthUrl() async throws -> URL { public func getAuthUrl() async throws -> URL {
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get") let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
let request = URLRequest(url: url) let request = URLRequest(url: url)
@ -81,14 +66,14 @@ class AllDebrid: PollingDebridSource, ObservableObject {
} }
// Fetches API keys // Fetches API keys
func getApiKey(checkID: String, pin: String) async throws { public func getApiKey(checkID: String, pin: String) async throws {
let queryItems = [ let queryItems = [
URLQueryItem(name: "agent", value: appName), URLQueryItem(name: "agent", value: appName),
URLQueryItem(name: "check", value: checkID), URLQueryItem(name: "check", value: checkID),
URLQueryItem(name: "pin", value: pin) URLQueryItem(name: "pin", value: pin)
] ]
let request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems)) let request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
// Timer to poll AD API for key // Timer to poll AD API for key
authTask = Task { authTask = Task {
@ -124,17 +109,17 @@ class AllDebrid: PollingDebridSource, ObservableObject {
} }
// Adds a manual API key instead of web auth // Adds a manual API key instead of web auth
func setApiKey(_ key: String) { public func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey") FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey") UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
} }
func getToken() -> String? { public func getToken() -> String? {
FerriteKeychain.shared.get("AllDebrid.ApiKey") FerriteKeychain.shared.get("AllDebrid.ApiKey")
} }
// Clears tokens. No endpoint to deregister a device // Clears tokens. No endpoint to deregister a device
func logout() { public func logout() {
FerriteKeychain.shared.delete("AllDebrid.ApiKey") FerriteKeychain.shared.delete("AllDebrid.ApiKey")
UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey") UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey")
} }
@ -165,7 +150,7 @@ class AllDebrid: PollingDebridSource, ObservableObject {
} }
// Builds a URL for further requests // Builds a URL for further requests
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL { private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: urlString) else { guard var components = URLComponents(string: urlString) else {
throw DebridError.InvalidUrl throw DebridError.InvalidUrl
} }
@ -183,7 +168,7 @@ class AllDebrid: PollingDebridSource, ObservableObject {
// MARK: - Instant availability // MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws { public func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
let sendMagnets = magnets.filter { magnet in let sendMagnets = magnets.filter { magnet in
@ -199,82 +184,65 @@ class AllDebrid: PollingDebridSource, ObservableObject {
} }
} }
// Fetch the user magnets to the latest version if sendMagnets.isEmpty {
try await getUserMagnets() return
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: []
)
)
}
} }
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)
}
return DebridIA(
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
source: self.id,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
}
IAValues += availableHashes
} }
// MARK: - Downloading // MARK: - Downloading
// Wrapper function to fetch a download link from the API // Wrapper function to fetch a download link from the API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String {
let selectedMagnetId: String let selectedMagnetId: String
if let existingMagnet = cloudMagnets.first(where: { if let existingMagnet = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "Ready" }) {
$0.hash == magnet.hash && cachedStatus.contains($0.status) selectedMagnetId = existingMagnet.torrentId
}) {
selectedMagnetId = existingMagnet.id
} else { } else {
let magnetId = try await addMagnet(magnet: magnet) let magnetId = try await addMagnet(magnet: magnet)
selectedMagnetId = String(magnetId) selectedMagnetId = String(magnetId)
} }
let rawResponse = try await fetchMagnetStatus( let lockedLink = try await fetchMagnetStatus(
magnetId: selectedMagnetId, magnetId: selectedMagnetId,
selectedIndex: iaFile?.id ?? 0 selectedIndex: iaFile?.fileId ?? 0
) )
guard let magnets = rawResponse.magnets[safe: 0] else {
throw DebridError.EmptyUserMagnets
}
// Batches require an unrestrict from the user try await saveLink(link: lockedLink)
if magnets.links.count > 1, iaFile == nil { let downloadUrl = try await unlockLink(lockedLink: lockedLink)
var copiedIA = ia
copiedIA?.files = magnets.links.enumerated().compactMap { index, file in return downloadUrl
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 // Adds a magnet link to the user's AD account
func addMagnet(magnet: Magnet) async throws -> Int { public func addMagnet(magnet: Magnet) async throws -> Int {
guard let magnetLink = magnet.link else { guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid") throw DebridError.FailedRequest(description: "The magnet link is invalid")
} }
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload")) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -289,61 +257,67 @@ class AllDebrid: PollingDebridSource, ObservableObject {
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
if let magnet = rawResponse.magnets[safe: 0] { if let magnet = rawResponse.magnets[safe: 0] {
if !magnet.ready {
throw DebridError.IsCaching
}
return magnet.id return magnet.id
} else { } else {
throw DebridError.InvalidResponse throw DebridError.InvalidResponse
} }
} }
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse { public func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> String {
let queryItems = [ let queryItems = [
URLQueryItem(name: "id", value: magnetId) URLQueryItem(name: "id", value: magnetId)
] ]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems)) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
return rawResponse // Better to fetch no link at all than the wrong link
if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] {
return linkWrapper.link
} else {
throw DebridError.EmptyTorrents
}
} }
// Known as unlockLink in AD's API public func unlockLink(lockedLink: String) async throws -> String {
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
let queryItems = [ let queryItems = [
URLQueryItem(name: "link", value: restrictedFile.streamUrlString) URLQueryItem(name: "link", value: lockedLink)
] ]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems)) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: "unlockLink") let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
return rawResponse.link return rawResponse.link
} }
func saveLink(link: String) async throws { public func saveLink(link: String) async throws {
let queryItems = [ let queryItems = [
URLQueryItem(name: "links[]", value: link) URLQueryItem(name: "links[]", value: link)
] ]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems)) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }
// MARK: - Cloud methods // MARK: - Cloud methods
func getUserMagnets() async throws { // Referred to as "User magnets" in AllDebrid's API
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status")) public func getUserTorrents() async throws {
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
cloudMagnets = rawResponse.magnets.map { magnetResponse in if rawResponse.magnets.isEmpty {
DebridCloudMagnet( throw DebridError.EmptyData
id: String(magnetResponse.id), }
cloudTorrents = rawResponse.magnets.map { magnetResponse in
DebridCloudTorrent(
torrentId: String(magnetResponse.id),
source: self.id,
fileName: magnetResponse.filename, fileName: magnetResponse.filename,
status: magnetResponse.status, status: magnetResponse.status,
hash: magnetResponse.hash, hash: magnetResponse.hash,
@ -352,44 +326,48 @@ class AllDebrid: PollingDebridSource, ObservableObject {
} }
} }
func deleteUserMagnet(cloudMagnetId: String?) async throws { public func deleteTorrent(torrentId: String?) async throws {
guard let cloudMagnetId else { guard let torrentId else {
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid") throw DebridError.FailedRequest(description: "The torrentID \(String(describing: torrentId)) is invalid")
} }
let queryItems = [ let queryItems = [
URLQueryItem(name: "id", value: cloudMagnetId) URLQueryItem(name: "id", value: torrentId)
] ]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems)) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }
func getUserDownloads() async throws { public func getUserDownloads() async throws {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links")) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
if rawResponse.links.isEmpty {
throw DebridError.EmptyData
}
// The link is also the ID // The link is also the ID
cloudDownloads = rawResponse.links.map { link in cloudDownloads = rawResponse.links.map { link in
DebridCloudDownload( DebridCloudDownload(
id: link.link, fileName: link.filename, link: link.link downloadId: link.link, source: self.id, fileName: link.filename, link: link.link
) )
} }
} }
// Not used // Not used
func checkUserDownloads(link: String) -> String? { public func checkUserDownloads(link: String) async throws -> String? {
link nil
} }
// The downloadId is actually the download link // The downloadId is actually the download link
func deleteUserDownload(downloadId: String) async throws { public func deleteDownload(downloadId: String) async throws {
let queryItems = [ let queryItems = [
URLQueryItem(name: "link", value: downloadId) URLQueryItem(name: "link", value: downloadId)
] ]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems)) var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }

View file

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

View file

@ -7,15 +7,15 @@
import Foundation import Foundation
class Kodi { public class Kodi {
private let encoder = JSONEncoder() let encoder = JSONEncoder()
// Used to add server to CoreData. Not part of API // Used to add server to CoreData. Not part of API
func addServer(urlString: String, public func addServer(urlString: String,
friendlyName: String?, friendlyName: String?,
username: String?, username: String?,
password: String?, password: String?,
existingServer: KodiServer? = nil) throws existingServer: KodiServer? = nil) throws
{ {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
@ -65,7 +65,7 @@ class Kodi {
try backgroundContext.save() try backgroundContext.save()
} }
func ping(server: KodiServer) async throws { public func ping(server: KodiServer) async throws {
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!) var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@ -94,7 +94,7 @@ class Kodi {
} }
} }
func sendVideoUrl(urlString: String, server: KodiServer) async throws { public func sendVideoUrl(urlString: String, server: KodiServer) async throws {
if URL(string: urlString) == nil { if URL(string: urlString) == nil {
throw KodiError.InvalidPlaybackUrl throw KodiError.InvalidPlaybackUrl
} }

View file

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

View file

@ -7,19 +7,16 @@
import Foundation import Foundation
class Premiumize: OAuthDebridSource, ObservableObject { public class Premiumize: OAuthDebridSource, ObservableObject {
let id = "Premiumize" public let id = "Premiumize"
let abbreviation = "PM" public let abbreviation = "PM"
let website = "https://premiumize.me" public let website = "https://premiumize.me"
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " + @Published public var authProcessing: Bool = false
"You must pay to access the service." public var isLoggedIn: Bool {
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil getToken() != nil
} }
var manualToken: String? { public var manualToken: String? {
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
return getToken() return getToken()
} else { } else {
@ -27,27 +24,20 @@ class Premiumize: OAuthDebridSource, ObservableObject {
} }
} }
@Published var IAValues: [DebridIA] = [] @Published public var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = [] @Published public var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = [] @Published public var cloudTorrents: [DebridCloudTorrent] = []
var cloudTTL: Double = 0.0 public var cloudTTL: Double = 0.0
private let baseAuthUrl = "https://www.premiumize.me/authorize" let baseAuthUrl = "https://www.premiumize.me/authorize"
private let baseApiUrl = "https://www.premiumize.me/api" let baseApiUrl = "https://www.premiumize.me/api"
private let clientId = "791565696" let clientId = "791565696"
private let jsonDecoder = JSONDecoder() let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
}
}
// MARK: - Auth // MARK: - Auth
func getAuthUrl() throws -> URL { public func getAuthUrl() throws -> URL {
var urlComponents = URLComponents(string: baseAuthUrl)! var urlComponents = URLComponents(string: baseAuthUrl)!
urlComponents.queryItems = [ urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId), URLQueryItem(name: "client_id", value: clientId),
@ -62,7 +52,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
} }
} }
func handleAuthCallback(url: URL) throws { public func handleAuthCallback(url: URL) throws {
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let callbackFragment = callbackComponents?.fragment else { guard let callbackFragment = callbackComponents?.fragment else {
@ -80,17 +70,17 @@ class Premiumize: OAuthDebridSource, ObservableObject {
} }
// Adds a manual API key instead of web auth // Adds a manual API key instead of web auth
func setApiKey(_ key: String) { public func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken") FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey") UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
} }
func getToken() -> String? { public func getToken() -> String? {
FerriteKeychain.shared.get("Premiumize.AccessToken") FerriteKeychain.shared.get("Premiumize.AccessToken")
} }
// Clears tokens. No endpoint to deregister a device // Clears tokens. No endpoint to deregister a device
func logout() { public func logout() {
FerriteKeychain.shared.delete("Premiumize.AccessToken") FerriteKeychain.shared.delete("Premiumize.AccessToken")
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey") UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
} }
@ -142,7 +132,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
// MARK: - Instant availability // MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws { public func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
// Remove magnets that don't have an associated link for PM along with existing TTL logic // Remove magnets that don't have an associated link for PM along with existing TTL logic
@ -178,7 +168,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
// Function to divide and execute DDL endpoint requests in parallel // Function to divide and execute DDL endpoint requests in parallel
// Calls this for 10 requests at a time to not overwhelm API servers // Calls this for 10 requests at a time to not overwhelm API servers
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] { public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
for magnet in magnetChunk { for magnet in magnetChunk {
group.addTask { group.addTask {
@ -197,7 +187,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
} }
// Grabs DDL links // Grabs DDL links
private func fetchDDL(magnet: Magnet) async throws -> DebridIA { func fetchDDL(magnet: Magnet) async throws -> DebridIA {
if magnet.hash == nil { if magnet.hash == nil {
throw DebridError.EmptyData throw DebridError.EmptyData
} }
@ -218,7 +208,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
if !content.isEmpty { if !content.isEmpty {
let files = content.map { file in let files = content.map { file in
DebridIAFile( DebridIAFile(
id: 0, fileId: 0,
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path, name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
streamUrlString: file.link streamUrlString: file.link
) )
@ -226,6 +216,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
return DebridIA( return DebridIA(
magnet: magnet, magnet: magnet,
source: id,
expiryTimeStamp: Date().timeIntervalSince1970 + 300, expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files files: files
) )
@ -236,7 +227,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
// Function to divide and execute cache endpoint requests in parallel // Function to divide and execute cache endpoint requests in parallel
// Calls this for 100 hashes at a time due to API limits // Calls this for 100 hashes at a time due to API limits
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] { public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
for chunk in magnets.chunked(into: 100) { for chunk in magnets.chunked(into: 100) {
group.addTask { group.addTask {
@ -256,7 +247,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
} }
// Parent function for initial checking of the cache // Parent function for initial checking of the cache
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] { func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")! var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) } urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
guard let url = urlComponents.url else { guard let url = urlComponents.url else {
@ -285,28 +276,21 @@ class Premiumize: OAuthDebridSource, ObservableObject {
// MARK: - Downloading // MARK: - Downloading
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { // Wrapper function to fetch a DDL link from the API
public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String {
// Store the item in PM cloud for later use // Store the item in PM cloud for later use
try await createTransfer(magnet: magnet) try await createTransfer(magnet: magnet)
if let iaFile { if let iaFile, let streamUrlString = iaFile.streamUrlString {
return (iaFile, nil) return streamUrlString
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] { } else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0], let streamUrlString = firstFile.streamUrlString {
return (firstFile, nil) return streamUrlString
} else { } else {
throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API") throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
} }
} }
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { func createTransfer(magnet: Magnet) async throws {
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 { guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid") throw DebridError.FailedRequest(description: "The magnet link is invalid")
} }
@ -325,7 +309,7 @@ class Premiumize: OAuthDebridSource, ObservableObject {
// MARK: - Cloud methods // MARK: - Cloud methods
func getUserDownloads() async throws { public func getUserDownloads() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
@ -337,11 +321,11 @@ class Premiumize: OAuthDebridSource, ObservableObject {
// The "link" is the ID for Premiumize // The "link" is the ID for Premiumize
cloudDownloads = rawResponse.files.map { file in cloudDownloads = rawResponse.files.map { file in
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id) DebridCloudDownload(downloadId: file.id, source: self.id, fileName: file.name, link: file.id)
} }
} }
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse { func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")! var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)] urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
guard let url = urlComponents.url else { guard let url = urlComponents.url else {
@ -356,12 +340,12 @@ class Premiumize: OAuthDebridSource, ObservableObject {
return rawResponse return rawResponse
} }
func checkUserDownloads(link: String) async throws -> String? { public func checkUserDownloads(link: String) async throws -> String? {
// Link is the cloud item ID // Link is the cloud item ID
try await itemDetails(itemID: link).link try await itemDetails(itemID: link).link
} }
func deleteUserDownload(downloadId: String) async throws { public func deleteDownload(downloadId: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -374,8 +358,8 @@ class Premiumize: OAuthDebridSource, ObservableObject {
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }
// No user magnets for Premiumize // No user torrents for Premiumize
func getUserMagnets() {} public func getUserTorrents() async throws {}
func deleteUserMagnet(cloudMagnetId: String?) {} public func deleteTorrent(torrentId: String?) async throws {}
} }

View file

@ -7,28 +7,20 @@
import Foundation import Foundation
class RealDebrid: PollingDebridSource, ObservableObject { public class RealDebrid: PollingDebridSource, ObservableObject {
let id = "RealDebrid" public let id = "RealDebrid"
let abbreviation = "RD" public let abbreviation = "RD"
let website = "https://real-debrid.com" public let website = "https://real-debrid.com"
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " + public var authTask: Task<Void, Error>?
"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."
let cachedStatus: [String] = ["downloaded"] @Published public var authProcessing: Bool = false
var authTask: Task<Void, Error>?
@Published var authProcessing: Bool = false
// Check the manual token since getTokens() is async // Check the manual token since getTokens() is async
var isLoggedIn: Bool { public var isLoggedIn: Bool {
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
} }
var manualToken: String? { public var manualToken: String? {
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") { if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
return FerriteKeychain.shared.get("RealDebrid.AccessToken") return FerriteKeychain.shared.get("RealDebrid.AccessToken")
} else { } else {
@ -36,39 +28,31 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
} }
@Published var IAValues: [DebridIA] = [] @Published public var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = [] @Published public var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = [] @Published public var cloudTorrents: [DebridCloudTorrent] = []
var cloudTTL: Double = 0.0 public var cloudTTL: Double = 0.0
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0" let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
private let openSourceClientId = "X245A4XAIBGVM" let openSourceClientId = "X245A4XAIBGVM"
private let jsonDecoder = JSONDecoder() let jsonDecoder = JSONDecoder()
@MainActor @MainActor
private func setUserDefaultsValue(_ value: Any, forKey: String) { func setUserDefaultsValue(_ value: Any, forKey: String) {
UserDefaults.standard.set(value, forKey: forKey) UserDefaults.standard.set(value, forKey: forKey)
} }
@MainActor @MainActor
private func removeUserDefaultsValue(forKey: String) { func removeUserDefaultsValue(forKey: String) {
UserDefaults.standard.removeObject(forKey: forKey) UserDefaults.standard.removeObject(forKey: forKey)
} }
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth // MARK: - Auth
// Fetches the device code from RD // Fetches the device code from RD
func getAuthUrl() async throws -> URL { public func getAuthUrl() async throws -> URL {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")! var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
urlComponents.queryItems = [ urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId), URLQueryItem(name: "client_id", value: openSourceClientId),
@ -102,7 +86,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Fetches the user's client ID and secret // Fetches the user's client ID and secret
func getDeviceCredentials(deviceCode: String) async throws { public func getDeviceCredentials(deviceCode: String) async throws {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")! var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
urlComponents.queryItems = [ urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId), URLQueryItem(name: "client_id", value: openSourceClientId),
@ -146,7 +130,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Fetch all tokens for the user and store in FerriteKeychain.shared // Fetch all tokens for the user and store in FerriteKeychain.shared
func getApiTokens(deviceCode: String) async throws { public func getApiTokens(deviceCode: String) async throws {
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else { guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
throw DebridError.EmptyData throw DebridError.EmptyData
} }
@ -180,7 +164,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
} }
func getToken() async -> String? { public func getToken() async -> String? {
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp") let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
if Date().timeIntervalSince1970 > accessTokenStamp { if Date().timeIntervalSince1970 > accessTokenStamp {
@ -199,7 +183,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
// Adds a manual API key instead of web auth // Adds a manual API key instead of web auth
// Clear out existing refresh tokens and timestamps // Clear out existing refresh tokens and timestamps
func setApiKey(_ key: String) { public func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken") FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
FerriteKeychain.shared.delete("RealDebrid.RefreshToken") FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp") FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
@ -208,7 +192,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Deletes tokens from device and RD's servers // Deletes tokens from device and RD's servers
func logout() async { public func logout() async {
FerriteKeychain.shared.delete("RealDebrid.RefreshToken") FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
FerriteKeychain.shared.delete("RealDebrid.ClientSecret") FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
@ -252,9 +236,8 @@ class RealDebrid: PollingDebridSource, ObservableObject {
// MARK: - Instant availability // MARK: - Instant availability
// Post-API changes // Checks if the magnet is streamable on RD
// Use user magnets to check for IA instead public func instantAvailability(magnets: [Magnet]) async throws {
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
let sendMagnets = magnets.filter { magnet in let sendMagnets = magnets.filter { magnet in
@ -270,16 +253,70 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
} }
// Fetch the user magnets to the latest version if sendMagnets.isEmpty {
try await getUserMagnets() return
}
for cloudMagnet in cloudMagnets { var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(sendMagnets.compactMap(\.hash).joined(separator: "/"))")!)
if cachedStatus.contains(cloudMagnet.status),
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash }) 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
IAValues.append( IAValues.append(
DebridIA( DebridIA(
magnet: Magnet(hash: cloudMagnet.hash, link: nil), 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,
expiryTimeStamp: Date().timeIntervalSince1970 + 300, expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: [] files: []
) )
@ -291,52 +328,30 @@ class RealDebrid: PollingDebridSource, ObservableObject {
// MARK: - Downloading // MARK: - Downloading
// Wrapper function to fetch a download link from the API // Wrapper function to fetch a download link from the API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) { public func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String {
var selectedMagnetId = "" var selectedMagnetId = ""
do { do {
// Don't queue a new job if the magnet already exists in the user's library // Don't queue a new job if the torrent already exists
if let existingCloudMagnet = cloudMagnets.first(where: { if let existingTorrent = cloudTorrents.first(where: { $0.hash == magnet.hash && $0.status == "downloaded" }) {
$0.hash == magnet.hash && cachedStatus.contains($0.status) selectedMagnetId = existingTorrent.torrentId
}) {
selectedMagnetId = existingCloudMagnet.id
} else { } else {
selectedMagnetId = try await addMagnet(magnet: magnet) selectedMagnetId = try await addMagnet(magnet: magnet)
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? []) try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
} }
let response = try await torrentInfo(debridID: selectedMagnetId)
let filteredFiles = response.files.filter { $0.selected == 1 }
// Need to return this to the user
if filteredFiles.count > 1, iaFile == nil {
var copiedIA = ia
copiedIA?.files = response.files.enumerated().compactMap { index, file in
DebridIAFile(
id: index,
name: file.path,
streamUrlString: response.links[safe: index]
)
}
return (nil, copiedIA)
}
// RealDebrid has 1 as the first ID for a file // RealDebrid has 1 as the first ID for a file
let selectedFileId = iaFile?.id ?? 1 let torrentLink = try await torrentInfo(
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId }) debridID: selectedMagnetId,
selectedFileId: iaFile?.fileId ?? 1
)
let downloadLink = try await unrestrictLink(debridDownloadLink: torrentLink)
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else { return downloadLink
throw DebridError.EmptyUserMagnets
}
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
return (restrictedFile, nil)
} catch { } catch {
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty { if case DebridError.EmptyTorrents = error, !selectedMagnetId.isEmpty {
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId) try? await deleteTorrent(torrentId: selectedMagnetId)
} }
// Re-raise the error to the calling function // Re-raise the error to the calling function
@ -345,7 +360,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Adds a magnet link to the user's RD account // Adds a magnet link to the user's RD account
func addMagnet(magnet: Magnet) async throws -> String { public func addMagnet(magnet: Magnet) async throws -> String {
guard let magnetLink = magnet.link else { guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid") throw DebridError.FailedRequest(description: "The magnet link is invalid")
} }
@ -366,7 +381,7 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Queues the magnet link for downloading // Queues the magnet link for downloading
func selectFiles(debridID: String, fileIds: [Int]) async throws { public func selectFiles(debridID: String, fileIds: [Int]) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -386,31 +401,32 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Gets the info of a torrent from a given ID // Gets the info of a torrent from a given ID
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse { public func torrentInfo(debridID: String, selectedFileId: Int?) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data) 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 magnet is downloading // Let the user know if a torrent is downloading
switch rawResponse.status { if let torrentLink = rawResponse.links[safe: linkIndex ?? -1], rawResponse.status == "downloaded" {
case "downloaded": return torrentLink
return rawResponse } else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
case "downloading", "queued":
throw DebridError.IsCaching throw DebridError.IsCaching
default: } else {
throw DebridError.EmptyUserMagnets throw DebridError.EmptyTorrents
} }
} }
// Downloads link from selectFiles for playback // Downloads link from selectFiles for playback
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String { public func unrestrictLink(debridDownloadLink: String) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents() var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)] bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
request.httpBody = bodyComponents.query?.data(using: .utf8) request.httpBody = bodyComponents.query?.data(using: .utf8)
@ -422,38 +438,39 @@ class RealDebrid: PollingDebridSource, ObservableObject {
// MARK: - Cloud methods // MARK: - Cloud methods
// Gets the user's cloud magnet library // Gets the user's torrent library
func getUserMagnets() async throws { public func getUserTorrents() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data) let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
cloudMagnets = rawResponse.map { response in cloudTorrents = rawResponse.map { response in
DebridCloudMagnet( DebridCloudTorrent(
id: response.id, torrentId: response.id,
source: self.id,
fileName: response.filename, fileName: response.filename,
status: response.status, status: response.status,
hash: response.hash, hash: response.hash,
links: [response.id] links: response.links
) )
} }
} }
// Deletes a magnet download from RD // Deletes a torrent download from RD
func deleteUserMagnet(cloudMagnetId: String?) async throws { public func deleteTorrent(torrentId: String?) async throws {
let deleteId: String let deleteId: String
if let cloudMagnetId { if let torrentId {
deleteId = cloudMagnetId deleteId = torrentId
} else { } else {
// Refresh the user magnet list // Refresh the torrent cloud
// The first file is the currently caching one // The first file is the currently caching one
let _ = try await getUserMagnets() let _ = try await getUserTorrents()
guard let firstCloudMagnet = cloudMagnets[safe: -1] else { guard let firstTorrent = cloudTorrents[safe: -1] else {
throw DebridError.EmptyUserMagnets throw DebridError.EmptyTorrents
} }
deleteId = firstCloudMagnet.id deleteId = firstTorrent.torrentId
} }
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
@ -463,22 +480,22 @@ class RealDebrid: PollingDebridSource, ObservableObject {
} }
// Gets the user's downloads // Gets the user's downloads
func getUserDownloads() async throws { public func getUserDownloads() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
let data = try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data) let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
cloudDownloads = rawResponse.map { response in cloudDownloads = rawResponse.map { response in
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download) DebridCloudDownload(downloadId: response.id, source: self.id, fileName: response.filename, link: response.download)
} }
} }
// Not used // Not used
func checkUserDownloads(link: String) -> String? { public func checkUserDownloads(link: String) -> String? {
link nil
} }
func deleteUserDownload(downloadId: String) async throws { public func deleteDownload(downloadId: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
request.httpMethod = "DELETE" request.httpMethod = "DELETE"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Action" representedClassName="Action" syncable="YES"> <entity name="Action" representedClassName="Action" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/> <attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/> <attribute name="author" attributeType="String" defaultValueString=""/>
@ -106,7 +106,6 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/> <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="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="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="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="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/> <relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
@ -119,7 +118,6 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/> <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="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="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="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="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/> <relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
@ -136,14 +134,6 @@
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/> <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"/> <relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
</entity> </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"> <entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/> <attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/> <attribute name="rssUrl" optional="YES" attributeType="String"/>
@ -151,7 +141,6 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/> <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="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="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="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="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/> <relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>

View file

@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
extension Color { public extension Color {
init(hex: String) { init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0 var int: UInt64 = 0

View file

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

View file

@ -9,6 +9,10 @@ import UIKit
extension UIDevice { extension UIDevice {
var hasNotch: Bool { var hasNotch: Bool {
UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0 if #available(iOS 11.0, *) {
return UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
} else {
return false
}
} }
} }

View file

@ -5,12 +5,13 @@
// Created by Brian Dashore on 8/15/22. // Created by Brian Dashore on 8/15/22.
// //
import Introspect
import SwiftUI import SwiftUI
extension View { extension View {
// Modifies properties of a view. Works the same way as a ViewModifier // 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 // From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
func modifyViewProp(_ body: (inout Self) -> Void) -> Self { public func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
var result = self var result = self
body(&result) body(&result)
@ -19,6 +20,16 @@ extension View {
// MARK: Modifiers // 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 { func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation)) modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
} }

View file

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

View file

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

View file

@ -7,7 +7,7 @@
import Foundation import Foundation
extension AllDebrid { public extension AllDebrid {
// MARK: - Generic AllDebrid response // MARK: - Generic AllDebrid response
// Uses a generic parametr for whatever underlying response is present // Uses a generic parametr for whatever underlying response is present
@ -53,7 +53,7 @@ extension AllDebrid {
// MARK: - AddMagnetData // MARK: - AddMagnetData
struct AddMagnetData: Codable { internal struct AddMagnetData: Codable {
let magnet, hash, name, filenameOriginal: String let magnet, hash, name, filenameOriginal: String
let size: Int let size: Int
let ready: Bool let ready: Bool
@ -71,7 +71,7 @@ extension AllDebrid {
struct MagnetStatusResponse: Codable { struct MagnetStatusResponse: Codable {
let magnets: [MagnetStatusData] let magnets: [MagnetStatusData]
init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) { if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
@ -103,7 +103,7 @@ extension AllDebrid {
// MARK: - MagnetStatusLink // MARK: - MagnetStatusLink
// Abridged for required parameters // Abridged for required parameters
struct MagnetStatusLink: Codable { internal struct MagnetStatusLink: Codable {
let link: String let link: String
let filename: String let filename: String
let size: Int let size: Int
@ -137,7 +137,7 @@ extension AllDebrid {
// MARK: - IAMagnetResponse // MARK: - IAMagnetResponse
struct InstantAvailabilityMagnet: Codable { internal struct InstantAvailabilityMagnet: Codable {
let magnet, hash: String let magnet, hash: String
let instant: Bool let instant: Bool
let files: [InstantAvailabilityFile]? let files: [InstantAvailabilityFile]?
@ -145,7 +145,7 @@ extension AllDebrid {
// MARK: - IAFileResponse // MARK: - IAFileResponse
struct InstantAvailabilityFile: Codable { internal struct InstantAvailabilityFile: Codable {
let name: String let name: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {

View file

@ -8,7 +8,7 @@
import Foundation import Foundation
// Version is optional until v1 is phased out // Version is optional until v1 is phased out
struct Backup: Codable { public struct Backup: Codable {
let version: Int? let version: Int?
var bookmarks: [BookmarkJson]? var bookmarks: [BookmarkJson]?
var history: [HistoryJson]? var history: [HistoryJson]?

View file

@ -10,7 +10,7 @@ import Foundation
// MARK: - Universal IA enum (IA = InstantAvailability) // MARK: - Universal IA enum (IA = InstantAvailability)
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable { public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
case full = "Cached" case full = "Cached"
case partial = "Batch" case partial = "Batch"
case none = "Uncached" case none = "Uncached"
@ -18,7 +18,7 @@ enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
// MARK: - Enum for debrid differentiation. 0 is nil // MARK: - Enum for debrid differentiation. 0 is nil
enum DebridType: Int, Codable, Hashable, CaseIterable { public enum DebridType: Int, Codable, Hashable, CaseIterable {
case realDebrid = 1 case realDebrid = 1
case allDebrid = 2 case allDebrid = 2
case premiumize = 3 case premiumize = 3
@ -47,7 +47,7 @@ enum DebridType: Int, Codable, Hashable, CaseIterable {
} }
// Wrapper struct for magnet links to contain both the link and hash for easy access // Wrapper struct for magnet links to contain both the link and hash for easy access
struct Magnet: Codable, Hashable, Sendable { public struct Magnet: Codable, Hashable, Sendable {
var hash: String? var hash: String?
var link: String? var link: String?
@ -55,14 +55,12 @@ struct Magnet: Codable, Hashable, Sendable {
if let hash, link == nil { if let hash, link == nil {
self.hash = parseHash(hash) self.hash = parseHash(hash)
self.link = generateLink(hash: hash, title: title, trackers: trackers) self.link = generateLink(hash: hash, title: title, trackers: trackers)
} else if let link, hash == nil { } else if let parsedLink = parseLink(link), hash == nil {
let (link, hash) = parseLink(link) self.link = parsedLink
self.hash = parseHash(extractHash(link: parsedLink))
self.link = link
self.hash = hash
} else { } else {
self.hash = parseHash(hash) self.hash = parseHash(hash)
self.link = parseLink(link).link self.link = parseLink(link)
} }
} }
@ -110,35 +108,19 @@ struct Magnet: Codable, Hashable, Sendable {
} }
} }
func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) { func parseLink(_ link: String?) -> String? {
let separator = "magnet:?xt=urn:btih:" if let decodedLink = link?.removingPercentEncoding {
let separator = "magnet:?xt=urn:btih:"
// Remove percent encoding from the link and ensure it's a magnet if decodedLink.starts(with: separator) {
guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else { return decodedLink
return (nil, nil) } else if decodedLink.contains(separator) {
} let splitLink = decodedLink.components(separatedBy: separator)
return splitLink.last.map { separator + $0 } ?? nil
// Isolate the magnet link if it's bundled with another protocol } else {
let isolatedLink: String? return nil
if decodedLink.starts(with: separator) { }
isolatedLink = decodedLink
} else { } else {
let splitLink = decodedLink.components(separatedBy: separator) return nil
isolatedLink = splitLink.last.map { separator + $0 }
}
guard let isolatedLink else {
return (nil, nil)
}
// If the hash can be extracted, decrypt it if necessary and return the revised link + hash
if let originalHash = extractHash(link: isolatedLink),
let parsedHash = parseHash(originalHash)
{
let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash)
return (replacedLink, parsedHash)
} else {
return (decodedLink, nil)
} }
} }
} }

View file

@ -7,49 +7,51 @@
import Foundation import Foundation
struct DebridIA: Hashable, Sendable { public struct DebridIA: Hashable, Sendable {
let magnet: Magnet let magnet: Magnet
let source: String
let expiryTimeStamp: Double let expiryTimeStamp: Double
var files: [DebridIAFile] var files: [DebridIAFile]
} }
struct DebridIAFile: Hashable, Sendable { public struct DebridIAFile: Hashable, Sendable {
let id: Int let fileId: Int
let name: String let name: String
let streamUrlString: String? let streamUrlString: String?
let batchIds: [Int] let batchIds: [Int]
init(id: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) { init(fileId: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) {
self.id = id self.fileId = fileId
self.name = name self.name = name
self.streamUrlString = streamUrlString self.streamUrlString = streamUrlString
self.batchIds = batchIds self.batchIds = batchIds
} }
} }
struct DebridCloudDownload: Hashable, Sendable { public struct DebridCloudDownload: Hashable, Sendable {
let id: String let downloadId: String
let source: String
let fileName: String let fileName: String
let link: String let link: String
} }
struct DebridCloudMagnet: Hashable, Sendable { public struct DebridCloudTorrent: Hashable, Sendable {
let id: String let torrentId: String
let source: String
let fileName: String let fileName: String
let status: String let status: String
let hash: String let hash: String
let links: [String] let links: [String]
} }
enum DebridError: Error { public enum DebridError: Error {
case InvalidUrl case InvalidUrl
case InvalidPostBody case InvalidPostBody
case InvalidResponse case InvalidResponse
case InvalidToken case InvalidToken
case EmptyData case EmptyData
case EmptyUserMagnets case EmptyTorrents
case IsCaching case IsCaching
case FailedRequest(description: String) case FailedRequest(description: String)
case AuthQuery(description: String) case AuthQuery(description: String)
case NotImplemented
} }

View file

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

View file

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

View file

@ -7,7 +7,7 @@
import Foundation import Foundation
struct PluginListJson: Codable { public struct PluginListJson: Codable {
let name: String let name: String
let author: String let author: String
var sources: [SourceJson]? var sources: [SourceJson]?
@ -16,8 +16,8 @@ struct PluginListJson: Codable {
// Color: Hex value // Color: Hex value
public struct PluginTagJson: Codable, Hashable, Sendable { public struct PluginTagJson: Codable, Hashable, Sendable {
let name: String public let name: String
let colorHex: String? public let colorHex: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name

View file

@ -7,7 +7,7 @@
import Foundation import Foundation
extension Premiumize { public extension Premiumize {
// MARK: - CacheCheckResponse // MARK: - CacheCheckResponse
struct CacheCheckResponse: Codable { struct CacheCheckResponse: Codable {
@ -20,6 +20,7 @@ extension Premiumize {
struct DDLResponse: Codable { struct DDLResponse: Codable {
let status: String let status: String
let content: [DDLData]? let content: [DDLData]?
let location: String
let filename: String let filename: String
let filesize: Int let filesize: Int
} }

View file

@ -8,7 +8,7 @@
import Foundation import Foundation
extension RealDebrid { public extension RealDebrid {
// MARK: - device code endpoint // MARK: - device code endpoint
struct DeviceCodeResponse: Codable, Sendable { struct DeviceCodeResponse: Codable, Sendable {
@ -58,7 +58,7 @@ extension RealDebrid {
struct InstantAvailabilityResponse: Codable, Sendable { struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData? var data: InstantAvailabilityData?
init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) { if let data = try? container.decode(InstantAvailabilityData.self) {
@ -67,11 +67,11 @@ extension RealDebrid {
} }
} }
struct InstantAvailabilityData: Codable, Sendable { internal struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]] var rd: [[String: InstantAvailabilityInfo]]
} }
struct InstantAvailabilityInfo: Codable, Sendable { internal struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String var filename: String
var filesize: Int var filesize: Int
} }
@ -96,7 +96,7 @@ extension RealDebrid {
// MARK: - torrentInfo endpoint // MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable, Sendable { internal struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int let bytes, originalBytes: Int
let host: String let host: String
@ -117,7 +117,7 @@ extension RealDebrid {
} }
} }
struct TorrentInfoFile: Codable, Sendable { internal struct TorrentInfoFile: Codable, Sendable {
let id: Int let id: Int
let path: String let path: String
let bytes, selected: Int let bytes, selected: Int
@ -136,7 +136,7 @@ extension RealDebrid {
// MARK: - unrestrictLink endpoint // MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable, Sendable { internal struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String let id, filename: String
let mimeType: String? let mimeType: String?
let filesize: Int let filesize: Int

View file

@ -8,7 +8,7 @@
import Foundation import Foundation
// A raw search result structure displayed on the UI // A raw search result structure displayed on the UI
struct SearchResult: Codable, Hashable, Sendable { public struct SearchResult: Codable, Hashable, Sendable {
let title: String? let title: String?
let source: String let source: String
let size: String? let size: String?

View file

@ -7,14 +7,14 @@
import Foundation import Foundation
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable { public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case json case json
case text case text
} }
struct SourceJson: Codable, Hashable, Sendable, PluginJson { public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
let name: String public let name: String
let version: Int16 public let version: Int16
let minVersion: String? let minVersion: String?
let about: String? let about: String?
let website: String? let website: String?
@ -25,33 +25,33 @@ struct SourceJson: Codable, Hashable, Sendable, PluginJson {
let jsonParser: SourceJsonParserJson? let jsonParser: SourceJsonParserJson?
let rssParser: SourceRssParserJson? let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson? let htmlParser: SourceHtmlParserJson?
let author: String? public let author: String?
let listId: UUID? public let listId: UUID?
let listName: String? public let listName: String?
let tags: [PluginTagJson]? public let tags: [PluginTagJson]?
} }
extension SourceJson { public extension SourceJson {
// Fetches all tags without optional requirement // Fetches all tags without optional requirement
func getTags() -> [PluginTagJson] { func getTags() -> [PluginTagJson] {
tags ?? [] tags ?? []
} }
} }
enum SourcePreferredParser: Int16, CaseIterable, Sendable { public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
// case none = 0 // case none = 0
case scraping = 1 case scraping = 1
case rss = 2 case rss = 2
case siteApi = 3 case siteApi = 3
} }
struct SourceApiJson: Codable, Hashable, Sendable { public struct SourceApiJson: Codable, Hashable, Sendable {
let apiUrl: String? let apiUrl: String?
let clientId: SourceApiCredentialJson? let clientId: SourceApiCredentialJson?
let clientSecret: SourceApiCredentialJson? let clientSecret: SourceApiCredentialJson?
} }
struct SourceApiCredentialJson: Codable, Hashable, Sendable { public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let query: String? let query: String?
let value: String? let value: String?
let dynamic: Bool? let dynamic: Bool?
@ -60,9 +60,8 @@ struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let expiryLength: Double? let expiryLength: Double?
} }
struct SourceJsonParserJson: Codable, Hashable, Sendable { public struct SourceJsonParserJson: Codable, Hashable, Sendable {
let searchUrl: String let searchUrl: String
let request: SourceRequestJson?
let results: String? let results: String?
let subResults: String? let subResults: String?
let title: SourceComplexQueryJson let title: SourceComplexQueryJson
@ -73,10 +72,9 @@ struct SourceJsonParserJson: Codable, Hashable, Sendable {
let sl: SourceSLJson? let sl: SourceSLJson?
} }
struct SourceRssParserJson: Codable, Hashable, Sendable { public struct SourceRssParserJson: Codable, Hashable, Sendable {
let rssUrl: String? let rssUrl: String?
let searchUrl: String let searchUrl: String
let request: SourceRequestJson?
let items: String let items: String
let title: SourceComplexQueryJson let title: SourceComplexQueryJson
let magnetHash: SourceComplexQueryJson? let magnetHash: SourceComplexQueryJson?
@ -86,9 +84,8 @@ struct SourceRssParserJson: Codable, Hashable, Sendable {
let sl: SourceSLJson? let sl: SourceSLJson?
} }
struct SourceHtmlParserJson: Codable, Hashable, Sendable { public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String? let searchUrl: String?
let request: SourceRequestJson?
let rows: String let rows: String
let title: SourceComplexQueryJson let title: SourceComplexQueryJson
let magnet: SourceMagnetJson let magnet: SourceMagnetJson
@ -97,21 +94,21 @@ struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let sl: SourceSLJson? let sl: SourceSLJson?
} }
struct SourceComplexQueryJson: Codable, Hashable, Sendable { public struct SourceComplexQueryJson: Codable, Hashable, Sendable {
let query: String let query: String
let discriminator: String? let discriminator: String?
let attribute: String? let attribute: String?
let regex: String? let regex: String?
} }
struct SourceMagnetJson: Codable, Hashable, Sendable { public struct SourceMagnetJson: Codable, Hashable, Sendable {
let query: String let query: String
let attribute: String let attribute: String
let regex: String? let regex: String?
let externalLinkQuery: String? let externalLinkQuery: String?
} }
struct SourceSLJson: Codable, Hashable, Sendable { public struct SourceSLJson: Codable, Hashable, Sendable {
let seeders: String? let seeders: String?
let leechers: String? let leechers: String?
let combined: String? let combined: String?
@ -120,9 +117,3 @@ struct SourceSLJson: Codable, Hashable, Sendable {
let seederRegex: String? let seederRegex: String?
let leecherRegex: String? let leecherRegex: String?
} }
struct SourceRequestJson: Codable, Hashable, Sendable {
let method: String?
let headers: [String: String]?
let body: String?
}

View file

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

View file

@ -7,14 +7,12 @@
import Foundation import Foundation
protocol DebridSource: AnyObservableObject { public protocol DebridSource: AnyObservableObject {
// ID of the service // ID of the service
// var id: DebridInfo { get } // var id: DebridInfo { get }
var id: String { get } var id: String { get }
var abbreviation: String { get } var abbreviation: String { get }
var website: String { get } var website: String { get }
var description: String? { get }
var cachedStatus: [String] { get }
// Auth variables // Auth variables
var authProcessing: Bool { get set } var authProcessing: Bool { get set }
@ -23,50 +21,37 @@ protocol DebridSource: AnyObservableObject {
// Manual API key // Manual API key
var manualToken: String? { get } 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 // Common authentication functions
func setApiKey(_ key: String) func setApiKey(_ key: String)
func logout() async func logout() async
// Instant availability variables
var IAValues: [DebridIA] { get set }
// Instant availability functions // Instant availability functions
func instantAvailability(magnets: [Magnet]) async throws func instantAvailability(magnets: [Magnet]) async throws
// Fetches a download link from a source // Fetches a download link from a source
// Include the instant availability information with the args // Include the instant availability information with the args
// Cloud magnets also checked here // Torrents also checked here
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String
// Unrestricts a locked file // Cloud variables
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String var cloudDownloads: [DebridCloudDownload] { get set }
var cloudTorrents: [DebridCloudTorrent] { get set }
var cloudTTL: Double { get set }
// User downloads functions // User downloads functions
func getUserDownloads() async throws func getUserDownloads() async throws
func checkUserDownloads(link: String) async throws -> String? func checkUserDownloads(link: String) async throws -> String?
func deleteUserDownload(downloadId: String) async throws func deleteDownload(downloadId: String) async throws
// User magnet functions // User torrent functions
func getUserMagnets() async throws func getUserTorrents() async throws
func deleteUserMagnet(cloudMagnetId: String?) async throws func deleteTorrent(torrentId: String?) async throws
} }
extension DebridSource { public protocol PollingDebridSource: DebridSource {
var description: String? {
nil
}
var cachedStatus: [String] {
[]
}
}
protocol PollingDebridSource: DebridSource {
// Task reference for polling // Task reference for polling
var authTask: Task<Void, Error>? { get set } var authTask: Task<Void, Error>? { get set }
@ -74,7 +59,7 @@ protocol PollingDebridSource: DebridSource {
func getAuthUrl() async throws -> URL func getAuthUrl() async throws -> URL
} }
protocol OAuthDebridSource: DebridSource { public protocol OAuthDebridSource: DebridSource {
// Fetches the auth URL // Fetches the auth URL
func getAuthUrl() throws -> URL func getAuthUrl() throws -> URL

View file

@ -8,7 +8,7 @@
import CoreData import CoreData
import Foundation import Foundation
protocol Plugin: ObservableObject, NSManagedObject { public protocol Plugin: ObservableObject, NSManagedObject {
var id: UUID { get set } var id: UUID { get set }
var listId: UUID? { get set } var listId: UUID? { get set }
var name: String { get set } var name: String { get set }
@ -27,7 +27,7 @@ extension Plugin {
} }
} }
protocol PluginJson: Hashable { public protocol PluginJson: Hashable {
var name: String { get } var name: String { get }
var version: Int16 { get } var version: Int16 { get }
var author: String? { get } var author: String? { get }

View file

@ -9,7 +9,7 @@
import Foundation import Foundation
class Application { public class Application {
static let shared = Application() static let shared = Application()
// OS name for Plugins to read. Lowercase for ease of use // OS name for Plugins to read. Lowercase for ease of use

View file

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

View file

@ -27,7 +27,7 @@ class ErasedObservableObject: ObservableObject {
} }
} }
protocol AnyObservableObject: AnyObject { public protocol AnyObservableObject: AnyObject {
var objectWillChange: ObservableObjectPublisher { get } var objectWillChange: ObservableObjectPublisher { get }
} }
@ -59,14 +59,14 @@ protocol AnyObservableObject: AnyObject {
/// Not all injected objects need this property wrapper. See the example projects for examples each /// Not all injected objects need this property wrapper. See the example projects for examples each
/// way. /// way.
@propertyWrapper @propertyWrapper
struct Store<ObjectType> { public struct Store<ObjectType> {
/// The underlying object being stored. /// The underlying object being stored.
let wrappedValue: ObjectType public let wrappedValue: ObjectType
// See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38 // See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38
fileprivate var _observableObject: ObservedObject<ErasedObservableObject> fileprivate var _observableObject: ObservedObject<ErasedObservableObject>
@MainActor var observableObject: ErasedObservableObject { @MainActor internal var observableObject: ErasedObservableObject {
_observableObject.wrappedValue _observableObject.wrappedValue
} }
@ -83,16 +83,16 @@ struct Store<ObjectType> {
/// } /// }
/// } /// }
/// ``` /// ```
var projectedValue: Wrapper { public var projectedValue: Wrapper {
Wrapper(self) Wrapper(self)
} }
/// Create a stored value on a custom scheduler. /// Create a stored value on a custom scheduler.
/// ///
/// Use this init to schedule updates on a specific scheduler other than `DispatchQueue.main`. /// Use this init to schedule updates on a specific scheduler other than `DispatchQueue.main`.
init<S: Scheduler>(wrappedValue: ObjectType, public init<S: Scheduler>(wrappedValue: ObjectType,
on scheduler: S, on scheduler: S,
schedulerOptions: S.SchedulerOptions? = nil) schedulerOptions: S.SchedulerOptions? = nil)
{ {
self.wrappedValue = wrappedValue self.wrappedValue = wrappedValue
@ -112,7 +112,7 @@ struct Store<ObjectType> {
/// Create a stored value which publishes on the main thread. /// Create a stored value which publishes on the main thread.
/// ///
/// To control when updates are published, see ``init(wrappedValue:on:schedulerOptions:)``. /// To control when updates are published, see ``init(wrappedValue:on:schedulerOptions:)``.
init(wrappedValue: ObjectType) { public init(wrappedValue: ObjectType) {
self.init(wrappedValue: wrappedValue, on: DispatchQueue.main) self.init(wrappedValue: wrappedValue, on: DispatchQueue.main)
} }
@ -120,15 +120,15 @@ struct Store<ObjectType> {
/// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper) /// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper)
/// type. /// type.
@dynamicMemberLookup @dynamicMemberLookup
struct Wrapper { public struct Wrapper {
private var store: Store private var store: Store
init(_ store: Store<ObjectType>) { internal init(_ store: Store<ObjectType>) {
self.store = store self.store = store
} }
/// Returns a binding to the resulting value of a given key path. /// Returns a binding to the resulting value of a given key path.
subscript<Subject>( public subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject> dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>
) -> Binding<Subject> { ) -> Binding<Subject> {
Binding { Binding {
@ -141,7 +141,7 @@ struct Store<ObjectType> {
} }
extension Store: DynamicProperty { extension Store: DynamicProperty {
nonisolated mutating func update() { public nonisolated mutating func update() {
_observableObject.update() _observableObject.update()
} }
} }

View file

@ -7,9 +7,9 @@
import Foundation import Foundation
class BackupManager: ObservableObject { public class BackupManager: ObservableObject {
// Constant variable for backup versions // Constant variable for backup versions
private let latestBackupVersion: Int = 2 let latestBackupVersion: Int = 2
var logManager: LoggingManager? var logManager: LoggingManager?
@ -21,17 +21,17 @@ class BackupManager: ObservableObject {
@Published var selectedBackupUrl: URL? @Published var selectedBackupUrl: URL?
@MainActor @MainActor
private func updateRestoreCompletedMessage(newString: String) { func updateRestoreCompletedMessage(newString: String) {
restoreCompletedMessage.append(newString) restoreCompletedMessage.append(newString)
} }
@MainActor @MainActor
private func toggleRestoreCompletedAlert() { func toggleRestoreCompletedAlert() {
showRestoreCompletedAlert.toggle() showRestoreCompletedAlert.toggle()
} }
@MainActor @MainActor
private func updateBackupUrls(newUrl: URL) { func updateBackupUrls(newUrl: URL) {
backupUrls.append(newUrl) backupUrls.append(newUrl)
} }

View file

@ -9,21 +9,26 @@ import Foundation
import SwiftUI import SwiftUI
@MainActor @MainActor
class DebridManager: ObservableObject { public class DebridManager: ObservableObject {
// Linked classes // Linked classes
var logManager: LoggingManager? var logManager: LoggingManager?
@Published var realDebrid: RealDebrid = .init() @Published var realDebrid: RealDebrid = .init()
@Published var allDebrid: AllDebrid = .init() @Published var allDebrid: AllDebrid = .init()
@Published var premiumize: Premiumize = .init() @Published var premiumize: Premiumize = .init()
@Published var torbox: TorBox = .init()
@Published var offcloud: OffCloud = .init()
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud] lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize]
// UI Variables // UI Variables
@Published var showWebView: Bool = false @Published var showWebView: Bool = false
@Published var showAuthSession: Bool = false @Published var showAuthSession: Bool = false
@Published var enabledDebrids: [DebridSource] = []
var hasEnabledDebrids: Bool {
debridSources.contains { $0.isLoggedIn }
}
var enabledDebridCount: Int {
debridSources.filter(\.isLoggedIn).count
}
@Published var selectedDebridSource: DebridSource? { @Published var selectedDebridSource: DebridSource? {
didSet { didSet {
@ -33,10 +38,9 @@ class DebridManager: ObservableObject {
var selectedDebridItem: DebridIA? var selectedDebridItem: DebridIA?
var selectedDebridFile: DebridIAFile? var selectedDebridFile: DebridIAFile?
var requiresUnrestrict: Bool = false
// TODO: Figure out a way to remove this var // TODO: Figure out a way to remove this var
private var selectedOAuthDebridSource: OAuthDebridSource? var selectedOAuthDebridSource: OAuthDebridSource?
@Published var filteredIAStatus: Set<IAStatus> = [] @Published var filteredIAStatus: Set<IAStatus> = []
@ -44,15 +48,18 @@ class DebridManager: ObservableObject {
var downloadUrl: String = "" var downloadUrl: String = ""
var authUrl: URL? var authUrl: URL?
// RealDebrid auth variables
var realDebridAuthProcessing: Bool = false
@Published var showDeleteAlert: Bool = false @Published var showDeleteAlert: Bool = false
@Published var showWebLoginAlert: Bool = false
@Published var showNotImplementedAlert: Bool = false // AllDebrid auth variables
@Published var notImplementedMessage: String = "" var allDebridAuthProcessing: Bool = false
// Premiumize auth variables
var premiumizeAuthProcessing: Bool = false
init() { init() {
// Update the UI for debrid services that are enabled
enabledDebrids = debridSources.filter(\.isLoggedIn)
// Set the preferred service. Contains migration logic for earlier versions // Set the preferred service. Contains migration logic for earlier versions
if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") { if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") {
let debridServiceId: String? let debridServiceId: String?
@ -76,7 +83,7 @@ class DebridManager: ObservableObject {
// TODO: Remove after v0.8.0 // TODO: Remove after v0.8.0
// Function to migrate the preferred service to the new string ID format // Function to migrate the preferred service to the new string ID format
private func migratePreferredService(_ idInt: Int) -> String? { public func migratePreferredService(_ idInt: Int) -> String? {
// Undo the EnabledDebrids key // Undo the EnabledDebrids key
UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray") UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray")
@ -85,7 +92,7 @@ class DebridManager: ObservableObject {
// Wrapper function to match error descriptions // Wrapper function to match error descriptions
// Error can be suppressed to end user but must be printed in logs // Error can be suppressed to end user but must be printed in logs
private func sendDebridError( func sendDebridError(
_ error: Error, _ error: Error,
prefix: String, prefix: String,
presentError: Bool = true, presentError: Bool = true,
@ -112,20 +119,20 @@ class DebridManager: ObservableObject {
} }
// Cleans all cached IA values in the event of a full IA refresh // Cleans all cached IA values in the event of a full IA refresh
func clearIAValues() { public func clearIAValues() {
for debridSource in debridSources { for debridSource in debridSources {
debridSource.IAValues = [] debridSource.IAValues = []
} }
} }
// Clears all selected files and items // Clears all selected files and items
func clearSelectedDebridItems() { public func clearSelectedDebridItems() {
selectedDebridItem = nil selectedDebridItem = nil
selectedDebridFile = nil selectedDebridFile = nil
} }
// Common function to populate hashes for debrid services // Common function to populate hashes for debrid services
func populateDebridIA(_ resultMagnets: [Magnet]) async { public func populateDebridIA(_ resultMagnets: [Magnet]) async {
for debridSource in debridSources { for debridSource in debridSources {
if !debridSource.isLoggedIn { if !debridSource.isLoggedIn {
continue continue
@ -141,7 +148,7 @@ class DebridManager: ObservableObject {
} }
// Common function to match a magnet hash with a provided debrid service // Common function to match a magnet hash with a provided debrid service
func matchMagnetHash(_ magnet: Magnet) -> IAStatus { public func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
guard let magnetHash = magnet.hash else { guard let magnetHash = magnet.hash else {
return .none return .none
} }
@ -155,9 +162,9 @@ class DebridManager: ObservableObject {
} }
} }
func selectDebridResult(magnet: Magnet) -> Bool { public func selectDebridResult(magnet: Magnet) -> Bool {
guard let magnetHash = magnet.hash else { guard let magnetHash = magnet.hash else {
logManager?.error("DebridManager: Could not find the magnet hash") logManager?.error("DebridManager: Could not find the torrent magnet hash")
return false return false
} }
@ -167,14 +174,9 @@ class DebridManager: ObservableObject {
if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) { if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) {
selectedDebridItem = IAItem selectedDebridItem = IAItem
if IAItem.files.count == 1 {
selectedDebridFile = IAItem.files[safe: 0]
}
return true return true
} else { } else {
logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)") logManager?.error("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
return false return false
} }
} }
@ -182,14 +184,14 @@ class DebridManager: ObservableObject {
// MARK: - Authentication UI linked functions // MARK: - Authentication UI linked functions
// Common function to delegate what debrid service to authenticate with // Common function to delegate what debrid service to authenticate with
func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async { public func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
defer { defer {
// Don't cancel processing if using OAuth // Don't cancel processing if using OAuth
if !(debridSource is OAuthDebridSource) { if !(debridSource is OAuthDebridSource) {
debridSource.authProcessing = false debridSource.authProcessing = false
} }
if enabledDebrids.count == 1 { if enabledDebridCount == 1 {
selectedDebridSource = debridSource selectedDebridSource = debridSource
} }
} }
@ -197,8 +199,6 @@ class DebridManager: ObservableObject {
// Set an API key if manually provided // Set an API key if manually provided
if let apiKey { if let apiKey {
debridSource.setApiKey(apiKey) debridSource.setApiKey(apiKey)
enabledDebrids.append(debridSource)
return return
} }
@ -211,7 +211,6 @@ class DebridManager: ObservableObject {
if validateAuthUrl(authUrl) { if validateAuthUrl(authUrl) {
try await pollingSource.authTask?.value try await pollingSource.authTask?.value
enabledDebrids.append(debridSource)
} else { } else {
throw DebridError.AuthQuery(description: "The authentication URL was invalid") throw DebridError.AuthQuery(description: "The authentication URL was invalid")
} }
@ -230,12 +229,8 @@ class DebridManager: ObservableObject {
await sendDebridError(error, prefix: "\(debridSource.id) authentication error") await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
} }
} else { } else {
// Let the user know that a traditional auth method doesn't exist
showWebLoginAlert.toggle()
logManager?.error( logManager?.error(
"DebridManager: Auth: \(debridSource.id) does not have a login portal.", "DebridManager: Auth: Could not figure out the authentication type for \(debridSource.id). Is this configured properly?"
showToast: false
) )
return return
@ -258,7 +253,7 @@ class DebridManager: ObservableObject {
} }
// Wrapper function to validate and present an auth URL to the user // Wrapper function to validate and present an auth URL to the user
@discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { @discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
guard let url else { guard let url else {
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))") logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
return false return false
@ -275,9 +270,9 @@ class DebridManager: ObservableObject {
} }
// Currently handles Premiumize callback // Currently handles Premiumize callback
func handleAuthCallback(url: URL?, error: Error?) async { public func handleAuthCallback(url: URL?, error: Error?) async {
defer { defer {
if enabledDebrids.count == 1 { if enabledDebridCount == 1 {
selectedDebridSource = selectedOAuthDebridSource selectedDebridSource = selectedOAuthDebridSource
} }
@ -295,7 +290,6 @@ class DebridManager: ObservableObject {
if let callbackUrl = url { if let callbackUrl = url {
try oauthDebridSource.handleAuthCallback(url: callbackUrl) try oauthDebridSource.handleAuthCallback(url: callbackUrl)
enabledDebrids.append(oauthDebridSource)
} else { } else {
throw DebridError.AuthQuery(description: "The callback URL was invalid") throw DebridError.AuthQuery(description: "The callback URL was invalid")
} }
@ -306,29 +300,22 @@ class DebridManager: ObservableObject {
// MARK: - Logout UI functions // MARK: - Logout UI functions
func logout(_ debridSource: some DebridSource) async { public func logout(_ debridSource: some DebridSource) async {
await debridSource.logout() await debridSource.logout()
if selectedDebridSource?.id == debridSource.id { if selectedDebridSource?.id == debridSource.id {
selectedDebridSource = nil selectedDebridSource = nil
} }
enabledDebrids.removeAll { $0.id == debridSource.id }
} }
// MARK: - Debrid fetch UI linked functions // MARK: - Debrid fetch UI linked functions
// Common function to delegate what debrid service to fetch from // Common function to delegate what debrid service to fetch from
// Cloudinfo is used for any extra information provided by debrid cloud // Cloudinfo is used for any extra information provided by debrid cloud
func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
defer { defer {
logManager?.hideIndeterminateToast()
if !requiresUnrestrict {
clearSelectedDebridItems()
}
currentDebridTask = nil currentDebridTask = nil
logManager?.hideIndeterminateToast()
} }
logManager?.updateIndeterminateToast("Loading content", cancelAction: { logManager?.updateIndeterminateToast("Loading content", cancelAction: {
@ -341,37 +328,18 @@ class DebridManager: ObservableObject {
} }
do { do {
// Cleanup beforehand
requiresUnrestrict = false
if let cloudInfo { if let cloudInfo {
downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? "" downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? ""
return return
} }
if let magnet { if let magnet {
let (restrictedFile, newIA) = try await debridSource.getRestrictedFile( let downloadLink = try await debridSource.getDownloadLink(
magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile 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 // Update the UI
downloadUrl = try await debridSource.unrestrictFile(restrictedFile) downloadUrl = downloadLink
} else { } else {
throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API") throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API")
} }
@ -385,48 +353,22 @@ class DebridManager: ObservableObject {
default: default:
await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled") await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled")
} }
}
}
func unrestrictDownload() async {
defer {
logManager?.hideIndeterminateToast() 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 // Wrapper to handle cloud fetching
func fetchDebridCloud(bypassTTL: Bool = false) async { public func fetchDebridCloud(bypassTTL: Bool = false) async {
guard let selectedSource = selectedDebridSource else { guard let selectedSource = selectedDebridSource else {
return return
} }
if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL { if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL {
do { do {
// Populates the inner downloads and magnet arrays // Populates the inner downloads and torrent arrays
try await selectedSource.getUserDownloads() try await selectedSource.getUserDownloads()
try await selectedSource.getUserMagnets() try await selectedSource.getUserTorrents()
// Update the TTL to 5 minutes from now // Update the TTL to 5 minutes from now
selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300 selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300
@ -439,55 +381,31 @@ class DebridManager: ObservableObject {
} }
} }
func deleteCloudDownload(_ download: DebridCloudDownload) async { public func deleteCloudDownload(_ download: DebridCloudDownload) async {
guard let selectedSource = selectedDebridSource else { guard let selectedSource = selectedDebridSource else {
return return
} }
do { do {
try await selectedSource.deleteUserDownload(downloadId: download.id) try await selectedSource.deleteDownload(downloadId: download.downloadId)
await fetchDebridCloud(bypassTTL: true) await fetchDebridCloud(bypassTTL: true)
} catch { } catch {
switch error { await sendDebridError(error, prefix: "\(selectedSource.id) download delete 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")
}
} }
} }
func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async { public func deleteCloudTorrent(_ torrent: DebridCloudTorrent) async {
guard let selectedSource = selectedDebridSource else { guard let selectedSource = selectedDebridSource else {
return return
} }
do { do {
try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id) try await selectedSource.deleteTorrent(torrentId: torrent.torrentId)
await fetchDebridCloud(bypassTTL: true) await fetchDebridCloud(bypassTTL: true)
} catch { } catch {
switch error { await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete 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")
}
} }
} }
} }

View file

@ -1,5 +1,5 @@
// //
// LoggingManager.swift // ToastViewModel.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/19/22. // Created by Brian Dashore on 7/19/22.
@ -70,8 +70,8 @@ class LoggingManager: ObservableObject {
// TODO: Maybe append to a constant logfile? // TODO: Maybe append to a constant logfile?
func info(_ message: String, public func info(_ message: String,
description: String? = nil) description: String? = nil)
{ {
let log = Log( let log = Log(
level: .info, level: .info,
@ -88,8 +88,8 @@ class LoggingManager: ObservableObject {
print("LOG: \(log.toMessage())") print("LOG: \(log.toMessage())")
} }
func warn(_ message: String, public func warn(_ message: String,
description: String? = nil) description: String? = nil)
{ {
let log = Log( let log = Log(
level: .warn, level: .warn,
@ -106,9 +106,9 @@ class LoggingManager: ObservableObject {
print("LOG: \(log.toMessage())") print("LOG: \(log.toMessage())")
} }
func error(_ message: String, public func error(_ message: String,
description: String? = nil, description: String? = nil,
showToast: Bool = true) showToast: Bool = true)
{ {
let log = Log( let log = Log(
level: .error, level: .error,
@ -132,7 +132,7 @@ class LoggingManager: ObservableObject {
// MARK: - Indeterminate functions // MARK: - Indeterminate functions
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) { public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
indeterminateToastDescription = description indeterminateToastDescription = description
if let cancelAction { if let cancelAction {
@ -144,13 +144,13 @@ class LoggingManager: ObservableObject {
} }
} }
func hideIndeterminateToast() { public func hideIndeterminateToast() {
showIndeterminateToast = false showIndeterminateToast = false
indeterminateToastDescription = "" indeterminateToastDescription = ""
indeterminateCancelAction = nil indeterminateCancelAction = nil
} }
func exportLogs() { public func exportLogs() {
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss" logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt" let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs") let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")

View file

@ -8,12 +8,12 @@
import SwiftUI import SwiftUI
@MainActor @MainActor
class NavigationViewModel: ObservableObject { public class NavigationViewModel: ObservableObject {
var logManager: LoggingManager? var logManager: LoggingManager?
// Used between SearchResultsView and MagnetChoiceView // Used between SearchResultsView and MagnetChoiceView
enum ChoiceSheetType: Identifiable { public enum ChoiceSheetType: Identifiable {
var id: Int { public var id: Int {
hashValue hashValue
} }
@ -53,7 +53,7 @@ class NavigationViewModel: ObservableObject {
@Published var currentSortFilter: SortFilter? @Published var currentSortFilter: SortFilter?
@Published var currentSortOrder: SortOrder = .forward @Published var currentSortOrder: SortOrder = .forward
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool { public func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
switch currentSortFilter { switch currentSortFilter {
case .leechers: case .leechers:
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else { guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
@ -97,7 +97,7 @@ class NavigationViewModel: ObservableObject {
@Published var searchPrompt: String = "Search" @Published var searchPrompt: String = "Search"
@Published var lastSearchPromptIndex: Int = -1 @Published var lastSearchPromptIndex: Int = -1
private let searchBarTextArray: [String] = [ let searchBarTextArray: [String] = [
"What's on your mind?", "What's on your mind?",
"Discover something interesting", "Discover something interesting",
"Find an engaging show", "Find an engaging show",

View file

@ -1,5 +1,5 @@
// //
// PluginManager.swift // SourceManager.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/25/22. // Created by Brian Dashore on 7/25/22.
@ -9,7 +9,7 @@ import Foundation
import SwiftUI import SwiftUI
import Yams import Yams
class PluginManager: ObservableObject { public class PluginManager: ObservableObject {
var logManager: LoggingManager? var logManager: LoggingManager?
let kodi: Kodi = .init() let kodi: Kodi = .init()
@ -25,18 +25,18 @@ class PluginManager: ObservableObject {
@Published var actionSuccessAlertMessage: String = "" @Published var actionSuccessAlertMessage: String = ""
@MainActor @MainActor
private func cleanAvailablePlugins() { func cleanAvailablePlugins() {
availableSources = [] availableSources = []
availableActions = [] availableActions = []
} }
@MainActor @MainActor
private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) { func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
availableSources += newPlugins.availableSources availableSources += newPlugins.availableSources
availableActions += newPlugins.availableActions availableActions += newPlugins.availableActions
} }
func fetchPluginsFromUrl() async { public func fetchPluginsFromUrl() async {
let pluginListRequest = PluginList.fetchRequest() let pluginListRequest = PluginList.fetchRequest()
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else { guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
await logManager?.error("PluginManager: No plugin lists found") await logManager?.error("PluginManager: No plugin lists found")
@ -97,7 +97,7 @@ class PluginManager: ObservableObject {
await logManager?.info("Plugin list fetch finished") await logManager?.info("Plugin list fetch finished")
} }
private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? { func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
var tempSources: [SourceJson] = [] var tempSources: [SourceJson] = []
var tempActions: [ActionJson] = [] var tempActions: [ActionJson] = []
@ -176,7 +176,7 @@ class PluginManager: ObservableObject {
} }
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback) // Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? { func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
let osArray = deeplinks.filter { deeplink in let osArray = deeplinks.filter { deeplink in
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() }) deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
} }
@ -244,7 +244,7 @@ class PluginManager: ObservableObject {
} }
} }
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] { func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
switch String(describing: PJ.self) { switch String(describing: PJ.self) {
case "SourceJson": case "SourceJson":
return availableSources as? [PJ] ?? [] return availableSources as? [PJ] ?? []
@ -256,7 +256,7 @@ class PluginManager: ObservableObject {
} }
// Checks if the current app version is supported by the source // Checks if the current app version is supported by the source
private func checkAppVersion(minVersion: String?) -> Bool { func checkAppVersion(minVersion: String?) -> Bool {
// If there's no min version, assume that every version is supported // If there's no min version, assume that every version is supported
guard let minVersion else { guard let minVersion else {
return true return true
@ -266,7 +266,7 @@ class PluginManager: ObservableObject {
} }
// Fetches sources using the background context // Fetches sources using the background context
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] { public func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
if !filteredInstalledSources.isEmpty, !searchResultsEmpty { if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
@ -279,7 +279,7 @@ class PluginManager: ObservableObject {
} }
@MainActor @MainActor
func runDefaultAction(urlString: String?, navModel: NavigationViewModel) { public func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
let context = PersistenceController.shared.backgroundContext let context = PersistenceController.shared.backgroundContext
guard let urlString else { guard let urlString else {
@ -332,7 +332,7 @@ class PluginManager: ObservableObject {
// The iOS version of Ferrite only runs deeplink actions // The iOS version of Ferrite only runs deeplink actions
@MainActor @MainActor
func runDeeplinkAction(_ action: Action, urlString: String?) { public func runDeeplinkAction(_ action: Action, urlString: String?) {
guard let deeplink = action.deeplink, let urlString else { 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!" actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
showActionErrorAlert.toggle() showActionErrorAlert.toggle()
@ -355,7 +355,7 @@ class PluginManager: ObservableObject {
} }
@MainActor @MainActor
func sendToKodi(urlString: String?, server: KodiServer) async { public func sendToKodi(urlString: String?, server: KodiServer) async {
guard let urlString else { guard let urlString else {
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send" actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
showActionErrorAlert.toggle() showActionErrorAlert.toggle()
@ -380,7 +380,7 @@ class PluginManager: ObservableObject {
} }
} }
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async { public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else { guard let actionJson else {
await logManager?.error("Action addition: No action present. Contact the app dev!") await logManager?.error("Action addition: No action present. Contact the app dev!")
return return
@ -448,7 +448,7 @@ class PluginManager: ObservableObject {
} }
} }
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async { public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
guard let sourceJson else { guard let sourceJson else {
await logManager?.error("Source addition: No source present. Contact the app dev!") await logManager?.error("Source addition: No source present. Contact the app dev!")
return return
@ -535,7 +535,7 @@ class PluginManager: ObservableObject {
} }
} }
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) { func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext) let newSourceApi = SourceApi(context: backgroundContext)
@ -570,8 +570,7 @@ class PluginManager: ObservableObject {
newSource.api = newSourceApi newSource.api = newSourceApi
} }
// TODO: Migrate parser addition to a common protocol func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext) let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
@ -579,13 +578,6 @@ class PluginManager: ObservableObject {
newSourceJsonParser.results = jsonParserJson.results newSourceJsonParser.results = jsonParserJson.results
newSourceJsonParser.subResults = jsonParserJson.subResults 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 // Tune these complex queries to the final JSON parser format
if let magnetLinkJson = jsonParserJson.magnetLink { if let magnetLinkJson = jsonParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
@ -646,7 +638,7 @@ class PluginManager: ObservableObject {
newSource.jsonParser = newSourceJsonParser newSource.jsonParser = newSourceJsonParser
} }
private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) { func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext) let newSourceRssParser = SourceRssParser(context: backgroundContext)
@ -654,13 +646,6 @@ class PluginManager: ObservableObject {
newSourceRssParser.searchUrl = rssParserJson.searchUrl newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items 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 { if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext) let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query newSourceMagnetLink.query = magnetLinkJson.query
@ -725,7 +710,7 @@ class PluginManager: ObservableObject {
newSource.rssParser = newSourceRssParser newSource.rssParser = newSourceRssParser
} }
private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) { func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext) let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
@ -741,16 +726,6 @@ class PluginManager: ObservableObject {
newSourceHtmlParser.subName = newSourceSubName 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 // Adds a title complex query
let newSourceTitle = SourceTitle(context: backgroundContext) let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = htmlParserJson.title.query newSourceTitle.query = htmlParserJson.title.query
@ -795,7 +770,7 @@ class PluginManager: ObservableObject {
// Adds a plugin list // Adds a plugin list
// Can move this to PersistenceController if needed // Can move this to PersistenceController if needed
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws { public func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") { if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {

View file

@ -27,18 +27,18 @@ class ScrapingViewModel: ObservableObject {
// Only add results with valid magnet hashes to the search results array // Only add results with valid magnet hashes to the search results array
@MainActor @MainActor
private func updateSearchResults(newResults: [SearchResult]) { func updateSearchResults(newResults: [SearchResult]) {
searchResults += newResults searchResults += newResults
} }
@MainActor @MainActor
private func clearSearchResults() { func clearSearchResults() {
searchResults = [] searchResults = []
} }
@Published var currentSourceNames: Set<String> = [] @Published var currentSourceNames: Set<String> = []
@MainActor @MainActor
private func updateCurrentSourceNames(_ newName: String) { func updateCurrentSourceNames(_ newName: String) {
currentSourceNames.insert(newName) currentSourceNames.insert(newName)
logManager?.updateIndeterminateToast( logManager?.updateIndeterminateToast(
"Loading \(currentSourceNames.joined(separator: ", "))", "Loading \(currentSourceNames.joined(separator: ", "))",
@ -47,7 +47,7 @@ class ScrapingViewModel: ObservableObject {
} }
@MainActor @MainActor
private func removeCurrentSourceName(_ removedName: String) { func removeCurrentSourceName(_ removedName: String) {
currentSourceNames.remove(removedName) currentSourceNames.remove(removedName)
logManager?.updateIndeterminateToast( logManager?.updateIndeterminateToast(
"Loading \(currentSourceNames.joined(separator: ", "))", "Loading \(currentSourceNames.joined(separator: ", "))",
@ -56,39 +56,17 @@ class ScrapingViewModel: ObservableObject {
} }
@MainActor @MainActor
private func clearCurrentSourceNames() { func clearCurrentSourceNames() {
currentSourceNames = [] currentSourceNames = []
logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil) logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil)
} }
// Utility function to print source specific errors // Utility function to print source specific errors
private func sendSourceError(_ description: String) async { func sendSourceError(_ description: String) async {
await logManager?.error(description, showToast: false) await logManager?.error(description, showToast: false)
} }
// Substitutes the given string with an arbitrary parameter dictionary public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
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)\"") await logManager?.info("Started scanning sources for query \"\(searchText)\"")
if sources.isEmpty { if sources.isEmpty {
@ -102,7 +80,7 @@ class ScrapingViewModel: ObservableObject {
cleanedSearchText = searchText.lowercased() cleanedSearchText = searchText.lowercased()
if await !debridManager.enabledDebrids.isEmpty { if await !debridManager.hasEnabledDebrids {
await debridManager.clearIAValues() await debridManager.clearIAValues()
} }
@ -136,7 +114,7 @@ class ScrapingViewModel: ObservableObject {
var failedSourceNames: [String] = [] var failedSourceNames: [String] = []
for await (requestResult, sourceName) in group { for await (requestResult, sourceName) in group {
if let requestResult { if let requestResult {
if await !debridManager.enabledDebrids.isEmpty { if await debridManager.hasEnabledDebrids {
await debridManager.populateDebridIA(requestResult.magnets) await debridManager.populateDebridIA(requestResult.magnets)
} }
@ -166,7 +144,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
private func executeParser(source: Source) async -> SearchRequestResult? { func executeParser(source: Source) async -> SearchRequestResult? {
guard let website = source.website else { guard let website = source.website else {
await logManager?.error("Scraping: The base URL could not be found for source \(source.name)") await logManager?.error("Scraping: The base URL could not be found for source \(source.name)")
@ -182,26 +160,19 @@ class ScrapingViewModel: ObservableObject {
return nil 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 { switch preferredParser {
case .scraping: case .scraping:
if let htmlParser = source.htmlParser { if let htmlParser = source.htmlParser {
let replacedSearchUrl = htmlParser.searchUrl.map { let replacedSearchUrl = htmlParser.searchUrl.map {
substituteParams($0, with: params) $0
.replacingOccurrences(of: "{query}", with: encodedQuery)
} }
let data = await handleUrls( let data = await handleUrls(
website: website, website: website,
replacedSearchUrl: replacedSearchUrl, replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls, fallbackUrls: source.fallbackUrls,
sourceName: source.name, sourceName: source.name
requestParams: htmlParser.request.map { cleanRequest(request: $0, params: params) }
) )
if let data, if let data,
@ -212,25 +183,23 @@ class ScrapingViewModel: ObservableObject {
} }
case .rss: case .rss:
if let rssParser = source.rssParser { if let rssParser = source.rssParser {
params.updateValue(source.api?.clientSecret?.value ?? "", forKey: "secret") let replacedSearchUrl = rssParser.searchUrl
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
let replacedSearchUrl = substituteParams(rssParser.searchUrl, with: params) .replacingOccurrences(of: "{query}", with: encodedQuery)
// Do not use fallback URLs if the base URL isn't used // Do not use fallback URLs if the base URL isn't used
let data: Data? let data: Data?
if let rssUrl = rssParser.rssUrl { if let rssUrl = rssParser.rssUrl {
data = await fetchWebsiteData( data = await fetchWebsiteData(
urlString: rssUrl + replacedSearchUrl, urlString: rssUrl + replacedSearchUrl,
sourceName: source.name, sourceName: source.name
requestParams: rssParser.request
) )
} else { } else {
data = await handleUrls( data = await handleUrls(
website: website, website: website,
replacedSearchUrl: replacedSearchUrl, replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls, fallbackUrls: source.fallbackUrls,
sourceName: source.name, sourceName: source.name
requestParams: rssParser.request
) )
} }
@ -242,7 +211,8 @@ class ScrapingViewModel: ObservableObject {
} }
case .siteApi: case .siteApi:
if let jsonParser = source.jsonParser { if let jsonParser = source.jsonParser {
var replacedSearchUrl = substituteParams(jsonParser.searchUrl, with: params) var replacedSearchUrl = jsonParser.searchUrl
.replacingOccurrences(of: "{query}", with: encodedQuery)
// Handle anything API related including tokens, client IDs, and appending the API URL // 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 // The source API key is for APIs that require extra credentials or use a different URL
@ -278,8 +248,7 @@ class ScrapingViewModel: ObservableObject {
website: passedUrl, website: passedUrl,
replacedSearchUrl: replacedSearchUrl, replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls, fallbackUrls: source.fallbackUrls,
sourceName: source.name, sourceName: source.name
requestParams: jsonParser.request
) )
if let data { if let data {
@ -294,16 +263,16 @@ class ScrapingViewModel: ObservableObject {
} }
// Checks the base URL for any website data then iterates through the fallback URLs // Checks the base URL for any website data then iterates through the fallback URLs
private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? { func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String) async -> Data? {
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "") let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) { if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
return data return data
} }
if let fallbackUrls { if let fallbackUrls {
for fallbackUrl in fallbackUrls { for fallbackUrl in fallbackUrls {
let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "") let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) { if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
return data return data
} }
} }
@ -312,12 +281,12 @@ class ScrapingViewModel: ObservableObject {
return nil return nil
} }
private func handleApiCredential(_ credential: SourceApiCredential, public func handleApiCredential(_ credential: SourceApiCredential,
replacement: String, replacement: String,
searchUrl: String, searchUrl: String,
apiUrl: String?, apiUrl: String?,
website: String, website: String,
sourceName: String) async -> String? sourceName: String) async -> String?
{ {
// Is the credential expired // Is the credential expired
var isExpired = false var isExpired = false
@ -329,7 +298,8 @@ class ScrapingViewModel: ObservableObject {
// Fetch a new credential if it's expired or doesn't exist yet // Fetch a new credential if it's expired or doesn't exist yet
if let value = credential.value, !isExpired { if let value = credential.value, !isExpired {
return substituteParams(searchUrl, with: [replacement: value]) return searchUrl
.replacingOccurrences(of: replacement, with: value)
} else if } else if
credential.value == nil || isExpired, credential.value == nil || isExpired,
let credentialUrl = credential.urlString, let credentialUrl = credential.urlString,
@ -353,9 +323,9 @@ class ScrapingViewModel: ObservableObject {
return nil return nil
} }
private func fetchApiCredential(urlString: String, public func fetchApiCredential(urlString: String,
credential: SourceApiCredential, credential: SourceApiCredential,
sourceName: String) async -> String? sourceName: String) async -> String?
{ {
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.") await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.")
@ -399,7 +369,7 @@ class ScrapingViewModel: ObservableObject {
} }
// Fetches the data for a URL // Fetches the data for a URL
private func fetchWebsiteData(urlString: String, sourceName: String, requestParams: SourceRequest?) async -> Data? { public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!") await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!")
@ -418,12 +388,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
var request = URLRequest(url: url, timeoutInterval: timeout) let 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 { do {
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
@ -446,7 +411,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? { public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
guard let jsonParser = source.jsonParser else { guard let jsonParser = source.jsonParser else {
return nil return nil
} }
@ -521,10 +486,10 @@ class ScrapingViewModel: ObservableObject {
} }
// TODO: Add regex parsing for API // TODO: Add regex parsing for API
private func parseJsonResult(_ result: JSON, public func parseJsonResult(_ result: JSON,
jsonParser: SourceJsonParser, jsonParser: SourceJsonParser,
source: Source, source: Source,
existingSearchResult: SearchResult? = nil) -> SearchResult? existingSearchResult: SearchResult? = nil) -> SearchResult?
{ {
// Enforce these parsers // Enforce these parsers
guard let titleParser = jsonParser.title else { guard let titleParser = jsonParser.title else {
@ -615,7 +580,7 @@ class ScrapingViewModel: ObservableObject {
} }
// RSS feed scraper // RSS feed scraper
private func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? { public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
guard let rssParser = source.rssParser else { guard let rssParser = source.rssParser else {
return nil return nil
} }
@ -750,11 +715,11 @@ class ScrapingViewModel: ObservableObject {
} }
// Complex query parsing for RSS scraping // Complex query parsing for RSS scraping
private func runRssComplexQuery(item: Element, func runRssComplexQuery(item: Element,
query: String, query: String,
attribute: String, attribute: String,
discriminator: String?, discriminator: String?,
regexString: String?) throws -> String? regexString: String?) throws -> String?
{ {
var parsedValue: String? var parsedValue: String?
@ -783,7 +748,7 @@ class ScrapingViewModel: ObservableObject {
} }
// HTML scraper // HTML scraper
private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? { public func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
guard let htmlParser = source.htmlParser else { guard let htmlParser = source.htmlParser else {
return nil return nil
} }
@ -835,7 +800,7 @@ class ScrapingViewModel: ObservableObject {
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
guard guard
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request), let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name),
let magnetHtml = String(data: data, encoding: .utf8) let magnetHtml = String(data: data, encoding: .utf8)
else { else {
continue continue
@ -920,7 +885,7 @@ class ScrapingViewModel: ObservableObject {
) )
} }
if let leecherQuery = seederLeecher.leechers { if let leecherQuery = seederLeecher.seeders {
leechers = try? runHtmlComplexQuery( leechers = try? runHtmlComplexQuery(
row: row, row: row,
query: leecherQuery, query: leecherQuery,
@ -955,10 +920,10 @@ class ScrapingViewModel: ObservableObject {
} }
// Complex query parsing for HTML scraping // Complex query parsing for HTML scraping
private func runHtmlComplexQuery(row: Element, func runHtmlComplexQuery(row: Element,
query: String, query: String,
attribute: String, attribute: String,
regexString: String?) throws -> String? regexString: String?) throws -> String?
{ {
var parsedValue: String? var parsedValue: String?
@ -980,7 +945,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
private func runRegex(parsedValue: String, regexString: String) -> String? { func runRegex(parsedValue: String, regexString: String) -> String? {
// TODO: Maybe dynamically parse flags // TODO: Maybe dynamically parse flags
let replacedRegexString = regexString let replacedRegexString = regexString
.replacingOccurrences(of: "{query}", with: cleanedSearchText) .replacingOccurrences(of: "{query}", with: cleanedSearchText)
@ -1003,7 +968,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
private func parseSizeString(sizeString: String) -> String? { func parseSizeString(sizeString: String) -> String? {
// Test if the string can be a full integer // Test if the string can be a full integer
guard let size = Int(sizeString) else { guard let size = Int(sizeString) else {
return nil return nil
@ -1025,7 +990,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
private func cleanApiCreds(api: SourceApi, sourceName: String) async { func cleanApiCreds(api: SourceApi, sourceName: String) async {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
let hasCredentials = api.clientId != nil || api.clientSecret != nil let hasCredentials = api.clientId != nil || api.clientSecret != nil

View file

@ -56,7 +56,7 @@ struct HybridSecureField: View {
} }
extension HybridSecureField { extension HybridSecureField {
func fieldDisabled(_ isFieldDisabled: Bool) -> Self { public func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
modifyViewProp { $0.isFieldDisabled = isFieldDisabled } modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
} }
} }

View file

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

View file

@ -0,0 +1,27 @@
//
// 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))
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
//
// 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)
}
}
}

View file

@ -56,27 +56,20 @@ struct BookmarksView: View {
.frame(height: 15) .frame(height: 15)
} }
.task { .task {
await matchAgainstIA() if debridManager.hasEnabledDebrids {
} let magnets = bookmarks.compactMap {
.refreshable { if let magnetHash = $0.magnetHash {
await matchAgainstIA() return Magnet(hash: magnetHash, link: $0.magnetLink)
} else {
return nil
}
}
await debridManager.populateDebridIA(magnets)
}
} }
} }
func fetchPredicate() { func fetchPredicate() {
bookmarks.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText) 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)
}
}
} }

View file

@ -24,24 +24,21 @@ struct CloudDownloadView: View {
Button(cloudDownload.fileName) { Button(cloudDownload.fileName) {
navModel.resultFromCloud = true navModel.resultFromCloud = true
navModel.selectedTitle = cloudDownload.fileName navModel.selectedTitle = cloudDownload.fileName
var historyEntry = HistoryEntryJson( debridManager.downloadUrl = cloudDownload.link
name: cloudDownload.fileName,
source: debridSource.id PersistenceController.shared.createHistory(
HistoryEntryJson(
name: cloudDownload.fileName,
url: cloudDownload.link,
source: debridSource.id
),
performSave: true
) )
debridManager.currentDebridTask = Task { pluginManager.runDefaultAction(
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link) urlString: debridManager.downloadUrl,
navModel: navModel
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)) .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.tint(.primary) .tint(.primary)

View file

@ -1,5 +1,5 @@
// //
// CloudMagnetView.swift // CloudTorrentView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 6/6/24. // Created by Brian Dashore on 6/6/24.
@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
struct CloudMagnetView: View { struct CloudTorrentView: View {
@EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager @EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager @EnvironmentObject var pluginManager: PluginManager
@ -17,37 +17,29 @@ struct CloudMagnetView: View {
@Binding var searchText: String @Binding var searchText: String
var body: some View { var body: some View {
DisclosureGroup("Magnets") { DisclosureGroup("Torrents") {
ForEach(debridSource.cloudMagnets.filter { ForEach(debridSource.cloudTorrents.filter {
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased()) searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
}, id: \.self) { cloudMagnet in }, id: \.self) { cloudTorrent in
Button { Button {
if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty { if cloudTorrent.status == "downloaded", !cloudTorrent.links.isEmpty {
navModel.resultFromCloud = true navModel.resultFromCloud = true
navModel.selectedTitle = cloudMagnet.fileName navModel.selectedTitle = cloudTorrent.fileName
var historyInfo = HistoryEntryJson( var historyInfo = HistoryEntryJson(
name: cloudMagnet.fileName, name: cloudTorrent.fileName,
source: debridSource.id source: debridSource.id
) )
Task { Task {
let magnet = Magnet(hash: cloudMagnet.hash, link: nil) let magnet = Magnet(hash: cloudTorrent.hash, link: nil)
await debridManager.populateDebridIA([magnet]) await debridManager.populateDebridIA([magnet])
if debridManager.selectDebridResult(magnet: magnet) { if debridManager.selectDebridResult(magnet: magnet) {
// Is this a batch? // Is this a batch?
if cloudMagnet.links.count == 1 { if cloudTorrent.links.count == 1 {
await debridManager.fetchDebridDownload(magnet: magnet) await debridManager.fetchDebridDownload(magnet: magnet)
// Bump to batch
if debridManager.requiresUnrestrict {
navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch
return
}
if !debridManager.downloadUrl.isEmpty { if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo, performSave: true) PersistenceController.shared.createHistory(historyInfo, performSave: true)
@ -67,15 +59,15 @@ struct CloudMagnetView: View {
} }
} label: { } label: {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(cloudMagnet.fileName) Text(cloudTorrent.fileName)
.font(.callout) .font(.callout)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(4) .lineLimit(4)
HStack { HStack {
Text(cloudMagnet.status.capitalizingFirstLetter()) Text(cloudTorrent.status.capitalizingFirstLetter())
Spacer() Spacer()
DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links) DebridLabelView(debridSource: debridSource, cloudLinks: cloudTorrent.links)
} }
.font(.caption) .font(.caption)
} }
@ -85,9 +77,9 @@ struct CloudMagnetView: View {
} }
.onDelete { offsets in .onDelete { offsets in
for index in offsets { for index in offsets {
if let cloudMagnet = debridSource.cloudMagnets[safe: index] { if let cloudTorrent = debridSource.cloudTorrents[safe: index] {
Task { Task {
await debridManager.deleteUserMagnet(cloudMagnet) await debridManager.deleteCloudTorrent(cloudTorrent)
} }
} }
} }

View file

@ -16,8 +16,13 @@ struct DebridCloudView: View {
var body: some View { var body: some View {
List { List {
CloudDownloadView(debridSource: debridSource, searchText: $searchText) if !debridSource.cloudDownloads.isEmpty {
CloudMagnetView(debridSource: debridSource, searchText: $searchText) CloudDownloadView(debridSource: debridSource, searchText: $searchText)
}
if !debridSource.cloudTorrents.isEmpty {
CloudTorrentView(debridSource: debridSource, searchText: $searchText)
}
} }
.listStyle(.plain) .listStyle(.plain)
.task { .task {

View file

@ -76,7 +76,7 @@ struct HistorySectionView: View {
var body: some View { var body: some View {
if compareGroup(historyGroup) > 0 { if compareGroup(historyGroup) > 0 {
Section(formatter.string(from: historyGroup[0].date ?? Date())) { Section(header: InlineHeader(formatter.string(from: historyGroup[0].date ?? Date()))) {
ForEach(historyGroup, id: \.self) { history in ForEach(historyGroup, id: \.self) { history in
ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in
HistoryButtonView(entry: entry) HistoryButtonView(entry: entry)

View file

@ -19,7 +19,7 @@ struct LibraryPickerView: View {
Text("Bookmarks").tag(NavigationViewModel.LibraryPickerSegment.bookmarks) Text("Bookmarks").tag(NavigationViewModel.LibraryPickerSegment.bookmarks)
Text("History").tag(NavigationViewModel.LibraryPickerSegment.history) Text("History").tag(NavigationViewModel.LibraryPickerSegment.history)
if !debridManager.enabledDebrids.isEmpty { if debridManager.hasEnabledDebrids {
Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud) Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud)
} }
} }

View file

@ -1,5 +1,5 @@
// //
// InstalledPluginButtonView.swift // InstalledSourceButtonView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 8/5/22. // Created by Brian Dashore on 8/5/22.

View file

@ -1,5 +1,5 @@
// //
// PluginCatalogButtonView.swift // SourceCatalogButtonView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 8/5/22. // Created by Brian Dashore on 8/5/22.

View file

@ -11,7 +11,7 @@ struct PluginInfoAboutView<P: Plugin>: View {
@ObservedObject var selectedPlugin: P @ObservedObject var selectedPlugin: P
var body: some View { var body: some View {
Section("Description") { Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if let pluginAbout = selectedPlugin.about { if let pluginAbout = selectedPlugin.about {
if pluginAbout.last == "\n" { if pluginAbout.last == "\n" {

View file

@ -16,7 +16,7 @@ struct PluginInfoMetaView<P: Plugin>: View {
) var pluginLists: FetchedResults<PluginList> ) var pluginLists: FetchedResults<PluginList>
var body: some View { var body: some View {
Section("Metadata") { Section(header: InlineHeader("Metadata")) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) { HStack(spacing: 5) {
@ -32,7 +32,8 @@ struct PluginInfoMetaView<P: Plugin>: View {
Group { Group {
Text("ID: \(selectedPlugin.id)") 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: \(pluginList.name)")
Text("List ID: \(pluginList.id.uuidString)") Text("List ID: \(pluginList.id.uuidString)")
} else { } else {

View file

@ -39,7 +39,7 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
searchText: searchText searchText: searchText
) )
if !filteredUpdatedPlugins.isEmpty { if !filteredUpdatedPlugins.isEmpty {
Section("Updates") { Section(header: InlineHeader("Updates")) {
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
PluginCatalogButtonView(availablePlugin: updatedPlugin, needsUpdate: true) PluginCatalogButtonView(availablePlugin: updatedPlugin, needsUpdate: true)
} }
@ -47,7 +47,7 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
} }
if !installedPlugins.isEmpty { if !installedPlugins.isEmpty {
Section("Installed") { Section(header: InlineHeader("Installed")) {
ForEach(installedPlugins, id: \.self) { installedPlugin in ForEach(installedPlugins, id: \.self) { installedPlugin in
InstalledPluginButtonView( InstalledPluginButtonView(
installedPlugin: installedPlugin, installedPlugin: installedPlugin,
@ -64,7 +64,7 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
searchText: searchText searchText: searchText
) )
if !filteredAvailablePlugins.isEmpty { if !filteredAvailablePlugins.isEmpty {
Section("Catalog") { Section(header: InlineHeader("Catalog")) {
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
PluginCatalogButtonView(availablePlugin: availablePlugin, needsUpdate: false) PluginCatalogButtonView(availablePlugin: availablePlugin, needsUpdate: false)
} }

View file

@ -13,7 +13,7 @@ struct PluginInfoView<P: Plugin>: View {
@Binding var selectedPlugin: P? @Binding var selectedPlugin: P?
var body: some View { var body: some View {
NavigationStack { NavView {
List { List {
if let selectedPlugin { if let selectedPlugin {
PluginInfoMetaView(selectedPlugin: selectedPlugin) PluginInfoMetaView(selectedPlugin: selectedPlugin)

View file

@ -1,5 +1,5 @@
// //
// PluginTagsView.swift // PluginTagView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 2/7/23. // Created by Brian Dashore on 2/7/23.

View file

@ -19,7 +19,7 @@ struct SourceSettingsApiView: View {
var body: some View { var body: some View {
Section( Section(
header: Text("API credentials"), header: InlineHeader("API credentials"),
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.") 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 { if let clientId = selectedSourceApi.clientId, clientId.dynamic {

View file

@ -13,7 +13,7 @@ struct SourceSettingsBaseUrlView: View {
@State private var tempSite: String = "" @State private var tempSite: String = ""
var body: some View { var body: some View {
Section( Section(
header: Text("Base URL"), header: InlineHeader("Base URL"),
footer: Text("Enter the base URL of your server.") footer: Text("Enter the base URL of your server.")
) { ) {
TextField("https://...", text: $tempSite, onEditingChanged: { isFocused in TextField("https://...", text: $tempSite, onEditingChanged: { isFocused in

View file

@ -11,7 +11,7 @@ struct SourceSettingsMethodView: View {
@ObservedObject var selectedSource: Source @ObservedObject var selectedSource: Source
var body: some View { var body: some View {
Section("Fetch method") { Section(header: InlineHeader("Fetch method")) {
Picker("", selection: $selectedSource.preferredParser) { Picker("", selection: $selectedSource.preferredParser) {
if selectedSource.jsonParser != nil { if selectedSource.jsonParser != nil {
Text("Website API").tag(SourcePreferredParser.siteApi.rawValue) Text("Website API").tag(SourcePreferredParser.siteApi.rawValue)

View file

@ -60,7 +60,7 @@ struct SearchFilterHeaderView: View {
// MARK: - Cache status picker // MARK: - Cache status picker
if !debridManager.enabledDebrids.isEmpty { if debridManager.hasEnabledDebrids {
IAFilterView() IAFilterView()
} }

View file

@ -28,26 +28,50 @@ struct SearchResultButtonView: View {
navModel.selectedTitle = result.title ?? "" navModel.selectedTitle = result.title ?? ""
navModel.resultFromCloud = false navModel.resultFromCloud = false
var historyEntry = HistoryEntryJson(
name: result.title,
source: result.source
)
switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) { switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) {
case .full: case .full:
if debridManager.selectDebridResult(magnet: result.magnet) { if debridManager.selectDebridResult(magnet: result.magnet) {
debridManager.currentDebridTask = Task { debridManager.currentDebridTask = Task {
await downloadToDebrid() 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 = ""
}
}
} }
} }
case .partial: case .partial:
if debridManager.selectDebridResult(magnet: result.magnet) { if debridManager.selectDebridResult(magnet: result.magnet) {
navModel.selectedHistoryInfo = historyEntry navModel.selectedHistoryInfo = HistoryEntryJson(
name: result.title,
source: result.source
)
navModel.currentChoiceSheet = .batch navModel.currentChoiceSheet = .batch
} }
case .none: case .none:
historyEntry.url = result.magnet.link PersistenceController.shared.createHistory(
PersistenceController.shared.createHistory(historyEntry, performSave: true) HistoryEntryJson(
name: result.title,
url: result.magnet.link,
source: result.source
),
performSave: true
)
pluginManager.runDefaultAction( pluginManager.runDefaultAction(
urlString: result.magnet.link, urlString: result.magnet.link,
@ -68,7 +92,7 @@ struct SearchResultButtonView: View {
} }
.disableInteraction(navModel.currentChoiceSheet != nil) .disableInteraction(navModel.currentChoiceSheet != nil)
.tint(.primary) .tint(.primary)
.contextMenu { .conditionalContextMenu(id: existingBookmark) {
ZStack { ZStack {
if let bookmark = existingBookmark { if let bookmark = existingBookmark {
Button { Button {
@ -99,46 +123,19 @@ 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) { .alert("Caching file", isPresented: $debridManager.showDeleteAlert) {
Button("Yes", role: .destructive) { Button("Yes", role: .destructive) {
Task { Task {
try? await debridManager.selectedDebridSource?.deleteUserMagnet(cloudMagnetId: nil) try? await debridManager.selectedDebridSource?.deleteTorrent(torrentId: nil)
} }
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} message: { } message: {
Text( Text(
"\(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") is currently caching this file. " + "\(String(describing: debridManager.selectedDebridSource?.id)) is currently caching this file. " +
"Would you like to delete it? \n\n" + "Would you like to delete it? \n\n" +
"Progress can be checked on the \(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") website." "Progress can be checked on the RealDebrid website."
) )
} }
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in
@ -171,35 +168,4 @@ 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 = ""
}
}
}
} }

View file

@ -1,5 +1,5 @@
// //
// SearchResultInfoView.swift // SearchResultRDView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/26/22. // Created by Brian Dashore on 7/26/22.

View file

@ -1,5 +1,5 @@
// //
// DefaultActionPickerView.swift // DefaultActionsPickerViews.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 8/11/22. // Created by Brian Dashore on 8/11/22.

View file

@ -25,11 +25,11 @@ struct KodiEditorView: View {
@State private var errorAlertText: String = "" @State private var errorAlertText: String = ""
var body: some View { var body: some View {
NavigationStack { NavView {
Form { Form {
Group { Group {
Section( Section(
header: Text("URL"), header: InlineHeader("URL"),
footer: Text("Must follow the format http(s)://<ip>:<port>") footer: Text("Must follow the format http(s)://<ip>:<port>")
) { ) {
TextField("Enter URL", text: $serverUrl) TextField("Enter URL", text: $serverUrl)
@ -37,14 +37,14 @@ struct KodiEditorView: View {
} }
Section( Section(
header: Text("Friendly name"), header: InlineHeader("Friendly name"),
footer: Text("Defaults to the URL if not provided") footer: Text("Defaults to the URL if not provided")
) { ) {
TextField("Friendly name", text: $friendlyName) TextField("Friendly name", text: $friendlyName)
} }
Section( Section(
header: Text("Credentials"), header: InlineHeader("Credentials"),
footer: Text("Only use for clients with authentication") footer: Text("Only use for clients with authentication")
) { ) {
TextField("Username", text: $username) TextField("Username", text: $username)

View file

@ -18,7 +18,7 @@ struct SettingsKodiView: View {
var body: some View { var body: some View {
List { List {
Section("Description") { Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Kodi is an external application that is used to manage a local media library and playback.") 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( Section(
header: Text("Servers"), header: InlineHeader("Servers"),
footer: Text("Edit a server by holding it and accessing the context menu") footer: Text("Edit a server by holding it and accessing the context menu")
) { ) {
if kodiServers.isEmpty { if kodiServers.isEmpty {

View file

@ -1,5 +1,5 @@
// //
// PluginListEditorView.swift // SourceListEditorView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/25/22. // Created by Brian Dashore on 7/25/22.
@ -25,7 +25,7 @@ struct PluginListEditorView: View {
@State private var loadedSelectedList = false @State private var loadedSelectedList = false
var body: some View { var body: some View {
NavigationStack { NavView {
Form { Form {
TextField("Enter URL", text: $pluginListUrl) TextField("Enter URL", text: $pluginListUrl)
.disableAutocorrection(true) .disableAutocorrection(true)

View file

@ -1,5 +1,5 @@
// //
// SettingsPluginListView.swift // SettingsSourceListView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/25/22. // Created by Brian Dashore on 7/25/22.
@ -69,8 +69,12 @@ struct SettingsPluginListView: View {
} }
} }
.sheet(isPresented: $presentEditSheet) { .sheet(isPresented: $presentEditSheet) {
PluginListEditorView() if #available(iOS 16, *) {
.presentationDetents([.medium]) PluginListEditorView()
.presentationDetents([.medium])
} else {
PluginListEditorView()
}
} }
.navigationTitle("Plugin Lists") .navigationTitle("Plugin Lists")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View file

@ -20,7 +20,7 @@ struct SettingsAppVersionView: View {
ProgressView() ProgressView()
} else if !releases.isEmpty { } else if !releases.isEmpty {
List { List {
Section("GitHub links") { Section(header: InlineHeader("GitHub links")) {
ForEach(releases, id: \.self) { release in ForEach(releases, id: \.self) { release in
ListRowLinkView(text: release.tagName, link: release.htmlUrl) ListRowLinkView(text: release.tagName, link: release.htmlUrl)
} }

View file

@ -1,5 +1,5 @@
// //
// SettingsDebridInfoView.swift // DebridInfoView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 3/5/23. // Created by Brian Dashore on 3/5/23.
@ -16,18 +16,16 @@ struct SettingsDebridInfoView: View {
var body: some View { var body: some View {
List { List {
Section("Description") { Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(debridSource.description ?? Text("\(debridSource.id) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.")
"\(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")!) Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!)
} }
} }
Section( Section(
header: Text("Login status"), header: InlineHeader("Login status"),
footer: Text("A WebView will show up to prompt you for credentials") footer: Text("A WebView will show up to prompt you for credentials")
) { ) {
Button { Button {
@ -48,17 +46,10 @@ struct SettingsDebridInfoView: View {
) )
.foregroundColor(debridSource.isLoggedIn ? .red : .blue) .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( Section(
header: Text("API key"), header: InlineHeader("API key"),
footer: Text("Add a permanent API key here. Only use this if web authentication does not work!") footer: Text("Add a permanent API key here. Only use this if web authentication does not work!")
) { ) {
HybridSecureField( HybridSecureField(

View file

@ -1,31 +0,0 @@
//
// 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
}
}
}

View file

@ -27,7 +27,7 @@ struct ContentView: View {
@State private var dismissAction: () -> Void = {} @State private var dismissAction: () -> Void = {}
var body: some View { var body: some View {
NavigationStack { NavView {
List { List {
SearchResultsView(searchText: $searchText) SearchResultsView(searchText: $searchText)
} }

View file

@ -30,7 +30,7 @@ struct LibraryView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
var body: some View { var body: some View {
NavigationStack { NavView {
ZStack { ZStack {
switch navModel.libraryPickerSelection { switch navModel.libraryPickerSelection {
case .bookmarks: case .bookmarks:
@ -96,11 +96,6 @@ struct LibraryView: View {
.esAutocapitalization(autocorrectSearch ? .sentences : .none) .esAutocapitalization(autocorrectSearch ? .sentences : .none)
.environment(\.editMode, $editMode) .environment(\.editMode, $editMode)
} }
.alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(debridManager.notImplementedMessage)
}
.onChange(of: navModel.libraryPickerSelection) { _ in .onChange(of: navModel.libraryPickerSelection) { _ in
editMode = .inactive editMode = .inactive
} }

View file

@ -12,7 +12,7 @@ struct LoginWebView: View {
var url: URL var url: URL
var body: some View { var body: some View {
NavigationStack { NavView {
WebView(url: url) WebView(url: url)
.navigationTitle("Sign in") .navigationTitle("Sign in")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View file

@ -54,8 +54,12 @@ struct MainView: View {
case .batch: case .batch:
BatchChoiceView() BatchChoiceView()
case .activity: case .activity:
ShareSheet(activityItems: navModel.activityItems) if #available(iOS 16, *) {
.presentationDetents([.medium, .large]) ShareSheet(activityItems: navModel.activityItems)
.presentationDetents([.medium, .large])
} else {
ShareSheet(activityItems: navModel.activityItems)
}
} }
} }
.onAppear { .onAppear {

View file

@ -30,7 +30,7 @@ struct PluginsView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
var body: some View { var body: some View {
NavigationStack { NavView {
ZStack { ZStack {
if checkedForPlugins { if checkedForPlugins {
switch navModel.pluginPickerSelection { switch navModel.pluginPickerSelection {

View file

@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
extension View { public extension View {
// A dismissAction must be added in the parent view struct due to lifecycle issues // A dismissAction must be added in the parent view struct due to lifecycle issues
func expandedSearchable(text: Binding<String>, func expandedSearchable(text: Binding<String>,
isSearching: Binding<Bool>? = nil, isSearching: Binding<Bool>? = nil,
@ -212,7 +212,10 @@ struct SearchBar<ScopeContent: View>: UIViewControllerRepresentable {
private func setup() { private func setup() {
parent?.navigationItem.searchController = searchController parent?.navigationItem.searchController = searchController
parent?.navigationItem.hidesSearchBarWhenScrolling = false parent?.navigationItem.hidesSearchBarWhenScrolling = false
parent?.navigationItem.preferredSearchBarPlacement = .stacked
if #available(iOS 16, *) {
parent?.navigationItem.preferredSearchBarPlacement = .stacked
}
// Makes search bar appear when application starts // Makes search bar appear when application starts
parent?.navigationController?.navigationBar.sizeToFit() parent?.navigationController?.navigationBar.sizeToFit()

View file

@ -6,6 +6,7 @@
// //
import BetterSafariView import BetterSafariView
import Introspect
import SwiftUI import SwiftUI
import WebKit import WebKit
@ -42,15 +43,24 @@ struct SettingsView: View {
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
var body: some View { var body: some View {
NavigationStack { NavView {
Form { Form {
Section("Debrid services") { Section(header: InlineHeader("Debrid services")) {
ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in
SettingsDebridLinkView(debridSource: debridSource) NavigationLink {
SettingsDebridInfoView(debridSource: debridSource)
} label: {
HStack {
Text(debridSource.id)
Spacer()
Text(debridSource.isLoggedIn ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
}
}
} }
} }
Section("Playback services") { Section(header: InlineHeader("Playback services")) {
NavigationLink { NavigationLink {
SettingsKodiView(kodiServers: kodiServers) SettingsKodiView(kodiServers: kodiServers)
} label: { } label: {
@ -64,7 +74,7 @@ struct SettingsView: View {
} }
Section( Section(
header: Text("Behavior"), header: InlineHeader("Behavior"),
footer: VStack(alignment: .leading, spacing: 8) { footer: VStack(alignment: .leading, spacing: 8) {
Text("Temporarily disable ephemeral auth if you cannot log into a service") Text("Temporarily disable ephemeral auth if you cannot log into a service")
Text("Only disable search timeout if results are slow to fetch") Text("Only disable search timeout if results are slow to fetch")
@ -111,14 +121,14 @@ struct SettingsView: View {
} }
} }
Section("Plugin management") { Section(header: InlineHeader("Plugin management")) {
NavigationLink("Plugin lists") { NavigationLink("Plugin lists") {
SettingsPluginListView() SettingsPluginListView()
} }
} }
Section("Default actions") { Section(header: InlineHeader("Default actions")) {
if !debridManager.enabledDebrids.isEmpty { if debridManager.hasEnabledDebrids {
NavigationLink { NavigationLink {
DefaultActionPickerView( DefaultActionPickerView(
actionRequirement: .debrid, actionRequirement: .debrid,
@ -175,13 +185,13 @@ struct SettingsView: View {
} }
} }
Section("Backups") { Section(header: InlineHeader("Backups")) {
NavigationLink("Backups") { NavigationLink("Backups") {
BackupsView() BackupsView()
} }
} }
Section("Updates") { Section(header: InlineHeader("Updates")) {
Toggle(isOn: $autoUpdateNotifs) { Toggle(isOn: $autoUpdateNotifs) {
Text("Show update alerts") Text("Show update alerts")
} }
@ -191,7 +201,7 @@ struct SettingsView: View {
} }
} }
Section("Information") { Section(header: InlineHeader("Information")) {
ListRowLinkView(text: "Donate", link: "https://ko-fi.com/kingbri") ListRowLinkView(text: "Donate", link: "https://ko-fi.com/kingbri")
ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues") ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues")
@ -200,7 +210,7 @@ struct SettingsView: View {
} }
} }
Section("Debug") { Section(header: InlineHeader("Debug")) {
NavigationLink("Logs") { NavigationLink("Logs") {
SettingsLogView() SettingsLogView()
} }

View file

@ -1,5 +1,5 @@
// //
// ActionChoiceView.swift // MagnetChoiceView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/20/22. // Created by Brian Dashore on 7/20/22.
@ -29,9 +29,9 @@ struct ActionChoiceView: View {
@State private var showMagnetCopyAlert = false @State private var showMagnetCopyAlert = false
var body: some View { var body: some View {
NavigationStack { NavView {
Form { Form {
Section("Now Playing") { Section(header: InlineHeader("Now Playing")) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(navModel.selectedTitle) Text(navModel.selectedTitle)
.font(.callout) .font(.callout)
@ -46,7 +46,7 @@ struct ActionChoiceView: View {
} }
if !debridManager.downloadUrl.isEmpty { if !debridManager.downloadUrl.isEmpty {
Section("Debrid options") { Section(header: InlineHeader("Debrid options")) {
ForEach(actions, id: \.id) { action in ForEach(actions, id: \.id) { action in
if action.requires.contains(ActionRequirement.debrid.rawValue) { if action.requires.contains(ActionRequirement.debrid.rawValue) {
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") { ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
@ -91,7 +91,7 @@ struct ActionChoiceView: View {
} }
if !navModel.resultFromCloud { if !navModel.resultFromCloud {
Section("Magnet options") { Section(header: InlineHeader("Magnet options")) {
ForEach(actions, id: \.id) { action in ForEach(actions, id: \.id) { action in
if action.requires.contains(ActionRequirement.magnet.rawValue) { if action.requires.contains(ActionRequirement.magnet.rawValue) {
ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") { ListRowButtonView(action.name, systemImage: "arrow.up.forward.app.fill") {
@ -123,8 +123,13 @@ struct ActionChoiceView: View {
} }
.tint(.primary) .tint(.primary)
.sheet(isPresented: $navModel.showLocalActivitySheet) { .sheet(isPresented: $navModel.showLocalActivitySheet) {
ShareSheet(activityItems: navModel.activityItems) // TODO: Fix share sheet
.presentationDetents([.medium, .large]) if #available(iOS 16, *) {
ShareSheet(activityItems: navModel.activityItems)
.presentationDetents([.medium, .large])
} else {
ShareSheet(activityItems: navModel.activityItems)
}
} }
.alert("Action successful", isPresented: $pluginManager.showActionSuccessAlert) { .alert("Action successful", isPresented: $pluginManager.showActionSuccessAlert) {
Button("OK", role: .cancel) {} Button("OK", role: .cancel) {}
@ -138,8 +143,6 @@ struct ActionChoiceView: View {
} }
.onDisappear { .onDisappear {
debridManager.downloadUrl = "" debridManager.downloadUrl = ""
debridManager.clearSelectedDebridItems()
debridManager.requiresUnrestrict = false
navModel.selectedTitle = "" navModel.selectedTitle = ""
navModel.selectedBatchTitle = "" navModel.selectedBatchTitle = ""
navModel.resultFromCloud = false navModel.resultFromCloud = false
@ -150,11 +153,8 @@ struct ActionChoiceView: View {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { Button("Done") {
debridManager.downloadUrl = "" debridManager.downloadUrl = ""
debridManager.clearSelectedDebridItems()
debridManager.requiresUnrestrict = false
navModel.selectedTitle = "" navModel.selectedTitle = ""
navModel.selectedBatchTitle = "" navModel.selectedBatchTitle = ""
navModel.resultFromCloud = false
dismiss() dismiss()
} }

View file

@ -19,8 +19,9 @@ struct BatchChoiceView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
// TODO: Make this generic for an IA protocol
var body: some View { var body: some View {
NavigationStack { NavView {
List { List {
ForEach(debridManager.selectedDebridItem?.files ?? [], id: \.self) { file in ForEach(debridManager.selectedDebridItem?.files ?? [], id: \.self) { file in
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty { if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
@ -38,10 +39,6 @@ struct BatchChoiceView: View {
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.autocorrectionDisabled(!autocorrectSearch) .autocorrectionDisabled(!autocorrectSearch)
.textInputAutocapitalization(autocorrectSearch ? .sentences : .never) .textInputAutocapitalization(autocorrectSearch ? .sentences : .never)
.onDisappear {
debridManager.clearSelectedDebridItems()
debridManager.requiresUnrestrict = false
}
.navigationTitle("Select a file") .navigationTitle("Select a file")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@ -53,7 +50,6 @@ struct BatchChoiceView: View {
try? await Task.sleep(seconds: 1) try? await Task.sleep(seconds: 1)
debridManager.clearSelectedDebridItems() debridManager.clearSelectedDebridItems()
debridManager.requiresUnrestrict = false
} }
} }
} }
@ -64,11 +60,7 @@ struct BatchChoiceView: View {
// Common function to communicate betwen VMs and queue/display a download // Common function to communicate betwen VMs and queue/display a download
func queueCommonDownload(fileName: String) { func queueCommonDownload(fileName: String) {
debridManager.currentDebridTask = Task { debridManager.currentDebridTask = Task {
if debridManager.requiresUnrestrict { await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet)
await debridManager.unrestrictDownload()
} else {
await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet)
}
if !debridManager.downloadUrl.isEmpty { if !debridManager.downloadUrl.isEmpty {
try? await Task.sleep(seconds: 1) try? await Task.sleep(seconds: 1)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 KiB

View file

@ -1,5 +0,0 @@
Enter the following code on [TorBox's subscription page](https://torbox.app/subscription)
bb2d4f54-61bf-4d64-af08-8db0a900485a
Thanks for the referral!

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