Compare commits

..

68 commits

Author SHA1 Message Date
kingbri
f4184cf1b9 Search: Remove ConditionalContextMenu
Was required for iOS 15 to properly update its state. This is no longer
a requirement.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:26 -05:00
kingbri
cfc4a74afe Tree: Remove InlineHeader
Was a workaround for iOS 15. No longer required.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:26 -05:00
kingbri
7bb4ed5f7c Tree: Switch to NavigationStack
Since minVersion is iOS 16, remove the compatability view.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:26 -05:00
kingbri
f40f71bca3 Tree: Remove iOS 16 conditionals
iOS 16 is now the minimum version for the project.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:26 -05:00
kingbri
68a7c60c2d Dependencies: Update SwiftUI-Introspect
Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:26 -05:00
kingbri
8b00d11e44 Ferrite: Minimum iOS 16
Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:26 -05:00
kingbri
9d7bc9b314 Debrid: Update AllDebrid description
Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:34:12 -05:00
kingbri
25bff02875 Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:08:02 -05:00
kingbri
20dd00fa85 Debrid: Fix fetching for AllDebrid
User now has to manually unrestrict batches similar to how RealDebrid
and OffCloud work.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 23:07:24 -05:00
kingbri
f9d2f38329 Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 22:11:03 -05:00
kingbri
a7e20f30e6 Settings: Properly report login status of Debrid services
Since protocols can't be observed in SwiftUI, use a roundabout way
to check if a user is logged in. Maybe this should be changed in the future,
but it works for now.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 21:33:27 -05:00
kingbri
ecf92239d2 Debrid: Add new IA method for AllDebrid and fix cache fetch
The new AllDebrid IA method follows the same behavior as RealDebrid.

Only add user magnets into the IA if they're actually cached and
not caching into the service.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-27 18:20:04 -05:00
kingbri
dd54ec027b Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-26 23:43:06 -05:00
kingbri
84357ea2c5 Debrid: Update IA fetching for RealDebrid
To avoid inflating the IA value cache, restore the TTL logic and only
append IAs that are part of the sent magnets.

In addition, if an IA result isn't found for a model download, re-fetch
the IA cache.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-26 23:33:50 -05:00
kingbri
4fb5f77718 Debrid: Add context menu option to download to debrid
This is a manual button so users can download an item to their preferred
debrid service. If the item is cached in the debrid, it will download instantly
and display the result to the user. Otherwise, the caching alert is shown.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-26 22:26:53 -05:00
kingbri
e5a872e09f Debrid: Update for RealDebrid's API changes
The instantAvailability endpoint is now removed, so make IA return
a user's magnets instead with reliance on manual download as the
second solution.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-11-26 17:51:52 -05:00
kingbri
1d6ac13e84 Ferrite: Bump build number
Signed-off-by: kingbri <bdashore3@proton.me>
2024-10-04 12:33:47 -04:00
kingbri
896efed663 Project: Bump version
v0.7.2

Signed-off-by: kingbri <bdashore3@proton.me>
2024-07-13 13:47:51 -04:00
kingbri
a3463948ea Project: Update build number
Signed-off-by: kingbri <bdashore3@proton.me>
2024-07-13 13:38:14 -04:00
kingbri
215cd0feec Debrid: Fixes for RealDebrid cloud magnets
Cloud magnets are now under a rate limit and links are no longer
present per entry. Remove rich display for that section only and
present a batch sheet on click.

Also add more spots for cleaning up variables.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-07-13 13:34:05 -04:00
kingbri
9213b8627b Info: Fix URL scheme
Fixes an XCode warning.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-07-13 13:33:46 -04:00
Brian Dashore
6b40bb3ea2
Update README.md
Signed-off-by: kingbri <bdashore3@gmail.com>
2024-06-19 21:52:56 +00:00
kingbri
dbf12c0a79 Ferrite: Bump version
v0.7.1

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-17 11:58:29 -05:00
kingbri
70b628b608 Debrid: Use universal cached IDs
Different services can send different statuses for if a file is
cached or not. Therefore, make this scoped to the debrid service
rather than expecting everything to state "downloaded".

Also it feels pretty blank if the disclosure groups are gone when
a cloud array is empty, so remove those checks.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 21:23:22 -05:00
kingbri
78f2aff25b Debrid: Fix OffCloud single files and cloud population
Populate cloud lists when the app is launched to begin maintainence
of a synced list. In addition, fix the errors when OffCloud tried
fetching links for a single file. The explore endpoint only works
when the file is a batch which is unknown until it's actually called.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 21:12:26 -05:00
kingbri
489da8e82e Debrid: Clarify struct properties
Doesn't make sense to use more descriptive IDs when the struct
describes what the model is already.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:47:07 -05:00
kingbri
078e48d316 Treewide: Cleanup and rename
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:37:00 -05:00
kingbri
646c22c9be Debrid: Fix UI updates with auth
If a debrid is authorized, a Published variable needs to be notified
since SwiftUI can't read computed properties on the fly (they are
getters). Therefore, it's better to maintain a single source of truth
of which services are logged in.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
d512d8b88d Update README
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
d0728e1a9b Debrid: Make TorBox a rich service and fix cloud downloads
TorBox can now show if there's a batch before loading a file.

Cloud downloads should check the server in case there's a different
method to fetch a download link.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
89367b72da Update README
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
c5a08cc725 Debrid: Add description field and cleanup
Allow for overriding of the default description in the settings UI.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
0d39fd481a Update README and add media
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
5223c60acd Tree: Fix various bugs
- AllDebrid: Don't throw an empty error if cloud downloads/torrents
is empty
- Fix history not saving with the proper URLs
- Fix the HTMLParser looking at the incorrect term for seedLeech

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
80e966512a Debrid: Add OffCloud support
OffCloud is a debrid provider that allows for caching and playing
media. Does not have rich debrid support.

Also add a handler if functionality isn't implemented in the service.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
8f7fe94d21 Debrid: Fix cleanup of unrestrict and task
The task can be set to nil after completion, and the unrestrict
flag should also be set to nil when the batch sheet is dismissed.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
3ef041f889 Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
e49e37af36 Tree: Remove OffCloud references
Was an experiment for later commits.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
d6d731102c Debrid: Add TorBox support
TorBox is a service that handles magnet links under both a free
and paid plan. Integrate support into Ferrite. Will add rich services
once the instantAvailability endpoint returns a file list.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
4beb953596 Debrid: Add split for download and unrestrict
Some debrid services aren't "rich", which means that they don't
broadcast whether an instantly available torrent is a batch or a
single file. This results in all torrents either having the green
badge or red badge based on what hash is given.

However, batches need to intercept the download itself which requires
the download function to be split into download and unrestrict. In
between, there's room for the batch sheet to act.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
e1eca593f3 Debrid: Add alert if there's no web login option
Indicates to the user that an API must be used to log into the debrid
service.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
9b4f31daac Debrid: Reorder protocol
Helps when auto-filling stubs for new classes.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
24e39f9fba Revert "Extension: Remove Set warning"
This reverts commit cf090cfaa61acef5ff43f9f261764b0a125411f8.
2024-06-16 15:00:35 -05:00
kingbri
904b5a74b5 Ferrite: Update project settings
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
ecdd0199f6 Tree: Cleanup access levels
Public should not be used in an app since it declares public to
additional modules. However, an app is one module. Some structs/
classes need to be left public to conform to CoreData's generation.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
3b771e5deb Extension: Remove Set warning
This will be removed in the future anyway.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
d8107cb5b6 Sources: Add queryFirstLetter param
Stopgap for index-based sources. For example, the keyword "John"
will be converted to "j" for sources that use "/j/John".

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
42e202b207 Plugins: Add request options to sources
Adds HTTP method, headers, and a body string. Also use a common
function to substitute params rather to allow for maintanence of a
common dictionary.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
afceea7bfb Actions: Update to latest
Bump actions and macos build versions.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
4ae1966934 Debrid: Fix UI updates for IA
Hook to the published variable to push updates.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
796cc65016 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-16 15:00:35 -05:00
kingbri
90f44348b8 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-16 15:00:35 -05:00
kingbri
6192ef1ede Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
973fbb4099 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-16 15:00:35 -05:00
kingbri
243a16e3c4 Debrid: Unify cloud views
Cloud torrents and downloads are unified with the new protocol.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
44a90b77eb 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-16 15:00:35 -05:00
kingbri
59ac719d9a Debrid: Migrate preferred service setter
PreferredService is now the debrid ID.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
02636e0bda Debrid: Remove separated download functions
No longer needed due to the common type.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
40b323bd56 Debrid: Remove redundant logout functions
Logout is now handled in the debrid class itself.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
91f124130c 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-16 15:00:35 -05:00
kingbri
ec8455c08d Debrid: Swap to common DebridError
Removes the redundant error types.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
0c3648120d Debrid: Refactor IA and download functions
Use the common protocol to handle these.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
9650e6deec Debrid: Remove ID storage
Storing an ID reference is redundant. Store a class reference
instead.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
07731e7b00 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-16 15:00:35 -05:00
kingbri
b80f8900b7 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-16 15:00:35 -05:00
kingbri
cf0c5a30f7 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-16 15:00:35 -05:00
kingbri
96a6722e65 Debrid: Fix RealDebrid download handling
The torrent ID is no longer stored in the DebridManager.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-16 15:00:35 -05:00
kingbri
0caf8a8120 Debrid: Add common functions for existing magnets/downloads
This fixes cloud magnet fetching and also doesn't duplicate magnets
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-16 15:00:06 -05:00
101 changed files with 2009 additions and 948 deletions

View file

@ -12,6 +12,9 @@
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 */; };
@ -38,7 +41,6 @@
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 */; };
@ -66,7 +68,6 @@
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 */; };
@ -79,6 +80,7 @@
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 */; };
@ -94,11 +96,14 @@
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 */; };
@ -106,7 +111,6 @@
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 */; };
@ -130,11 +134,9 @@
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 /* CloudTorrentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudTorrentView.swift */; }; 0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.swift */; };
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; }; 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 */; };
@ -154,6 +156,8 @@
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 */; };
@ -165,6 +169,9 @@
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>"; };
@ -216,7 +223,6 @@
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>"; };
@ -242,11 +248,14 @@
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>"; };
@ -254,7 +263,6 @@
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>"; };
@ -278,11 +286,9 @@
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 /* CloudTorrentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTorrentView.swift; sourceTree = "<group>"; }; 0CB725332C123E760047FC0B /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.swift; sourceTree = "<group>"; };
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = "<group>"; }; 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>"; };
@ -302,6 +308,8 @@
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>"; };
@ -313,13 +321,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;
}; };
@ -383,6 +391,8 @@
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>";
@ -404,6 +414,8 @@
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>";
@ -412,7 +424,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */, 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
0CB725332C123E760047FC0B /* CloudTorrentView.swift */, 0CB725332C123E760047FC0B /* CloudMagnetView.swift */,
); );
path = Cloud; path = Cloud;
sourceTree = "<group>"; sourceTree = "<group>";
@ -457,6 +469,7 @@
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>";
@ -464,8 +477,6 @@
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 */,
@ -536,6 +547,7 @@
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>";
@ -565,9 +577,7 @@
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 */,
@ -655,6 +665,8 @@
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>";
@ -732,8 +744,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 */;
@ -747,7 +759,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1400; LastSwiftUpdateCheck = 1400;
LastUpgradeCheck = 1400; LastUpgradeCheck = 1600;
TargetAttributes = { TargetAttributes = {
0CAF1C67286F5C0E00296F86 = { 0CAF1C67286F5C0E00296F86 = {
CreatedOnToolsVersion = 14.0; CreatedOnToolsVersion = 14.0;
@ -770,8 +782,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 = "";
@ -823,6 +835,7 @@
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 */,
@ -830,13 +843,11 @@
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 */,
@ -863,7 +874,6 @@
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 */,
@ -874,7 +884,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 /* CloudTorrentView.swift in Sources */, 0CB725342C123E760047FC0B /* CloudMagnetView.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 */,
@ -890,7 +900,6 @@
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 */,
@ -921,17 +930,22 @@
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 */,
@ -948,11 +962,13 @@
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 */,
@ -972,6 +988,7 @@
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";
@ -1004,6 +1021,7 @@
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;
@ -1025,6 +1043,7 @@
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;
}; };
@ -1032,6 +1051,7 @@
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";
@ -1064,6 +1084,7 @@
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;
@ -1078,6 +1099,7 @@
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;
@ -1088,10 +1110,11 @@
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 = 17; CURRENT_PROJECT_VERSION = 21;
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;
@ -1102,12 +1125,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 = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.0; MARKETING_VERSION = 0.7.3;
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;
@ -1123,10 +1146,11 @@
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 = 17; CURRENT_PROJECT_VERSION = 21;
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;
@ -1137,12 +1161,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 = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.7.0; MARKETING_VERSION = 0.7.3;
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;
@ -1176,14 +1200,6 @@
/* 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";
@ -1224,6 +1240,14 @@
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";
@ -1243,11 +1267,6 @@
/* 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" */;
@ -1273,6 +1292,11 @@
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 = "1400" LastUpgradeVersion = "1600"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View file

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

View file

@ -7,8 +7,8 @@
import Foundation import Foundation
public class Github { class Github {
public func fetchLatestRelease() async throws -> Release? { 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 @@ public class Github {
return rawResponse return rawResponse
} }
public func fetchReleases() async throws -> [Release]? { func fetchReleases() async throws -> [Release]? {
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")! let 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
public class Kodi { class Kodi {
let encoder = JSONEncoder() private let encoder = JSONEncoder()
// Used to add server to CoreData. Not part of API // Used to add server to CoreData. Not part of API
public func addServer(urlString: String, 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 @@ public class Kodi {
try backgroundContext.save() try backgroundContext.save()
} }
public func ping(server: KodiServer) async throws { func ping(server: KodiServer) async throws {
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!) 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 @@ public class Kodi {
} }
} }
public func sendVideoUrl(urlString: String, server: KodiServer) async throws { func sendVideoUrl(urlString: String, server: KodiServer) async throws {
if URL(string: urlString) == nil { if URL(string: urlString) == nil {
throw KodiError.InvalidPlaybackUrl throw KodiError.InvalidPlaybackUrl
} }

View file

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

View file

@ -7,16 +7,19 @@
import Foundation import Foundation
public class Premiumize: OAuthDebridSource, ObservableObject { class Premiumize: OAuthDebridSource, ObservableObject {
public let id = "Premiumize" let id = "Premiumize"
public let abbreviation = "PM" let abbreviation = "PM"
public let website = "https://premiumize.me" let website = "https://premiumize.me"
@Published public var authProcessing: Bool = false let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
public var isLoggedIn: Bool { "You must pay to access the service."
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil getToken() != nil
} }
public var manualToken: String? { var manualToken: String? {
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
return getToken() return getToken()
} else { } else {
@ -24,20 +27,27 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
} }
} }
@Published public var IAValues: [DebridIA] = [] @Published var IAValues: [DebridIA] = []
@Published public var cloudDownloads: [DebridCloudDownload] = [] @Published var cloudDownloads: [DebridCloudDownload] = []
@Published public var cloudTorrents: [DebridCloudTorrent] = [] @Published var cloudMagnets: [DebridCloudMagnet] = []
public var cloudTTL: Double = 0.0 var cloudTTL: Double = 0.0
let baseAuthUrl = "https://www.premiumize.me/authorize" private let baseAuthUrl = "https://www.premiumize.me/authorize"
let baseApiUrl = "https://www.premiumize.me/api" private let baseApiUrl = "https://www.premiumize.me/api"
let clientId = "791565696" private let clientId = "791565696"
let jsonDecoder = JSONDecoder() private let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
}
}
// MARK: - Auth // MARK: - Auth
public func getAuthUrl() throws -> URL { 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),
@ -52,7 +62,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
} }
} }
public func handleAuthCallback(url: URL) throws { func handleAuthCallback(url: URL) throws {
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let callbackFragment = callbackComponents?.fragment else { guard let callbackFragment = callbackComponents?.fragment else {
@ -70,17 +80,17 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
} }
// Adds a manual API key instead of web auth // Adds a manual API key instead of web auth
public func setApiKey(_ key: String) { func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken") FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey") UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
} }
public func getToken() -> String? { 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
public func logout() { func logout() {
FerriteKeychain.shared.delete("Premiumize.AccessToken") FerriteKeychain.shared.delete("Premiumize.AccessToken")
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey") UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
} }
@ -132,7 +142,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
// MARK: - Instant availability // MARK: - Instant availability
public func instantAvailability(magnets: [Magnet]) async throws { 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
@ -168,7 +178,7 @@ public 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
public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] { func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
for magnet in magnetChunk { for magnet in magnetChunk {
group.addTask { group.addTask {
@ -187,7 +197,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
} }
// Grabs DDL links // Grabs DDL links
func fetchDDL(magnet: Magnet) async throws -> DebridIA { private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
if magnet.hash == nil { if magnet.hash == nil {
throw DebridError.EmptyData throw DebridError.EmptyData
} }
@ -208,7 +218,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
if !content.isEmpty { if !content.isEmpty {
let files = content.map { file in let files = content.map { file in
DebridIAFile( DebridIAFile(
fileId: 0, id: 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
) )
@ -216,7 +226,6 @@ public 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
) )
@ -227,7 +236,7 @@ public 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
public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] { func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in 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 {
@ -247,7 +256,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
} }
// Parent function for initial checking of the cache // Parent function for initial checking of the cache
func checkCache(magnets: [Magnet]) async throws -> [Magnet] { private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")! 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 {
@ -276,21 +285,28 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
// MARK: - Downloading // MARK: - Downloading
// Wrapper function to fetch a DDL 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 {
// 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, let streamUrlString = iaFile.streamUrlString { if let iaFile {
return streamUrlString return (iaFile, nil)
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0], let streamUrlString = firstFile.streamUrlString { } else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
return streamUrlString return (firstFile, nil)
} 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 createTransfer(magnet: Magnet) async throws { func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
guard let streamUrlString = restrictedFile.streamUrlString else {
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
}
return streamUrlString
}
private func createTransfer(magnet: Magnet) async throws {
guard let magnetLink = magnet.link else { guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid") throw DebridError.FailedRequest(description: "The magnet link is invalid")
} }
@ -309,7 +325,7 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
// MARK: - Cloud methods // MARK: - Cloud methods
public func getUserDownloads() async throws { 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)
@ -321,11 +337,11 @@ public 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(downloadId: file.id, source: self.id, fileName: file.name, link: file.id) DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
} }
} }
func itemDetails(itemID: String) async throws -> ItemDetailsResponse { private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")! 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 {
@ -340,12 +356,12 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
return rawResponse return rawResponse
} }
public func checkUserDownloads(link: String) async throws -> String? { 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
} }
public func deleteDownload(downloadId: String) async throws { func deleteUserDownload(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")
@ -358,8 +374,8 @@ public class Premiumize: OAuthDebridSource, ObservableObject {
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }
// No user torrents for Premiumize // No user magnets for Premiumize
public func getUserTorrents() async throws {} func getUserMagnets() {}
public func deleteTorrent(torrentId: String?) async throws {} func deleteUserMagnet(cloudMagnetId: String?) {}
} }

View file

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

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

View file

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

View file

@ -9,7 +9,7 @@
import CoreData import CoreData
import Foundation import Foundation
public extension Bookmark { 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,6 +16,7 @@ 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,6 +17,7 @@ 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

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

View file

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

View file

@ -17,6 +17,7 @@ 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="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Action" representedClassName="Action" syncable="YES"> <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,6 +106,7 @@
<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"/>
@ -118,6 +119,7 @@
<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"/>
@ -134,6 +136,14 @@
<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"/>
@ -141,6 +151,7 @@
<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
public extension Color { 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 @@
// //
// Array.swift // Set.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 11/26/22. // Created by Brian Dashore on 11/26/22.

View file

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

View file

@ -5,13 +5,12 @@
// 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
public func modifyViewProp(_ body: (inout Self) -> Void) -> Self { func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
var result = self var result = self
body(&result) body(&result)
@ -20,16 +19,6 @@ 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
public struct ActionJson: Codable, Hashable, PluginJson { struct ActionJson: Codable, Hashable, PluginJson {
public let name: String let name: String
public let version: Int16 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]?
public let author: String? let author: String?
public let listId: UUID? let listId: UUID?
public let listName: String? let listName: String?
public let tags: [PluginTagJson]? let tags: [PluginTagJson]?
public init(name: String, 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 @@ public struct ActionJson: Codable, Hashable, PluginJson {
self.tags = tags self.tags = tags
} }
public init(from decoder: Decoder) throws { 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 @@ public struct ActionJson: Codable, Hashable, PluginJson {
} }
} }
public struct DeeplinkActionJson: Codable, Hashable { struct DeeplinkActionJson: Codable, Hashable {
let os: [String] let os: [String]
let scheme: String let scheme: String
@ -77,7 +77,7 @@ public struct DeeplinkActionJson: Codable, Hashable {
self.scheme = scheme self.scheme = scheme
} }
public init(from decoder: Decoder) throws { 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 @@ public struct DeeplinkActionJson: Codable, Hashable {
} }
} }
public extension ActionJson { 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 @@ public extension ActionJson {
} }
} }
public enum ActionRequirement: String, Codable { enum ActionRequirement: String, Codable {
case magnet case magnet
case debrid case debrid
} }

View file

@ -7,7 +7,7 @@
import Foundation import Foundation
public extension AllDebrid { 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 @@ public extension AllDebrid {
// MARK: - AddMagnetData // MARK: - AddMagnetData
internal struct AddMagnetData: Codable { 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 @@ public extension AllDebrid {
struct MagnetStatusResponse: Codable { struct MagnetStatusResponse: Codable {
let magnets: [MagnetStatusData] let magnets: [MagnetStatusData]
public init(from decoder: Decoder) throws { 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 @@ public extension AllDebrid {
// MARK: - MagnetStatusLink // MARK: - MagnetStatusLink
// Abridged for required parameters // Abridged for required parameters
internal struct MagnetStatusLink: Codable { 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 @@ public extension AllDebrid {
// MARK: - IAMagnetResponse // MARK: - IAMagnetResponse
internal struct InstantAvailabilityMagnet: Codable { 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 @@ public extension AllDebrid {
// MARK: - IAFileResponse // MARK: - IAFileResponse
internal struct InstantAvailabilityFile: Codable { 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
public struct Backup: Codable { 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)
public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable { 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 @@ public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
// MARK: - Enum for debrid differentiation. 0 is nil // MARK: - Enum for debrid differentiation. 0 is nil
public enum DebridType: Int, Codable, Hashable, CaseIterable { enum DebridType: Int, Codable, Hashable, CaseIterable {
case realDebrid = 1 case realDebrid = 1
case allDebrid = 2 case allDebrid = 2
case premiumize = 3 case premiumize = 3
@ -47,7 +47,7 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable {
} }
// Wrapper struct for magnet links to contain both the link and hash for easy access // Wrapper struct for magnet links to contain both the link and hash for easy access
public struct Magnet: Codable, Hashable, Sendable { struct Magnet: Codable, Hashable, Sendable {
var hash: String? var hash: String?
var link: String? var link: String?
@ -55,12 +55,14 @@ public 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 parsedLink = parseLink(link), hash == nil { } else if let link, hash == nil {
self.link = parsedLink let (link, hash) = parseLink(link)
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) self.link = parseLink(link).link
} }
} }
@ -108,19 +110,35 @@ public struct Magnet: Codable, Hashable, Sendable {
} }
} }
func parseLink(_ link: String?) -> String? { func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) {
if let decodedLink = link?.removingPercentEncoding { let separator = "magnet:?xt=urn:btih:"
let separator = "magnet:?xt=urn:btih:"
if decodedLink.starts(with: separator) { // Remove percent encoding from the link and ensure it's a magnet
return decodedLink guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else {
} else if decodedLink.contains(separator) { return (nil, nil)
let splitLink = decodedLink.components(separatedBy: separator) }
return splitLink.last.map { separator + $0 } ?? nil
} else { // Isolate the magnet link if it's bundled with another protocol
return nil let isolatedLink: String?
} if decodedLink.starts(with: separator) {
isolatedLink = decodedLink
} else { } else {
return nil let splitLink = decodedLink.components(separatedBy: separator)
isolatedLink = splitLink.last.map { separator + $0 }
}
guard let isolatedLink else {
return (nil, nil)
}
// If the hash can be extracted, decrypt it if necessary and return the revised link + hash
if let originalHash = extractHash(link: isolatedLink),
let parsedHash = parseHash(originalHash)
{
let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash)
return (replacedLink, parsedHash)
} else {
return (decodedLink, nil)
} }
} }
} }

View file

@ -7,51 +7,49 @@
import Foundation import Foundation
public struct DebridIA: Hashable, Sendable { 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]
} }
public struct DebridIAFile: Hashable, Sendable { struct DebridIAFile: Hashable, Sendable {
let fileId: Int let id: Int
let name: String let name: String
let streamUrlString: String? let streamUrlString: String?
let batchIds: [Int] let batchIds: [Int]
init(fileId: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) { init(id: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) {
self.fileId = fileId self.id = id
self.name = name self.name = name
self.streamUrlString = streamUrlString self.streamUrlString = streamUrlString
self.batchIds = batchIds self.batchIds = batchIds
} }
} }
public struct DebridCloudDownload: Hashable, Sendable { struct DebridCloudDownload: Hashable, Sendable {
let downloadId: String let id: String
let source: String
let fileName: String let fileName: String
let link: String let link: String
} }
public struct DebridCloudTorrent: Hashable, Sendable { struct DebridCloudMagnet: Hashable, Sendable {
let torrentId: String let id: 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]
} }
public enum DebridError: Error { enum DebridError: Error {
case InvalidUrl case InvalidUrl
case InvalidPostBody case InvalidPostBody
case InvalidResponse case InvalidResponse
case InvalidToken case InvalidToken
case EmptyData case EmptyData
case EmptyTorrents case EmptyUserMagnets
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
public extension Github { 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

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

View file

@ -7,7 +7,7 @@
import Foundation import Foundation
public struct PluginListJson: Codable { 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 @@ public struct PluginListJson: Codable {
// Color: Hex value // Color: Hex value
public struct PluginTagJson: Codable, Hashable, Sendable { public struct PluginTagJson: Codable, Hashable, Sendable {
public let name: String let name: String
public let colorHex: String? 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
public extension Premiumize { extension Premiumize {
// MARK: - CacheCheckResponse // MARK: - CacheCheckResponse
struct CacheCheckResponse: Codable { struct CacheCheckResponse: Codable {
@ -20,7 +20,6 @@ public 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
public extension RealDebrid { extension RealDebrid {
// MARK: - device code endpoint // MARK: - device code endpoint
struct DeviceCodeResponse: Codable, Sendable { struct DeviceCodeResponse: Codable, Sendable {
@ -58,7 +58,7 @@ public extension RealDebrid {
struct InstantAvailabilityResponse: Codable, Sendable { struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData? var data: InstantAvailabilityData?
public init(from decoder: Decoder) throws { 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 @@ public extension RealDebrid {
} }
} }
internal struct InstantAvailabilityData: Codable, Sendable { struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]] var rd: [[String: InstantAvailabilityInfo]]
} }
internal struct InstantAvailabilityInfo: Codable, Sendable { struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String var filename: String
var filesize: Int var filesize: Int
} }
@ -96,7 +96,7 @@ public extension RealDebrid {
// MARK: - torrentInfo endpoint // MARK: - torrentInfo endpoint
internal struct TorrentInfoResponse: Codable, Sendable { 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 @@ public extension RealDebrid {
} }
} }
internal struct TorrentInfoFile: Codable, Sendable { 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 @@ public extension RealDebrid {
// MARK: - unrestrictLink endpoint // MARK: - unrestrictLink endpoint
internal struct UnrestrictLinkResponse: Codable, Sendable { 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
public struct SearchResult: Codable, Hashable, Sendable { 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
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable { enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case json case json
case text case text
} }
public struct SourceJson: Codable, Hashable, Sendable, PluginJson { struct SourceJson: Codable, Hashable, Sendable, PluginJson {
public let name: String let name: String
public let version: Int16 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 @@ public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
let jsonParser: SourceJsonParserJson? let jsonParser: SourceJsonParserJson?
let rssParser: SourceRssParserJson? let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson? let htmlParser: SourceHtmlParserJson?
public let author: String? let author: String?
public let listId: UUID? let listId: UUID?
public let listName: String? let listName: String?
public let tags: [PluginTagJson]? let tags: [PluginTagJson]?
} }
public extension SourceJson { extension SourceJson {
// Fetches all tags without optional requirement // Fetches all tags without optional requirement
func getTags() -> [PluginTagJson] { func getTags() -> [PluginTagJson] {
tags ?? [] tags ?? []
} }
} }
public enum SourcePreferredParser: Int16, CaseIterable, Sendable { 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
} }
public struct SourceApiJson: Codable, Hashable, Sendable { struct SourceApiJson: Codable, Hashable, Sendable {
let apiUrl: String? let apiUrl: String?
let clientId: SourceApiCredentialJson? let clientId: SourceApiCredentialJson?
let clientSecret: SourceApiCredentialJson? let clientSecret: SourceApiCredentialJson?
} }
public struct SourceApiCredentialJson: Codable, Hashable, Sendable { struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let query: String? let query: String?
let value: String? let value: String?
let dynamic: Bool? let dynamic: Bool?
@ -60,8 +60,9 @@ public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let expiryLength: Double? let expiryLength: Double?
} }
public struct SourceJsonParserJson: Codable, Hashable, Sendable { 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
@ -72,9 +73,10 @@ public struct SourceJsonParserJson: Codable, Hashable, Sendable {
let sl: SourceSLJson? let sl: SourceSLJson?
} }
public struct SourceRssParserJson: Codable, Hashable, Sendable { 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?
@ -84,8 +86,9 @@ public struct SourceRssParserJson: Codable, Hashable, Sendable {
let sl: SourceSLJson? let sl: SourceSLJson?
} }
public struct SourceHtmlParserJson: Codable, Hashable, Sendable { 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
@ -94,21 +97,21 @@ public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let sl: SourceSLJson? let sl: SourceSLJson?
} }
public struct SourceComplexQueryJson: Codable, Hashable, Sendable { 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?
} }
public struct SourceMagnetJson: Codable, Hashable, Sendable { 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?
} }
public struct SourceSLJson: Codable, Hashable, Sendable { struct SourceSLJson: Codable, Hashable, Sendable {
let seeders: String? let seeders: String?
let leechers: String? let leechers: String?
let combined: String? let combined: String?
@ -117,3 +120,9 @@ public 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

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

View file

@ -7,12 +7,14 @@
import Foundation import Foundation
public protocol DebridSource: AnyObservableObject { 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 }
@ -21,37 +23,50 @@ public 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
// Torrents also checked here // Cloud magnets also checked here
func getDownloadLink(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> String func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?)
// Cloud variables // Unrestricts a locked file
var cloudDownloads: [DebridCloudDownload] { get set } func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String
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 deleteDownload(downloadId: String) async throws func deleteUserDownload(downloadId: String) async throws
// User torrent functions // User magnet functions
func getUserTorrents() async throws func getUserMagnets() async throws
func deleteTorrent(torrentId: String?) async throws func deleteUserMagnet(cloudMagnetId: String?) async throws
} }
public protocol PollingDebridSource: DebridSource { extension DebridSource {
var description: String? {
nil
}
var cachedStatus: [String] {
[]
}
}
protocol PollingDebridSource: DebridSource {
// Task reference for polling // Task reference for polling
var authTask: Task<Void, Error>? { get set } var authTask: Task<Void, Error>? { get set }
@ -59,7 +74,7 @@ public protocol PollingDebridSource: DebridSource {
func getAuthUrl() async throws -> URL func getAuthUrl() async throws -> URL
} }
public protocol OAuthDebridSource: DebridSource { 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
public protocol Plugin: ObservableObject, NSManagedObject { 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 {
} }
} }
public protocol PluginJson: Hashable { 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
public class Application { 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

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

View file

@ -27,7 +27,7 @@ class ErasedObservableObject: ObservableObject {
} }
} }
public protocol AnyObservableObject: AnyObject { protocol AnyObservableObject: AnyObject {
var objectWillChange: ObservableObjectPublisher { get } var objectWillChange: ObservableObjectPublisher { get }
} }
@ -59,14 +59,14 @@ public protocol AnyObservableObject: AnyObject {
/// Not all injected objects need this property wrapper. See the example projects for examples each /// Not all injected objects need this property wrapper. See the example projects for examples each
/// way. /// way.
@propertyWrapper @propertyWrapper
public struct Store<ObjectType> { struct Store<ObjectType> {
/// The underlying object being stored. /// The underlying object being stored.
public let wrappedValue: ObjectType 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 internal var observableObject: ErasedObservableObject { @MainActor var observableObject: ErasedObservableObject {
_observableObject.wrappedValue _observableObject.wrappedValue
} }
@ -83,16 +83,16 @@ public struct Store<ObjectType> {
/// } /// }
/// } /// }
/// ``` /// ```
public var projectedValue: Wrapper { 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`.
public init<S: Scheduler>(wrappedValue: ObjectType, 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 @@ public 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:)``.
public init(wrappedValue: ObjectType) { init(wrappedValue: ObjectType) {
self.init(wrappedValue: wrappedValue, on: DispatchQueue.main) self.init(wrappedValue: wrappedValue, on: DispatchQueue.main)
} }
@ -120,15 +120,15 @@ public struct Store<ObjectType> {
/// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper) /// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper)
/// type. /// type.
@dynamicMemberLookup @dynamicMemberLookup
public struct Wrapper { struct Wrapper {
private var store: Store private var store: Store
internal init(_ store: Store<ObjectType>) { 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.
public subscript<Subject>( subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject> dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>
) -> Binding<Subject> { ) -> Binding<Subject> {
Binding { Binding {
@ -141,7 +141,7 @@ public struct Store<ObjectType> {
} }
extension Store: DynamicProperty { extension Store: DynamicProperty {
public nonisolated mutating func update() { nonisolated mutating func update() {
_observableObject.update() _observableObject.update()
} }
} }

View file

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

View file

@ -9,26 +9,21 @@ import Foundation
import SwiftUI import SwiftUI
@MainActor @MainActor
public class DebridManager: ObservableObject { 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] lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
// 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 {
@ -38,9 +33,10 @@ public 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
var selectedOAuthDebridSource: OAuthDebridSource? private var selectedOAuthDebridSource: OAuthDebridSource?
@Published var filteredIAStatus: Set<IAStatus> = [] @Published var filteredIAStatus: Set<IAStatus> = []
@ -48,18 +44,15 @@ public 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
// AllDebrid auth variables @Published var showNotImplementedAlert: Bool = false
var allDebridAuthProcessing: Bool = false @Published var notImplementedMessage: String = ""
// 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?
@ -83,7 +76,7 @@ public 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
public func migratePreferredService(_ idInt: Int) -> String? { private 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")
@ -92,7 +85,7 @@ public 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
func sendDebridError( private func sendDebridError(
_ error: Error, _ error: Error,
prefix: String, prefix: String,
presentError: Bool = true, presentError: Bool = true,
@ -119,20 +112,20 @@ public 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
public func clearIAValues() { 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
public func clearSelectedDebridItems() { 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
public func populateDebridIA(_ resultMagnets: [Magnet]) async { func populateDebridIA(_ resultMagnets: [Magnet]) async {
for debridSource in debridSources { for debridSource in debridSources {
if !debridSource.isLoggedIn { if !debridSource.isLoggedIn {
continue continue
@ -148,7 +141,7 @@ public 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
public func matchMagnetHash(_ magnet: Magnet) -> IAStatus { func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
guard let magnetHash = magnet.hash else { guard let magnetHash = magnet.hash else {
return .none return .none
} }
@ -162,9 +155,9 @@ public class DebridManager: ObservableObject {
} }
} }
public func selectDebridResult(magnet: Magnet) -> Bool { func selectDebridResult(magnet: Magnet) -> Bool {
guard let magnetHash = magnet.hash else { guard let magnetHash = magnet.hash else {
logManager?.error("DebridManager: Could not find the torrent magnet hash") logManager?.error("DebridManager: Could not find the magnet hash")
return false return false
} }
@ -174,9 +167,14 @@ public 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?.error("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)") logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
return false return false
} }
} }
@ -184,14 +182,14 @@ public 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
public func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async { 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 enabledDebridCount == 1 { if enabledDebrids.count == 1 {
selectedDebridSource = debridSource selectedDebridSource = debridSource
} }
} }
@ -199,6 +197,8 @@ public 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,6 +211,7 @@ public 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")
} }
@ -229,8 +230,12 @@ public 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: Could not figure out the authentication type for \(debridSource.id). Is this configured properly?" "DebridManager: Auth: \(debridSource.id) does not have a login portal.",
showToast: false
) )
return return
@ -253,7 +258,7 @@ public 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 func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { @discardableResult private 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
@ -270,9 +275,9 @@ public class DebridManager: ObservableObject {
} }
// Currently handles Premiumize callback // Currently handles Premiumize callback
public func handleAuthCallback(url: URL?, error: Error?) async { func handleAuthCallback(url: URL?, error: Error?) async {
defer { defer {
if enabledDebridCount == 1 { if enabledDebrids.count == 1 {
selectedDebridSource = selectedOAuthDebridSource selectedDebridSource = selectedOAuthDebridSource
} }
@ -290,6 +295,7 @@ public 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")
} }
@ -300,22 +306,29 @@ public class DebridManager: ObservableObject {
// MARK: - Logout UI functions // MARK: - Logout UI functions
public func logout(_ debridSource: some DebridSource) async { 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
public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
defer { defer {
currentDebridTask = nil
logManager?.hideIndeterminateToast() logManager?.hideIndeterminateToast()
if !requiresUnrestrict {
clearSelectedDebridItems()
}
currentDebridTask = nil
} }
logManager?.updateIndeterminateToast("Loading content", cancelAction: { logManager?.updateIndeterminateToast("Loading content", cancelAction: {
@ -328,18 +341,37 @@ public 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 downloadLink = try await debridSource.getDownloadLink( let (restrictedFile, newIA) = try await debridSource.getRestrictedFile(
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 = downloadLink downloadUrl = try await debridSource.unrestrictFile(restrictedFile)
} 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")
} }
@ -353,22 +385,48 @@ public 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
public func fetchDebridCloud(bypassTTL: Bool = false) async { 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 torrent arrays // Populates the inner downloads and magnet arrays
try await selectedSource.getUserDownloads() try await selectedSource.getUserDownloads()
try await selectedSource.getUserTorrents() try await selectedSource.getUserMagnets()
// 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
@ -381,31 +439,55 @@ public class DebridManager: ObservableObject {
} }
} }
public func deleteCloudDownload(_ download: DebridCloudDownload) async { func deleteCloudDownload(_ download: DebridCloudDownload) async {
guard let selectedSource = selectedDebridSource else { guard let selectedSource = selectedDebridSource else {
return return
} }
do { do {
try await selectedSource.deleteDownload(downloadId: download.downloadId) try await selectedSource.deleteUserDownload(downloadId: download.id)
await fetchDebridCloud(bypassTTL: true) await fetchDebridCloud(bypassTTL: true)
} catch { } catch {
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error") switch error {
case DebridError.NotImplemented:
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
notImplementedMessage = message
showNotImplementedAlert.toggle()
logManager?.error(
"DebridManager: \(message)",
showToast: false
)
default:
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
}
} }
} }
public func deleteCloudTorrent(_ torrent: DebridCloudTorrent) async { func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async {
guard let selectedSource = selectedDebridSource else { guard let selectedSource = selectedDebridSource else {
return return
} }
do { do {
try await selectedSource.deleteTorrent(torrentId: torrent.torrentId) try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id)
await fetchDebridCloud(bypassTTL: true) await fetchDebridCloud(bypassTTL: true)
} catch { } catch {
await sendDebridError(error, prefix: "\(selectedSource.id) torrent delete error") switch error {
case DebridError.NotImplemented:
let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website."
notImplementedMessage = message
showNotImplementedAlert.toggle()
logManager?.error(
"DebridManager: \(message)",
showToast: false
)
default:
await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error")
}
} }
} }
} }

View file

@ -1,5 +1,5 @@
// //
// ToastViewModel.swift // LoggingManager.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?
public func info(_ message: String, 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())")
} }
public func warn(_ message: String, 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())")
} }
public func error(_ message: String, 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
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) { func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
indeterminateToastDescription = description indeterminateToastDescription = description
if let cancelAction { if let cancelAction {
@ -144,13 +144,13 @@ class LoggingManager: ObservableObject {
} }
} }
public func hideIndeterminateToast() { func hideIndeterminateToast() {
showIndeterminateToast = false showIndeterminateToast = false
indeterminateToastDescription = "" indeterminateToastDescription = ""
indeterminateCancelAction = nil indeterminateCancelAction = nil
} }
public func exportLogs() { 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
public class NavigationViewModel: ObservableObject { class NavigationViewModel: ObservableObject {
var logManager: LoggingManager? var logManager: LoggingManager?
// Used between SearchResultsView and MagnetChoiceView // Used between SearchResultsView and MagnetChoiceView
public enum ChoiceSheetType: Identifiable { enum ChoiceSheetType: Identifiable {
public var id: Int { var id: Int {
hashValue hashValue
} }
@ -53,7 +53,7 @@ public class NavigationViewModel: ObservableObject {
@Published var currentSortFilter: SortFilter? @Published var currentSortFilter: SortFilter?
@Published var currentSortOrder: SortOrder = .forward @Published var currentSortOrder: SortOrder = .forward
public func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool { 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 @@ public class NavigationViewModel: ObservableObject {
@Published var searchPrompt: String = "Search" @Published var searchPrompt: String = "Search"
@Published var lastSearchPromptIndex: Int = -1 @Published var lastSearchPromptIndex: Int = -1
let searchBarTextArray: [String] = [ private 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 @@
// //
// SourceManager.swift // PluginManager.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
public class PluginManager: ObservableObject { class PluginManager: ObservableObject {
var logManager: LoggingManager? var logManager: LoggingManager?
let kodi: Kodi = .init() let kodi: Kodi = .init()
@ -25,18 +25,18 @@ public class PluginManager: ObservableObject {
@Published var actionSuccessAlertMessage: String = "" @Published var actionSuccessAlertMessage: String = ""
@MainActor @MainActor
func cleanAvailablePlugins() { private func cleanAvailablePlugins() {
availableSources = [] availableSources = []
availableActions = [] availableActions = []
} }
@MainActor @MainActor
func updateAvailablePlugins(_ newPlugins: AvailablePlugins) { private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
availableSources += newPlugins.availableSources availableSources += newPlugins.availableSources
availableActions += newPlugins.availableActions availableActions += newPlugins.availableActions
} }
public func fetchPluginsFromUrl() async { 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 @@ public class PluginManager: ObservableObject {
await logManager?.info("Plugin list fetch finished") await logManager?.info("Plugin list fetch finished")
} }
func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? { private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
var tempSources: [SourceJson] = [] var tempSources: [SourceJson] = []
var tempActions: [ActionJson] = [] var tempActions: [ActionJson] = []
@ -176,7 +176,7 @@ public class PluginManager: ObservableObject {
} }
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback) // Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? { private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
let osArray = deeplinks.filter { deeplink in 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 @@ public class PluginManager: ObservableObject {
} }
} }
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] { private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
switch String(describing: PJ.self) { switch String(describing: PJ.self) {
case "SourceJson": case "SourceJson":
return availableSources as? [PJ] ?? [] return availableSources as? [PJ] ?? []
@ -256,7 +256,7 @@ public 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
func checkAppVersion(minVersion: String?) -> Bool { private 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 @@ public class PluginManager: ObservableObject {
} }
// Fetches sources using the background context // Fetches sources using the background context
public func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] { 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 @@ public class PluginManager: ObservableObject {
} }
@MainActor @MainActor
public func runDefaultAction(urlString: String?, navModel: NavigationViewModel) { 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 @@ public class PluginManager: ObservableObject {
// The iOS version of Ferrite only runs deeplink actions // The iOS version of Ferrite only runs deeplink actions
@MainActor @MainActor
public func runDeeplinkAction(_ action: Action, urlString: String?) { 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 @@ public class PluginManager: ObservableObject {
} }
@MainActor @MainActor
public func sendToKodi(urlString: String?, server: KodiServer) async { 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 @@ public class PluginManager: ObservableObject {
} }
} }
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async { func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else { 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 @@ public class PluginManager: ObservableObject {
} }
} }
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async { func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
guard let sourceJson else { 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 @@ public class PluginManager: ObservableObject {
} }
} }
func addSourceApi(newSource: Source, apiJson: SourceApiJson) { private 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,7 +570,8 @@ public class PluginManager: ObservableObject {
newSource.api = newSourceApi newSource.api = newSourceApi
} }
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) { // TODO: Migrate parser addition to a common protocol
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext) let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
@ -578,6 +579,13 @@ public 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)
@ -638,7 +646,7 @@ public class PluginManager: ObservableObject {
newSource.jsonParser = newSourceJsonParser newSource.jsonParser = newSourceJsonParser
} }
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) { private 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)
@ -646,6 +654,13 @@ public 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
@ -710,7 +725,7 @@ public class PluginManager: ObservableObject {
newSource.rssParser = newSourceRssParser newSource.rssParser = newSourceRssParser
} }
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) { private 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)
@ -726,6 +741,16 @@ public 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
@ -770,7 +795,7 @@ public 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
public func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws { func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext 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
func updateSearchResults(newResults: [SearchResult]) { private func updateSearchResults(newResults: [SearchResult]) {
searchResults += newResults searchResults += newResults
} }
@MainActor @MainActor
func clearSearchResults() { private func clearSearchResults() {
searchResults = [] searchResults = []
} }
@Published var currentSourceNames: Set<String> = [] @Published var currentSourceNames: Set<String> = []
@MainActor @MainActor
func updateCurrentSourceNames(_ newName: String) { private 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
func removeCurrentSourceName(_ removedName: String) { private func removeCurrentSourceName(_ removedName: String) {
currentSourceNames.remove(removedName) currentSourceNames.remove(removedName)
logManager?.updateIndeterminateToast( logManager?.updateIndeterminateToast(
"Loading \(currentSourceNames.joined(separator: ", "))", "Loading \(currentSourceNames.joined(separator: ", "))",
@ -56,17 +56,39 @@ class ScrapingViewModel: ObservableObject {
} }
@MainActor @MainActor
func clearCurrentSourceNames() { private 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
func sendSourceError(_ description: String) async { private func sendSourceError(_ description: String) async {
await logManager?.error(description, showToast: false) await logManager?.error(description, showToast: false)
} }
public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async { // Substitutes the given string with an arbitrary parameter dictionary
private func substituteParams(_ input: String, with params: [String: String]) -> String {
let replaced = params.reduce(input) { result, param -> String in
result.replacingOccurrences(of: "{\(param.key)}", with: param.value)
}
return replaced
}
// Cleans a SourceRequest's body and headers to be substituted
private func cleanRequest(request: SourceRequest, params: [String: String]) -> SourceRequest {
if let body = request.body {
request.body = substituteParams(body, with: params)
}
if let headers = request.headers {
request.headers = headers.mapValues { substituteParams($0, with: params) }
}
return request
}
func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
await logManager?.info("Started scanning sources for query \"\(searchText)\"") await logManager?.info("Started scanning sources for query \"\(searchText)\"")
if sources.isEmpty { if sources.isEmpty {
@ -80,7 +102,7 @@ class ScrapingViewModel: ObservableObject {
cleanedSearchText = searchText.lowercased() cleanedSearchText = searchText.lowercased()
if await !debridManager.hasEnabledDebrids { if await !debridManager.enabledDebrids.isEmpty {
await debridManager.clearIAValues() await debridManager.clearIAValues()
} }
@ -114,7 +136,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.hasEnabledDebrids { if await !debridManager.enabledDebrids.isEmpty {
await debridManager.populateDebridIA(requestResult.magnets) await debridManager.populateDebridIA(requestResult.magnets)
} }
@ -144,7 +166,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
func executeParser(source: Source) async -> SearchRequestResult? { private func executeParser(source: Source) async -> SearchRequestResult? {
guard let website = source.website else { 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)")
@ -160,19 +182,26 @@ 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 {
$0 substituteParams($0, with: params)
.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,
@ -183,23 +212,25 @@ class ScrapingViewModel: ObservableObject {
} }
case .rss: case .rss:
if let rssParser = source.rssParser { if let rssParser = source.rssParser {
let replacedSearchUrl = rssParser.searchUrl params.updateValue(source.api?.clientSecret?.value ?? "", forKey: "secret")
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
.replacingOccurrences(of: "{query}", with: encodedQuery) let replacedSearchUrl = substituteParams(rssParser.searchUrl, with: params)
// 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
) )
} }
@ -211,8 +242,7 @@ class ScrapingViewModel: ObservableObject {
} }
case .siteApi: case .siteApi:
if let jsonParser = source.jsonParser { if let jsonParser = source.jsonParser {
var replacedSearchUrl = jsonParser.searchUrl var replacedSearchUrl = substituteParams(jsonParser.searchUrl, with: params)
.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
@ -248,7 +278,8 @@ 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 {
@ -263,16 +294,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
func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String) async -> Data? { private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? {
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "") let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) { if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
return data 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) { if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
return data return data
} }
} }
@ -281,12 +312,12 @@ class ScrapingViewModel: ObservableObject {
return nil return nil
} }
public func handleApiCredential(_ credential: SourceApiCredential, private 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
@ -298,8 +329,7 @@ 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 searchUrl return substituteParams(searchUrl, with: [replacement: value])
.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,
@ -323,9 +353,9 @@ class ScrapingViewModel: ObservableObject {
return nil return nil
} }
public func fetchApiCredential(urlString: String, private 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.")
@ -369,7 +399,7 @@ class ScrapingViewModel: ObservableObject {
} }
// Fetches the data for a URL // Fetches the data for a URL
public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? { private func fetchWebsiteData(urlString: String, sourceName: String, requestParams: SourceRequest?) async -> Data? {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { 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!")
@ -388,7 +418,12 @@ class ScrapingViewModel: ObservableObject {
} }
} }
let request = URLRequest(url: url, timeoutInterval: timeout) var request = URLRequest(url: url, timeoutInterval: timeout)
request.httpMethod = requestParams?.method
request.httpBody = requestParams?.body?.data(using: .utf8)
requestParams?.headers?.forEach { field, value in
request.addValue(value, forHTTPHeaderField: field)
}
do { do {
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
@ -411,7 +446,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? { private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
guard let jsonParser = source.jsonParser else { guard let jsonParser = source.jsonParser else {
return nil return nil
} }
@ -486,10 +521,10 @@ class ScrapingViewModel: ObservableObject {
} }
// TODO: Add regex parsing for API // TODO: Add regex parsing for API
public func parseJsonResult(_ result: JSON, private 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 {
@ -580,7 +615,7 @@ class ScrapingViewModel: ObservableObject {
} }
// RSS feed scraper // RSS feed scraper
public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? { private func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
guard let rssParser = source.rssParser else { guard let rssParser = source.rssParser else {
return nil return nil
} }
@ -715,11 +750,11 @@ class ScrapingViewModel: ObservableObject {
} }
// Complex query parsing for RSS scraping // Complex query parsing for RSS scraping
func runRssComplexQuery(item: Element, private 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?
@ -748,7 +783,7 @@ class ScrapingViewModel: ObservableObject {
} }
// HTML scraper // HTML scraper
public func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? { private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
guard let htmlParser = source.htmlParser else { guard let htmlParser = source.htmlParser else {
return nil return nil
} }
@ -800,7 +835,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), let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request),
let magnetHtml = String(data: data, encoding: .utf8) let magnetHtml = String(data: data, encoding: .utf8)
else { else {
continue continue
@ -885,7 +920,7 @@ class ScrapingViewModel: ObservableObject {
) )
} }
if let leecherQuery = seederLeecher.seeders { if let leecherQuery = seederLeecher.leechers {
leechers = try? runHtmlComplexQuery( leechers = try? runHtmlComplexQuery(
row: row, row: row,
query: leecherQuery, query: leecherQuery,
@ -920,10 +955,10 @@ class ScrapingViewModel: ObservableObject {
} }
// Complex query parsing for HTML scraping // Complex query parsing for HTML scraping
func runHtmlComplexQuery(row: Element, private 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?
@ -945,7 +980,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
func runRegex(parsedValue: String, regexString: String) -> String? { private func runRegex(parsedValue: String, regexString: String) -> String? {
// TODO: Maybe dynamically parse flags // TODO: Maybe dynamically parse flags
let replacedRegexString = regexString let replacedRegexString = regexString
.replacingOccurrences(of: "{query}", with: cleanedSearchText) .replacingOccurrences(of: "{query}", with: cleanedSearchText)
@ -968,7 +1003,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
func parseSizeString(sizeString: String) -> String? { private func parseSizeString(sizeString: String) -> String? {
// Test if the string can be a full integer // 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
@ -990,7 +1025,7 @@ class ScrapingViewModel: ObservableObject {
} }
} }
func cleanApiCreds(api: SourceApi, sourceName: String) async { private func cleanApiCreds(api: SourceApi, sourceName: String) async {
let backgroundContext = PersistenceController.shared.backgroundContext let 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 {
public func fieldDisabled(_ isFieldDisabled: Bool) -> Self { func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
modifyViewProp { $0.isFieldDisabled = isFieldDisabled } modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
} }
} }

View file

@ -21,13 +21,12 @@ 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 * self.offset, y: 0) .offset(x: reader.size.width * 1.2 * offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: self.offset) .animation(.default.repeatForever().speed(0.5), value: offset)
.onAppear { .onAppear {
withAnimation { withAnimation {
self.offset = 1 offset = 1
} }
} }
) )

View file

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

View file

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

View file

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

View file

@ -5,26 +5,18 @@
// 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 {
if #available(iOS 16, *) { content
content .introspect(.list, on: .iOS(.v16, .v17, .v18)) { collectionView in
.introspectCollectionView { collectionView in collectionView.contentInset.top = inset
collectionView.contentInset.top = inset }
}
} else {
content
.introspectTableView { tableView in
tableView.contentInset.top = inset
}
}
} }
} }

View file

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

View file

@ -56,20 +56,27 @@ struct BookmarksView: View {
.frame(height: 15) .frame(height: 15)
} }
.task { .task {
if debridManager.hasEnabledDebrids { await matchAgainstIA()
let magnets = bookmarks.compactMap { }
if let magnetHash = $0.magnetHash { .refreshable {
return Magnet(hash: magnetHash, link: $0.magnetLink) await matchAgainstIA()
} 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,21 +24,24 @@ struct CloudDownloadView: View {
Button(cloudDownload.fileName) { Button(cloudDownload.fileName) {
navModel.resultFromCloud = true navModel.resultFromCloud = true
navModel.selectedTitle = cloudDownload.fileName navModel.selectedTitle = cloudDownload.fileName
debridManager.downloadUrl = cloudDownload.link var historyEntry = HistoryEntryJson(
name: cloudDownload.fileName,
PersistenceController.shared.createHistory( source: debridSource.id
HistoryEntryJson(
name: cloudDownload.fileName,
url: cloudDownload.link,
source: debridSource.id
),
performSave: true
) )
pluginManager.runDefaultAction( debridManager.currentDebridTask = Task {
urlString: debridManager.downloadUrl, await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link)
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 @@
// //
// CloudTorrentView.swift // CloudMagnetView.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 CloudTorrentView: View { struct CloudMagnetView: 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,29 +17,37 @@ struct CloudTorrentView: View {
@Binding var searchText: String @Binding var searchText: String
var body: some View { var body: some View {
DisclosureGroup("Torrents") { DisclosureGroup("Magnets") {
ForEach(debridSource.cloudTorrents.filter { ForEach(debridSource.cloudMagnets.filter {
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased()) searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
}, id: \.self) { cloudTorrent in }, id: \.self) { cloudMagnet in
Button { Button {
if cloudTorrent.status == "downloaded", !cloudTorrent.links.isEmpty { if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty {
navModel.resultFromCloud = true navModel.resultFromCloud = true
navModel.selectedTitle = cloudTorrent.fileName navModel.selectedTitle = cloudMagnet.fileName
var historyInfo = HistoryEntryJson( var historyInfo = HistoryEntryJson(
name: cloudTorrent.fileName, name: cloudMagnet.fileName,
source: debridSource.id source: debridSource.id
) )
Task { Task {
let magnet = Magnet(hash: cloudTorrent.hash, link: nil) let magnet = Magnet(hash: cloudMagnet.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 cloudTorrent.links.count == 1 { if cloudMagnet.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)
@ -59,15 +67,15 @@ struct CloudTorrentView: View {
} }
} label: { } label: {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(cloudTorrent.fileName) Text(cloudMagnet.fileName)
.font(.callout) .font(.callout)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(4) .lineLimit(4)
HStack { HStack {
Text(cloudTorrent.status.capitalizingFirstLetter()) Text(cloudMagnet.status.capitalizingFirstLetter())
Spacer() Spacer()
DebridLabelView(debridSource: debridSource, cloudLinks: cloudTorrent.links) DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links)
} }
.font(.caption) .font(.caption)
} }
@ -77,9 +85,9 @@ struct CloudTorrentView: View {
} }
.onDelete { offsets in .onDelete { offsets in
for index in offsets { for index in offsets {
if let cloudTorrent = debridSource.cloudTorrents[safe: index] { if let cloudMagnet = debridSource.cloudMagnets[safe: index] {
Task { Task {
await debridManager.deleteCloudTorrent(cloudTorrent) await debridManager.deleteUserMagnet(cloudMagnet)
} }
} }
} }

View file

@ -16,13 +16,8 @@ struct DebridCloudView: View {
var body: some View { var body: some View {
List { List {
if !debridSource.cloudDownloads.isEmpty { CloudDownloadView(debridSource: debridSource, searchText: $searchText)
CloudDownloadView(debridSource: debridSource, searchText: $searchText) CloudMagnetView(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(header: InlineHeader(formatter.string(from: historyGroup[0].date ?? Date()))) { Section(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.hasEnabledDebrids { if !debridManager.enabledDebrids.isEmpty {
Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud) Text("Cloud").tag(NavigationViewModel.LibraryPickerSegment.debridCloud)
} }
} }

View file

@ -1,5 +1,5 @@
// //
// InstalledSourceButtonView.swift // InstalledPluginButtonView.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 @@
// //
// SourceCatalogButtonView.swift // PluginCatalogButtonView.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(header: InlineHeader("Description")) { Section("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(header: InlineHeader("Metadata")) { Section("Metadata") {
VStack(alignment: .leading) { VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) { HStack(spacing: 5) {
@ -32,8 +32,7 @@ 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(header: InlineHeader("Updates")) { Section("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(header: InlineHeader("Installed")) { Section("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(header: InlineHeader("Catalog")) { Section("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 {
NavView { NavigationStack {
List { List {
if let selectedPlugin { if let selectedPlugin {
PluginInfoMetaView(selectedPlugin: selectedPlugin) PluginInfoMetaView(selectedPlugin: selectedPlugin)

View file

@ -1,5 +1,5 @@
// //
// PluginTagView.swift // PluginTagsView.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: InlineHeader("API credentials"), header: Text("API credentials"),
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.") 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: InlineHeader("Base URL"), header: Text("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(header: InlineHeader("Fetch method")) { Section("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.hasEnabledDebrids { if !debridManager.enabledDebrids.isEmpty {
IAFilterView() IAFilterView()
} }

View file

@ -28,50 +28,26 @@ 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 debridManager.fetchDebridDownload(magnet: result.magnet) await downloadToDebrid()
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 = HistoryEntryJson( navModel.selectedHistoryInfo = historyEntry
name: result.title,
source: result.source
)
navModel.currentChoiceSheet = .batch navModel.currentChoiceSheet = .batch
} }
case .none: case .none:
PersistenceController.shared.createHistory( historyEntry.url = result.magnet.link
HistoryEntryJson( PersistenceController.shared.createHistory(historyEntry, performSave: true)
name: result.title,
url: result.magnet.link,
source: result.source
),
performSave: true
)
pluginManager.runDefaultAction( pluginManager.runDefaultAction(
urlString: result.magnet.link, urlString: result.magnet.link,
@ -92,7 +68,7 @@ struct SearchResultButtonView: View {
} }
.disableInteraction(navModel.currentChoiceSheet != nil) .disableInteraction(navModel.currentChoiceSheet != nil)
.tint(.primary) .tint(.primary)
.conditionalContextMenu(id: existingBookmark) { .contextMenu {
ZStack { ZStack {
if let bookmark = existingBookmark { if let bookmark = existingBookmark {
Button { Button {
@ -123,19 +99,46 @@ struct SearchResultButtonView: View {
} }
} }
} }
Button {
if debridManager.currentDebridTask == nil {
let foundIAResult = debridManager.selectDebridResult(magnet: result.magnet)
// Add a fake IA because we don't know if the magnet is cached at this point
if !foundIAResult {
debridManager.selectedDebridItem = DebridIA(
magnet: result.magnet,
expiryTimeStamp: Date().timeIntervalSince1970,
files: []
)
}
debridManager.currentDebridTask = Task {
await downloadToDebrid()
// Re-populate the IA cache if a result wasn't initially found
if !foundIAResult {
await debridManager.populateDebridIA([result.magnet])
}
}
}
} label: {
Text("Download to Debrid")
Image(systemName: "arrow.down.circle")
}
} }
.alert("Caching file", isPresented: $debridManager.showDeleteAlert) { .alert("Caching file", isPresented: $debridManager.showDeleteAlert) {
Button("Yes", role: .destructive) { Button("Yes", role: .destructive) {
Task { Task {
try? await debridManager.selectedDebridSource?.deleteTorrent(torrentId: nil) try? await debridManager.selectedDebridSource?.deleteUserMagnet(cloudMagnetId: nil)
} }
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} message: { } message: {
Text( Text(
"\(String(describing: debridManager.selectedDebridSource?.id)) is currently caching this file. " + "\(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") is currently caching this file. " +
"Would you like to delete it? \n\n" + "Would you like to delete it? \n\n" +
"Progress can be checked on the RealDebrid website." "Progress can be checked on the \(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") website."
) )
} }
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in .onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in
@ -168,4 +171,35 @@ struct SearchResultButtonView: View {
} }
} }
} }
// Common function to download
func downloadToDebrid() async {
var historyEntry = HistoryEntryJson(
name: result.title,
source: result.source
)
await debridManager.fetchDebridDownload(magnet: result.magnet)
navModel.selectedTitle = result.title ?? ""
if debridManager.requiresUnrestrict {
navModel.currentChoiceSheet = .batch
return
}
if !debridManager.downloadUrl.isEmpty {
historyEntry.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyEntry, performSave: true)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
if navModel.currentChoiceSheet != .action {
debridManager.downloadUrl = ""
}
}
}
} }

View file

@ -1,5 +1,5 @@
// //
// SearchResultRDView.swift // SearchResultInfoView.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 @@
// //
// DefaultActionsPickerViews.swift // DefaultActionPickerView.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 {
NavView { NavigationStack {
Form { Form {
Group { Group {
Section( Section(
header: InlineHeader("URL"), header: Text("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: InlineHeader("Friendly name"), header: Text("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: InlineHeader("Credentials"), header: Text("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(header: InlineHeader("Description")) { Section("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: InlineHeader("Servers"), header: Text("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 @@
// //
// SourceListEditorView.swift // PluginListEditorView.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 {
NavView { NavigationStack {
Form { Form {
TextField("Enter URL", text: $pluginListUrl) TextField("Enter URL", text: $pluginListUrl)
.disableAutocorrection(true) .disableAutocorrection(true)

View file

@ -1,5 +1,5 @@
// //
// SettingsSourceListView.swift // SettingsPluginListView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 7/25/22. // Created by Brian Dashore on 7/25/22.
@ -69,12 +69,8 @@ struct SettingsPluginListView: View {
} }
} }
.sheet(isPresented: $presentEditSheet) { .sheet(isPresented: $presentEditSheet) {
if #available(iOS 16, *) { PluginListEditorView()
PluginListEditorView() .presentationDetents([.medium])
.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(header: InlineHeader("GitHub links")) { Section("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 @@
// //
// DebridInfoView.swift // SettingsDebridInfoView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 3/5/23. // Created by Brian Dashore on 3/5/23.
@ -16,16 +16,18 @@ struct SettingsDebridInfoView: View {
var body: some View { var body: some View {
List { List {
Section(header: InlineHeader("Description")) { Section("Description") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("\(debridSource.id) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.") Text(debridSource.description ??
"\(debridSource.id) is a debrid service that is used for downloads and media playback. You must pay to access the service."
)
Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!) Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!)
} }
} }
Section( Section(
header: InlineHeader("Login status"), header: Text("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 {
@ -46,10 +48,17 @@ 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: InlineHeader("API key"), header: Text("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

@ -0,0 +1,31 @@
//
// SettingsDebridLinkView.swift
// Ferrite
//
// Created by Brian Dashore on 11/27/24.
//
import SwiftUI
struct SettingsDebridLinkView: View {
var debridSource: DebridSource
// TODO: Use a roundabout state for now
@State private var isLoggedIn = false
var body: some View {
NavigationLink {
SettingsDebridInfoView(debridSource: debridSource)
} label: {
HStack {
Text(debridSource.id)
Spacer()
Text(isLoggedIn ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
}
}
.onAppear {
isLoggedIn = debridSource.isLoggedIn
}
}
}

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 {
NavView { NavigationStack {
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 {
NavView { NavigationStack {
ZStack { ZStack {
switch navModel.libraryPickerSelection { switch navModel.libraryPickerSelection {
case .bookmarks: case .bookmarks:
@ -96,6 +96,11 @@ 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 {
NavView { NavigationStack {
WebView(url: url) WebView(url: url)
.navigationTitle("Sign in") .navigationTitle("Sign in")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View file

@ -54,12 +54,8 @@ struct MainView: View {
case .batch: case .batch:
BatchChoiceView() BatchChoiceView()
case .activity: case .activity:
if #available(iOS 16, *) { ShareSheet(activityItems: navModel.activityItems)
ShareSheet(activityItems: navModel.activityItems) .presentationDetents([.medium, .large])
.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 {
NavView { NavigationStack {
ZStack { ZStack {
if checkedForPlugins { if checkedForPlugins {
switch navModel.pluginPickerSelection { switch navModel.pluginPickerSelection {

View file

@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
public extension View { 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,10 +212,7 @@ 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,7 +6,6 @@
// //
import BetterSafariView import BetterSafariView
import Introspect
import SwiftUI import SwiftUI
import WebKit import WebKit
@ -43,24 +42,15 @@ struct SettingsView: View {
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
var body: some View { var body: some View {
NavView { NavigationStack {
Form { Form {
Section(header: InlineHeader("Debrid services")) { Section("Debrid services") {
ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in
NavigationLink { SettingsDebridLinkView(debridSource: debridSource)
SettingsDebridInfoView(debridSource: debridSource)
} label: {
HStack {
Text(debridSource.id)
Spacer()
Text(debridSource.isLoggedIn ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
}
}
} }
} }
Section(header: InlineHeader("Playback services")) { Section("Playback services") {
NavigationLink { NavigationLink {
SettingsKodiView(kodiServers: kodiServers) SettingsKodiView(kodiServers: kodiServers)
} label: { } label: {
@ -74,7 +64,7 @@ struct SettingsView: View {
} }
Section( Section(
header: InlineHeader("Behavior"), header: Text("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")
@ -121,14 +111,14 @@ struct SettingsView: View {
} }
} }
Section(header: InlineHeader("Plugin management")) { Section("Plugin management") {
NavigationLink("Plugin lists") { NavigationLink("Plugin lists") {
SettingsPluginListView() SettingsPluginListView()
} }
} }
Section(header: InlineHeader("Default actions")) { Section("Default actions") {
if debridManager.hasEnabledDebrids { if !debridManager.enabledDebrids.isEmpty {
NavigationLink { NavigationLink {
DefaultActionPickerView( DefaultActionPickerView(
actionRequirement: .debrid, actionRequirement: .debrid,
@ -185,13 +175,13 @@ struct SettingsView: View {
} }
} }
Section(header: InlineHeader("Backups")) { Section("Backups") {
NavigationLink("Backups") { NavigationLink("Backups") {
BackupsView() BackupsView()
} }
} }
Section(header: InlineHeader("Updates")) { Section("Updates") {
Toggle(isOn: $autoUpdateNotifs) { Toggle(isOn: $autoUpdateNotifs) {
Text("Show update alerts") Text("Show update alerts")
} }
@ -201,7 +191,7 @@ struct SettingsView: View {
} }
} }
Section(header: InlineHeader("Information")) { Section("Information") {
ListRowLinkView(text: "Donate", link: "https://ko-fi.com/kingbri") ListRowLinkView(text: "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")
@ -210,7 +200,7 @@ struct SettingsView: View {
} }
} }
Section(header: InlineHeader("Debug")) { Section("Debug") {
NavigationLink("Logs") { NavigationLink("Logs") {
SettingsLogView() SettingsLogView()
} }

View file

@ -1,5 +1,5 @@
// //
// MagnetChoiceView.swift // ActionChoiceView.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 {
NavView { NavigationStack {
Form { Form {
Section(header: InlineHeader("Now Playing")) { Section("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(header: InlineHeader("Debrid options")) { Section("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(header: InlineHeader("Magnet options")) { Section("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,13 +123,8 @@ struct ActionChoiceView: View {
} }
.tint(.primary) .tint(.primary)
.sheet(isPresented: $navModel.showLocalActivitySheet) { .sheet(isPresented: $navModel.showLocalActivitySheet) {
// TODO: Fix share sheet ShareSheet(activityItems: navModel.activityItems)
if #available(iOS 16, *) { .presentationDetents([.medium, .large])
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) {}
@ -143,6 +138,8 @@ 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
@ -153,8 +150,11 @@ 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,9 +19,8 @@ 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 {
NavView { NavigationStack {
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 {
@ -39,6 +38,10 @@ 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 {
@ -50,6 +53,7 @@ struct BatchChoiceView: View {
try? await Task.sleep(seconds: 1) try? await Task.sleep(seconds: 1)
debridManager.clearSelectedDebridItems() debridManager.clearSelectedDebridItems()
debridManager.requiresUnrestrict = false
} }
} }
} }
@ -60,7 +64,11 @@ 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 {
await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet) if debridManager.requiresUnrestrict {
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.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

5
Misc/Referrals/TorBox.md Normal file
View file

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

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