Compare commits

...

118 commits
v0.6.2 ... next

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
kingbri
273403b711 Debrid: Remove per-API IA structures
These aren't required since IA is a unified type. Only keep batch
IA for RealDebrid since it helps clear up confusion when gathering
InstantAvailability results.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 16:27:47 -04:00
kingbri
f9ecc746a1 Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 16:25:32 -04:00
kingbri
c641fdf300 Debrid: Add source to all models
Gives an ID of where the struct came from.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 16:24:07 -04:00
kingbri
f902142fee Debrid: Add Premiumize to InstantAvailability
Also add the requirement to the protocol.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 16:15:17 -04:00
kingbri
37ef64224e Debrid: Order API implementations
Reorder everything and mark off where different functions are located.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 16:00:40 -04:00
kingbri
9e306eff1e Debrid: Add protocol for cloud handling
Cloud downloads and torrents are now unified under their own
protocol and models. Downloads and torrents are separated.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-03 15:46:18 -04:00
kingbri
37450ef979 Debrid: Add InstantAvailability and download to protocol
Unify IA into a passable client side structure and add a common
download method to the DebridSource protocol.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-02 23:08:05 -04:00
kingbri
0fe1cbc888 Debrid: Begin using common protocols
Unifying the debrid services under a protocol will help slim down
on excess redundant code and allow for easy addition of new services
in the future.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-02 12:31:19 -04:00
kingbri
2e746320cf NavView: Switch to NavigationStack for iOS 17 and up
iOS 17 fixes the issues that NavigationStack had with iOS 16. This
means that futureproofing is fixed.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-08-27 01:14:26 -04:00
kingbri
13a40a237a Premiumize: Fix DDL fetching and debrid IA handling
Signed-off-by: kingbri <bdashore3@proton.me>
2023-08-26 11:29:19 -04:00
kingbri
46e0687bd7 Scraping: Add new source methods
Some sources can be unique and require some extra parsing. Add the
ability to extract a magnet link instead of assuming that every
source provides a properly formatted one.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-06-06 20:09:43 -04:00
kingbri
b8978fd29c Premiumize: Fix API key usage
PM has a different method to handle API keys compared to other services
which takes the value as an authorization header.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-05-02 14:44:03 -04:00
kingbri
cc550dd208 Debrid: Various updates to API and settings
Debrid services can change their APIs at any time which negatively
impacts user experiences on Ferrite.

Add the following:
- Ability for a user to add a manually generated API key only showing the
last 4 characters for security purposes.
- Make ephemeral auth sessions toggle-able. ASWebAuthenticationView does
not automatically clear on toggle change.
- Add the savedLinks endpoint for AllDebrid so users can access their
downloads and magnets.
- Add a links section to AD's cloud view.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-05-01 18:07:15 -04:00
kingbri
d8db3e0cc8 Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:57:32 -04:00
kingbri
3606dbb6ff Settings: Fix focus for fields
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:57:18 -04:00
kingbri
9e362c14b7 Ferrite: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:43:45 -04:00
kingbri
76a0262a14 Filters: Fix label with single value pickers
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:43:45 -04:00
kingbri
468d89b983 Scraping: Add configurable URLRequest timeout
Some websites and networks may be slow. Add the option to override
the request timeout of 15 seconds for search requests.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:43:42 -04:00
kingbri
45900d6456 SecureField: Fix keyboard focus when showing password
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:14:12 -04:00
kingbri
5232ddfc97 Filter: Add multi-select picker and change picker style
The default SwiftUI picker is too limited for Ferrite's purposes.
Switch to a custom implementation for UI consistency.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:14:12 -04:00
kingbri
f7d2f1ce60 Filters: Add result sorting
Sort by seeders, leechers, and size. Also supports ascending and
descending options.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:14:12 -04:00
kingbri
dc3014095c Search/Lists: Fix UI issues and appearance
The search bar sometimes had the scope bar added twice if a toolbar
was present (probably caused a refresh of the navigationTitle).

Rather than figuring out a hacky swiftUI solution to solve this,
add a check to enforce only one HostingViewController in the
scope bar.

Also migrate the inlinedList modifier to use safeAreaInsets from
native SwiftUI. Keep the introspected modifier for negative values.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-23 15:14:09 -04:00
kingbri
df534327e5 Scraping: Fix JSON scraping with subResults
SubResults were nullified by new title and magnet checks since they
were removed on the first pass.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-12 18:13:09 -04:00
kingbri
1d89b9519d Filters: Fix animations and add clear all button
Filters are more comprehensive and responsive to how many filters
are stored and if a user should clear all of them or not.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-12 18:13:09 -04:00
kingbri
8123fd8d0c Filters: Add debrid cache status option
Items can now be filtered based if they're present in the debrid cache,
are a batch file, or not present at all.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-12 18:13:09 -04:00
kingbri
75be076e0b Search: Fix keyboard lag with searchText
Make searchText a local variable passed to ScrapingModel to prevent
extreme keyboard lag and CPU usage when tracking this EnvironmentObject.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-09 21:37:14 -04:00
kingbri
4f303e1c1e Filters: Fix source filtering
Make it so that when a user chooses a source to filter, only filter
that specific source when a search occurs.

Also fix the "no results found" overlay text by checking if the
search bar textfield is being edited or not by modifiying ESSearchable.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-09 21:37:07 -04:00
kingbri
9427ca271b Ferrite: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-09 00:12:43 -04:00
kingbri
eacccf36ff Search: Add ExpandedSearchable replacement
ExpandedSearchable opens up the capabilities of the SwiftUI searchable
modifier and allows for additions of more properties such as custom
scope bars.

Since this is a reimplementation of UISearchController, changes
to SwiftUI should not affect search bars that rely on the scope bar
to always be present.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-09 00:12:40 -04:00
kingbri
fbd99752e4 Ferrite: Update to support iOS 16.4 and Swift 5.8
Xcode 14.3 changed many parts about swift which caused Ferrite to
not compile.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-08 18:17:50 -04:00
kingbri
d5ba67503b Plugins: Fix refreshable
Place refreshable in the individual lists instead of in the parent
ZStack as that applies to sheets as well.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-04 15:07:08 -04:00
kingbri
29614bc0f8 GitHub: Change repository URL
Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-02 15:32:01 -04:00
kingbri
b2616bdeb7 Plugins/Library: Add refreshable
Allow using pull to refresh for fetching plugins and information from
debrid cloud.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-02 15:14:42 -04:00
kingbri
d918039810 Plugins: Add website and about properties
These will serve as descriptions for a plugin which will be displayed
in the Plugin Info screen.

website has also replaced baseUrl and dynamicWebsite has replaced
dynamicBaseUrl

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-02 14:53:22 -04:00
kingbri
51366f3215 Sources: Don't require searchUrl in HTML parser
searchURL used to be a required variable in HTML parsers, but some
HTML sources can be single page which means that a search URL isn't
required.

Also make regex matching case insensitive along with adding anchors
to match newlines.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-01 23:19:15 -04:00
kingbri
22bec5da52 Plugins: Add option to use YAML for lists
YAML is a modern configuration format which is human readable
and supports more conventions than JSON.

However, some people may still prefer to write in JSON, so add the
option for users to write legacy JSON if they want to and to provide
backwards compatability with older plugins.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-01 23:11:30 -04:00
kingbri
c8c7732575 Search: Fix searchbar and prompts
Make searchbars adhere to autocorrect and fix the random search prompts
not applying by moving functionality to a ViewModel.

Also add a searchbar in the batch choice sheet.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-01 14:56:43 -04:00
kingbri
2cf6e46422 Sources: Improve regex and require title
Titles are now required as an entry without a title shouldn't be
featured. Support via regex is now added for matching along with
splicing strings via capture groups.

If a capture group isn't present, assume that a contains check
is occurring.

Also migrate back to searchText being located in scrapingModel.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-04-01 14:12:43 -04:00
kingbri
2982c971a8 Pickers: Change to the Picker structure
Pickers used to use a List workaround, change this to use actual
SwiftUI pickers.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-30 12:12:14 -04:00
kingbri
e650bbd2bb Settings: Switch NavigationLink initializers
Update to modern initializers instead of using deprecated ones.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-29 17:28:33 -04:00
kingbri
4ba58dc67f Ferrite: Use legacy NavigationView
NavigationStack has some glaring UI bugs, so switch back to
NavigationView for now since none of the new benefits from NavigationStack
are actually being used.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-29 15:26:00 -04:00
kingbri
3828ffa539 Ferrite: Forward port UI
Remove all iOS 14 specific components and workarounds and comply
with SwiftUI 3.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-29 14:43:49 -04:00
kingbri
8f9f522846 Bump minVersion to iOS 15
v0.7 and up drops iOS 14 support.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-28 12:22:46 -04:00
kingbri
2435952a86 Update README
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-28 12:05:05 -04:00
kingbri
1ed0710446 Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-28 11:59:41 -04:00
kingbri
5a73efa9dc Plugins: Fix crash on iOS 14
When installing an action and going back and forth to a different tab,
the app crashes on iOS 14.

Fix this by refreshing the list without worrying about previous
state. This makes Ferrite more efficient in terms of plugin fetching.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-28 11:53:49 -04:00
kingbri
371281118f Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-24 17:20:43 -04:00
kingbri
39a705717e Plugins: Unify settings
Plugin settings used to only be available for installed sources.
Change this to display info about an installed plugin and add settings
depending on the plugin type.

For example, a source will have additional settings specified by its
own views.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-24 17:20:40 -04:00
kingbri
9f83ebfce0 Ferrite: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-24 14:39:11 -04:00
kingbri
87d94e4c35 Plugins: Fix animation and appearance
Switching to a list that changes state on updates caused sheets
to break when animating. Place the list in a container ZStack
that doesn't break sheet presentation.

Also modernize the plugin installation buttons and make the catalog
buttons include the plugin list name which should help prevent
duplication.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-24 14:37:52 -04:00
139 changed files with 5170 additions and 3415 deletions

View file

@ -6,13 +6,13 @@ on:
jobs: jobs:
build: build:
runs-on: macos-12 runs-on: macos-14
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Xcode - name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1 uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: latest xcode-version: latest-stable
- name: Get commit SHA - name: Get commit SHA
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build - name: Build
@ -25,7 +25,7 @@ jobs:
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa

View file

@ -7,13 +7,13 @@ on:
jobs: jobs:
build: build:
runs-on: macos-12 runs-on: macos-14
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup Xcode - name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1 uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: latest xcode-version: latest-stable
- name: Build - name: Build
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
env: env:
@ -30,7 +30,7 @@ jobs:
run: | run: |
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
- name: Upload release - name: Upload release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip

View file

@ -1 +1 @@
5.7 5.8

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 */; };
@ -20,15 +23,12 @@
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; }; 0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; };
0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; }; 0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; }; 0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; }; 0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */; };
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; }; 0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; };
0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C2DD4CE29A6D47400293FC3 /* SwiftUIX */; };
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; }; 0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; };
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; }; 0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; };
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; }; 0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; };
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; }; 0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; };
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */; };
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; };
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */; }; 0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */; };
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */; }; 0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */; };
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */; }; 0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */; };
@ -39,10 +39,8 @@
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; }; 0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; }; 0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; };
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; }; 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridPickerView.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 */; };
@ -56,10 +54,7 @@
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; }; 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; };
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; }; 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; }; 0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; };
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */; };
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */; };
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; }; 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; }; 0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; }; 0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; }; 0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; };
@ -71,9 +66,11 @@
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; }; 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; }; 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; };
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; }; 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; }; 0C7075E429D374C50093DB2D /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E329D374C50093DB2D /* Color.swift */; };
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E529D3845D0093DB2D /* ShareSheet.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 */; };
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; }; 0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; };
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; }; 0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; };
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; }; 0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; };
@ -83,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 */; };
@ -92,7 +90,20 @@
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; }; 0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; };
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */; }; 0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */; };
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */; }; 0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */; };
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */; };
0C84FCE329E4B42600B0DFE4 /* IAFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */; };
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */; };
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; };
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; }; 0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; };
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */; };
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */; };
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; };
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */; };
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */; };
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */; }; 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 */; };
@ -100,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 */; };
@ -122,28 +132,35 @@
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; }; 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; }; 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; }; 0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.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 */; };
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 */; };
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; }; 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; };
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */; };
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; }; 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; };
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; }; 0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; };
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */; };
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; };
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */; };
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; }; 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; }; 0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */; };
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */; }; 0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */; };
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; }; 0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; };
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; }; 0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; };
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; }; 0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; };
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; }; 0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; };
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; }; 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 */; };
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; };
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -152,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>"; };
@ -160,14 +180,12 @@
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; }; 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = "<group>"; }; 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = "<group>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; }; 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; }; 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortFilterView.swift; sourceTree = "<group>"; };
0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; 0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; }; 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; }; 0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = "<group>"; };
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiEditorView.swift; sourceTree = "<group>"; }; 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiEditorView.swift; sourceTree = "<group>"; };
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataClass.swift"; sourceTree = "<group>"; }; 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataClass.swift"; sourceTree = "<group>"; };
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataProperties.swift"; sourceTree = "<group>"; };
@ -179,7 +197,6 @@
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; }; 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; }; 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; };
0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = "<group>"; }; 0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = "<group>"; };
0C42B5952932F2D5008057A0 /* DebridPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridPickerView.swift; sourceTree = "<group>"; };
0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = "<group>"; }; 0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = "<group>"; };
0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; }; 0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; }; 0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
@ -194,10 +211,7 @@
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; }; 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; }; 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; };
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppearHandler.swift; sourceTree = "<group>"; };
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; }; 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = "<group>"; };
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; }; 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; };
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; }; 0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; };
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; }; 0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; };
@ -207,7 +221,8 @@
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; }; 0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = "<group>"; }; 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = "<group>"; };
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>"; };
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.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>"; };
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>"; };
@ -227,7 +242,20 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = "<group>"; }; 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceFilterView.swift; sourceTree = "<group>"; };
0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAFilterView.swift; sourceTree = "<group>"; };
0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedDebridFilterView.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>"; };
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>"; };
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoView.swift; sourceTree = "<group>"; };
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsBaseUrlView.swift; sourceTree = "<group>"; };
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsApiView.swift; sourceTree = "<group>"; };
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsMethodView.swift; sourceTree = "<group>"; };
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDebridLinkView.swift; sourceTree = "<group>"; };
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionPickerView.swift; sourceTree = "<group>"; }; 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>"; };
@ -235,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>"; };
@ -257,28 +284,35 @@
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; }; 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; }; 0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CAF9318296399190050812A /* PremiumizeCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeCloudView.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>"; };
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>"; };
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedSearchable.swift; sourceTree = "<group>"; };
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; }; 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; };
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; }; 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerriteKeychain.swift; sourceTree = "<group>"; };
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoMetaView.swift; sourceTree = "<group>"; };
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoAboutView.swift; sourceTree = "<group>"; };
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; }; 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; }; 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; };
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScopeBar.swift; sourceTree = "<group>"; };
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = "<group>"; }; 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = "<group>"; };
0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; }; 0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; }; 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; };
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; }; 0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = "<group>"; }; 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>"; };
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>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -287,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 */,
0C2DD4CF29A6D47400293FC3 /* SwiftUIX 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 */,
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */, 0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -312,6 +346,7 @@
0C0755C32934244500ECA142 /* ComponentViews */ = { 0C0755C32934244500ECA142 /* ComponentViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C84FCDB29E4B3F400B0DFE4 /* Filters */,
0C3E00D4296F560800ECECB2 /* Plugin */, 0C3E00D4296F560800ECECB2 /* Plugin */,
0C0755C42934245800ECA142 /* Debrid */, 0C0755C42934245800ECA142 /* Debrid */,
0CA3B23528C265FD00616D3A /* Library */, 0CA3B23528C265FD00616D3A /* Library */,
@ -324,7 +359,6 @@
0C0755C42934245800ECA142 /* Debrid */ = { 0C0755C42934245800ECA142 /* Debrid */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C42B5952932F2D5008057A0 /* DebridPickerView.swift */,
0C0755C5293424A200ECA142 /* DebridLabelView.swift */, 0C0755C5293424A200ECA142 /* DebridLabelView.swift */,
); );
path = Debrid; path = Debrid;
@ -357,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>";
@ -368,6 +404,8 @@
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */, 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */, 0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */, 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */,
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */,
0C68135128BC1A7C00FAD890 /* GithubModels.swift */, 0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
0C422E7F293542F300486D65 /* PremiumizeModels.swift */, 0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
0C0167DB29293FA900B65783 /* RealDebridModels.swift */, 0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
@ -376,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>";
@ -383,9 +423,8 @@
0C2886D52960C4F800D6FC16 /* Cloud */ = { 0C2886D52960C4F800D6FC16 /* Cloud */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */, 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
0CAF9318296399190050812A /* PremiumizeCloudView.swift */, 0CB725332C123E760047FC0B /* CloudMagnetView.swift */,
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */,
); );
path = Cloud; path = Cloud;
sourceTree = "<group>"; sourceTree = "<group>";
@ -412,11 +451,13 @@
0C3E00D4296F560800ECECB2 /* Plugin */ = { 0C3E00D4296F560800ECECB2 /* Plugin */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0CD4030829DA01A3008D9F03 /* Info */,
0C44E2AA28D4E09B007711AE /* Buttons */, 0C44E2AA28D4E09B007711AE /* Buttons */,
0C794B65289DAC9F00DD1CC8 /* Source */, 0C794B65289DAC9F00DD1CC8 /* Source */,
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */, 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */,
0C5005512992B6750064606A /* PluginTagsView.swift */, 0C5005512992B6750064606A /* PluginTagsView.swift */,
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */, 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */,
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */,
); );
path = Plugin; path = Plugin;
sourceTree = "<group>"; sourceTree = "<group>";
@ -426,6 +467,9 @@
children = ( children = (
0C44E2A728D4DDDC007711AE /* Application.swift */, 0C44E2A728D4DDDC007711AE /* Application.swift */,
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */, 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */,
0C8AE2472C0FFB6600701675 /* Store.swift */,
0C07C6032C1A859B00808A46 /* FormDataBody.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@ -433,13 +477,9 @@
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 */,
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */,
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */,
); );
path = Modifiers; path = Modifiers;
sourceTree = "<group>"; sourceTree = "<group>";
@ -459,6 +499,7 @@
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */, 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */, 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */,
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */, 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */,
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */,
); );
path = SearchResult; path = SearchResult;
sourceTree = "<group>"; sourceTree = "<group>";
@ -467,6 +508,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0CE1C4172981E8D700418F20 /* Plugin.swift */, 0CE1C4172981E8D700418F20 /* Plugin.swift */,
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */,
); );
path = Protocols; path = Protocols;
sourceTree = "<group>"; sourceTree = "<group>";
@ -475,10 +517,26 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C733286289C4C820058D1FE /* SourceSettingsView.swift */, 0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */,
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */,
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */,
); );
path = Source; path = Source;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
0C84FCDB29E4B3F400B0DFE4 /* Filters */ = {
isa = PBXGroup;
children = (
0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */,
0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */,
0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */,
0C871BDE29994D9D005279AC /* FilterLabelView.swift */,
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */,
0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */,
);
path = Filters;
sourceTree = "<group>";
};
0CA0545C288F7CB200850554 /* Settings */ = { 0CA0545C288F7CB200850554 /* Settings */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -489,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>";
@ -517,14 +576,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0C44E2A928D4DFC4007711AE /* Modifiers */, 0C44E2A928D4DFC4007711AE /* Modifiers */,
0CE66B3928E640D200F69346 /* Backport.swift */,
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */,
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */,
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */, 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
0C871BDE29994D9D005279AC /* FilterLabelView.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 */,
@ -546,6 +599,7 @@
0CD72E16293D9928001A7EA4 /* Array.swift */, 0CD72E16293D9928001A7EA4 /* Array.swift */,
0C445C61293F9A0B0060744D /* Bundle.swift */, 0C445C61293F9A0B0060744D /* Bundle.swift */,
0CA148C9288903F000DE2211 /* Collection.swift */, 0CA148C9288903F000DE2211 /* Collection.swift */,
0C7075E329D374C50093DB2D /* Color.swift */,
0CA148CA288903F000DE2211 /* Data.swift */, 0CA148CA288903F000DE2211 /* Data.swift */,
0CA429F728C5098D000D0610 /* DateFormatter.swift */, 0CA429F728C5098D000D0610 /* DateFormatter.swift */,
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */, 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */,
@ -557,6 +611,7 @@
0C42B5972932F6DD008057A0 /* Set.swift */, 0C42B5972932F6DD008057A0 /* Set.swift */,
0C7C128528DAA3CD00381CD1 /* URL.swift */, 0C7C128528DAA3CD00381CD1 /* URL.swift */,
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */, 0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */,
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -596,7 +651,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0CA148CE288903F000DE2211 /* WebView.swift */, 0CA148CE288903F000DE2211 /* WebView.swift */,
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */, 0C7075E529D3845D0093DB2D /* ShareSheet.swift */,
0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */,
); );
path = RepresentableViews; path = RepresentableViews;
sourceTree = "<group>"; sourceTree = "<group>";
@ -609,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>";
@ -653,6 +711,15 @@
path = DataManagement; path = DataManagement;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
0CD4030829DA01A3008D9F03 /* Info */ = {
isa = PBXGroup;
children = (
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */,
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */,
);
path = Info;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -677,8 +744,8 @@
0C4CFC452897030D00AD9FAD /* Regex */, 0C4CFC452897030D00AD9FAD /* Regex */,
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */, 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
0CDDDE042935235E006810B1 /* BetterSafariView */, 0CDDDE042935235E006810B1 /* BetterSafariView */,
0C448BE829A135F100F4E266 /* Introspect-Static */, 0C748ED929D9256D0049B8BE /* Yams */,
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */, 0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */,
); );
productName = Torrenter; productName = Torrenter;
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */; productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
@ -692,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;
@ -715,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" */,
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */, 0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
); );
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */; productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -768,21 +835,24 @@
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 */,
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */, 0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */, 0CB6516528C5A5D700DCA721 /* InlinedList.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 */,
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 */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */, 0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */, 0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */,
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */, 0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */,
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */, 0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */, 0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
@ -790,40 +860,38 @@
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */, 0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */, 0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */,
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */, 0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */,
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */, 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */, 0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */, 0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */, 0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */,
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */, 0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */, 0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */, 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */, 0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */,
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */, 0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */, 0C7D11FE28AA03FE00ED92DB /* View.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 */,
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */, 0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */,
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */, 0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */, 0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */,
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */, 0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */, 0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */, 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */, 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */,
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */, 0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */, 0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */, 0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */, 0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */, 0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */,
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */, 0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */,
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */, 0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */,
@ -832,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 */,
@ -843,7 +910,6 @@
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */, 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */, 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */,
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */, 0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */,
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */, 0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */,
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */, 0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */,
0CA148E6288903F000DE2211 /* WebView.swift in Sources */, 0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
@ -854,39 +920,57 @@
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */, 0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */,
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */, 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
0C42B5982932F6DD008057A0 /* Set.swift in Sources */, 0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */, 0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */, 0CA05459288EE9E600850554 /* PluginManager.swift in Sources */,
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */, 0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */,
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */, 0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */,
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */, 0C733287289C4C820058D1FE /* SourceSettingsView.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 */,
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 */,
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.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 */,
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */, 0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
0C84FCE329E4B42600B0DFE4 /* IAFilterView.swift in Sources */,
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */,
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */, 0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */, 0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */,
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */, 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */, 0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */,
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */, 0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */, 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */, 0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */,
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 */,
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 */,
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */, 0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */,
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */, 0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */, 0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */, 0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
@ -904,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";
@ -936,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;
@ -950,13 +1036,14 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
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;
}; };
@ -964,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";
@ -996,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;
@ -1004,12 +1093,13 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
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;
@ -1020,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 = 14; 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;
@ -1034,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 = 14.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.6.2; 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;
@ -1055,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 = 14; 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;
@ -1069,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 = 14.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.6.2; 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;
@ -1108,22 +1200,6 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftUIX/SwiftUIX";
requirement = {
branch = master;
kind = branch;
};
};
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect/";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.2.2;
};
};
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = { 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/Regex"; repositoryURL = "https://github.com/sindresorhus/Regex";
@ -1148,6 +1224,14 @@
kind = branch; kind = branch;
}; };
}; };
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jpsim/Yams";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 5.0.5;
};
};
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON";
@ -1156,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";
@ -1175,16 +1267,6 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */ = {
isa = XCSwiftPackageProductDependency;
package = 0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */;
productName = SwiftUIX;
};
0C448BE829A135F100F4E266 /* Introspect-Static */ = {
isa = XCSwiftPackageProductDependency;
package = 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
productName = "Introspect-Static";
};
0C4CFC452897030D00AD9FAD /* Regex */ = { 0C4CFC452897030D00AD9FAD /* Regex */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */; package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */;
@ -1200,11 +1282,21 @@
package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */; package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift; productName = KeychainSwift;
}; };
0C748ED929D9256D0049B8BE /* Yams */ = {
isa = XCSwiftPackageProductDependency;
package = 0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams;
};
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = { 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
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

@ -6,43 +6,89 @@
// //
import Foundation import Foundation
import KeychainSwift
// TODO: Fix errors class AllDebrid: PollingDebridSource, ObservableObject {
public class AllDebrid { let id = "AllDebrid"
let jsonDecoder = JSONDecoder() let abbreviation = "AD"
let keychain = KeychainSwift() let website = "https://alldebrid.com"
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " +
let baseApiUrl = "https://api.alldebrid.com/v4" "You must pay to access this service. \n\n" +
let appName = "Ferrite" "It is not recommended to use this service since media cache checks are not possible via the API. " +
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
let cachedStatus: [String] = ["Ready"]
var authTask: Task<Void, Error>? var authTask: Task<Void, Error>?
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "AllDebrid.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.alldebrid.com/v4"
private let appName = "Ferrite"
private let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth
// Fetches information for PIN auth // Fetches information for PIN auth
public func getPinInfo() async throws -> PinResponse { 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)
do { do {
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
return rawResponse // Validate the URL before doing anything else
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
guard let userUrl = URL(string: rawResponse.userURL) else {
throw DebridError.AuthQuery(description: "The login URL is invalid")
}
// Spawn the polling task separately
authTask = Task {
try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin)
}
return userUrl
} catch { } catch {
print("Couldn't get pin information!") print("Couldn't get pin information!")
throw ADError.AuthQuery(description: error.localizedDescription) throw DebridError.AuthQuery(description: error.localizedDescription)
} }
} }
// 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 {
@ -50,7 +96,7 @@ public class AllDebrid {
while count < 12 { while count < 12 {
if Task.isCancelled { if Task.isCancelled {
throw ADError.AuthQuery(description: "Token request cancelled.") throw DebridError.AuthQuery(description: "Token request cancelled.")
} }
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
@ -60,7 +106,7 @@ public class AllDebrid {
// If there's an API key from the response, end the task successfully // If there's an API key from the response, end the task successfully
if let apiKeyResponse = rawResponse { if let apiKeyResponse = rawResponse {
keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey") FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
return return
} else { } else {
@ -69,7 +115,7 @@ public class AllDebrid {
} }
} }
throw ADError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
} }
if case let .failure(error) = await authTask?.result { if case let .failure(error) = await authTask?.result {
@ -77,15 +123,28 @@ public class AllDebrid {
} }
} }
// Clears tokens. No endpoint to deregister a device // Adds a manual API key instead of web auth
public func deleteTokens() { func setApiKey(_ key: String) {
keychain.delete("AllDebrid.ApiKey") FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
} }
func getToken() -> String? {
FerriteKeychain.shared.get("AllDebrid.ApiKey")
}
// Clears tokens. No endpoint to deregister a device
func logout() {
FerriteKeychain.shared.delete("AllDebrid.ApiKey")
UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey")
}
// MARK: - Common request
// Wrapper request function which matches the responses and returns data // Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = keychain.get("AllDebrid.ApiKey") else { guard let token = getToken() else {
throw ADError.InvalidToken throw DebridError.InvalidToken
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -93,23 +152,22 @@ public class AllDebrid {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else { guard let response = response as? HTTPURLResponse else {
throw ADError.FailedRequest(description: "No HTTP response given") throw DebridError.FailedRequest(description: "No HTTP response given")
} }
if response.statusCode >= 200, response.statusCode <= 299 { if response.statusCode >= 200, response.statusCode <= 299 {
return data return data
} else if response.statusCode == 401 { } else if response.statusCode == 401 {
deleteTokens() throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
throw ADError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
} else { } else {
throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
} }
} }
// 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 ADError.InvalidUrl throw DebridError.InvalidUrl
} }
components.queryItems = [ components.queryItems = [
@ -119,17 +177,104 @@ public class AllDebrid {
if let url = components.url { if let url = components.url {
return url return url
} else { } else {
throw ADError.InvalidUrl throw DebridError.InvalidUrl
}
}
// 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
}
}
// Fetch the user magnets to the latest version
try await getUserMagnets()
for cloudMagnet in cloudMagnets {
if cachedStatus.contains(cloudMagnet.status),
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
{
IAValues.append(
DebridIA(
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: []
)
)
}
}
}
// MARK: - Downloading
// Wrapper function to fetch a download link from the API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
let selectedMagnetId: String
if let existingMagnet = cloudMagnets.first(where: {
$0.hash == magnet.hash && cachedStatus.contains($0.status)
}) {
selectedMagnetId = existingMagnet.id
} else {
let magnetId = try await addMagnet(magnet: magnet)
selectedMagnetId = String(magnetId)
}
let rawResponse = try await fetchMagnetStatus(
magnetId: selectedMagnetId,
selectedIndex: iaFile?.id ?? 0
)
guard let magnets = rawResponse.magnets[safe: 0] else {
throw DebridError.EmptyUserMagnets
}
// Batches require an unrestrict from the user
if magnets.links.count > 1, iaFile == nil {
var copiedIA = ia
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 ADError.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")
@ -144,84 +289,108 @@ public class AllDebrid {
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 ADError.InvalidResponse throw DebridError.InvalidResponse
} }
} }
public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String { func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
let queryItems = [ let queryItems = [
URLQueryItem(name: "id", value: String(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 ADError.EmptyTorrents
}
} }
public func userMagnets() async throws -> [MagnetStatusData] { // Known as unlockLink in AD's API
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status")) func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
if rawResponse.magnets.isEmpty {
throw ADError.EmptyData
} else {
return rawResponse.magnets
}
}
public func deleteMagnet(magnetId: Int) async throws {
let queryItems = [ let queryItems = [
URLQueryItem(name: "id", value: String(magnetId)) URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
] ]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems)) var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function) let data = try await performRequest(request: &request, requestName: "unlockLink")
}
public func unlockLink(lockedLink: String) async throws -> String {
let queryItems = [
URLQueryItem(name: "link", value: lockedLink)
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
return rawResponse.link return rawResponse.link
} }
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { func saveLink(link: String) async throws {
let queryItems = magnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) } let queryItems = [
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems)) URLQueryItem(name: "links[]", value: link)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
// MARK: - Cloud methods
func getUserMagnets() async throws {
var request = try URLRequest(url: 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<InstantAvailabilityResponse>.self, from: data).data let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil } cloudMagnets = rawResponse.magnets.map { magnetResponse in
let availableHashes = filteredMagnets.map { magnetResp in DebridCloudMagnet(
// Force unwrap is OK here since the filter caught any nil values id: String(magnetResponse.id),
let files = magnetResp.files!.enumerated().map { index, magnetFile in fileName: magnetResponse.filename,
IAFile(id: index, fileName: magnetFile.name) status: magnetResponse.status,
} hash: magnetResponse.hash,
links: magnetResponse.links.map(\.link)
return IA(
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
) )
} }
}
return availableHashes func deleteUserMagnet(cloudMagnetId: String?) async throws {
guard let cloudMagnetId else {
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid")
}
let queryItems = [
URLQueryItem(name: "id", value: cloudMagnetId)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
func getUserDownloads() async throws {
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
// The link is also the ID
cloudDownloads = rawResponse.links.map { link in
DebridCloudDownload(
id: link.link, fileName: link.filename, link: link.link
)
}
}
// Not used
func checkUserDownloads(link: String) -> String? {
link
}
// The downloadId is actually the download link
func deleteUserDownload(downloadId: String) async throws {
let queryItems = [
URLQueryItem(name: "link", value: downloadId)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
} }
} }

View file

@ -7,9 +7,9 @@
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/bdashore3/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,8 +17,8 @@ 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/bdashore3/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

@ -6,17 +6,48 @@
// //
import Foundation import Foundation
import KeychainSwift
public class Premiumize { class Premiumize: OAuthDebridSource, ObservableObject {
let jsonDecoder = JSONDecoder() let id = "Premiumize"
let keychain = KeychainSwift() let abbreviation = "PM"
let website = "https://premiumize.me"
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
"You must pay to access the service."
let baseAuthUrl = "https://www.premiumize.me/authorize" @Published var authProcessing: Bool = false
let baseApiUrl = "https://www.premiumize.me/api" var isLoggedIn: Bool {
let clientId = "791565696" getToken() != nil
}
public func buildAuthUrl() throws -> URL { var manualToken: String? {
if UserDefaults.standard.bool(forKey: "Premiumize.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 baseAuthUrl = "https://www.premiumize.me/authorize"
private let baseApiUrl = "https://www.premiumize.me/api"
private let clientId = "791565696"
private let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
}
}
// MARK: - Auth
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),
@ -27,59 +58,185 @@ public class Premiumize {
if let url = urlComponents.url { if let url = urlComponents.url {
return url return url
} else { } else {
throw PMError.InvalidUrl throw DebridError.InvalidUrl
} }
} }
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 {
throw PMError.InvalidResponse throw DebridError.InvalidResponse
} }
var fragmentComponents = URLComponents() var fragmentComponents = URLComponents()
fragmentComponents.query = callbackFragment fragmentComponents.query = callbackFragment
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else { guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
throw PMError.InvalidToken throw DebridError.InvalidToken
} }
keychain.set(accessToken, forKey: "Premiumize.AccessToken") FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken")
}
// Adds a manual API key instead of web auth
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
}
func getToken() -> String? {
FerriteKeychain.shared.get("Premiumize.AccessToken")
} }
// Clears tokens. No endpoint to deregister a device // Clears tokens. No endpoint to deregister a device
public func deleteTokens() { func logout() {
keychain.delete("Premiumize.AccessToken") FerriteKeychain.shared.delete("Premiumize.AccessToken")
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
} }
// MARK: - Common request
// Wrapper request function which matches the responses and returns data // Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = keychain.get("Premiumize.AccessToken") else { guard let token = getToken() else {
throw PMError.InvalidToken throw DebridError.InvalidToken
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") // Use the API query parameter if a manual API key is present
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
guard
let requestUrl = request.url,
var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false)
else {
throw DebridError.InvalidUrl
}
let apiTokenItem = URLQueryItem(name: "apikey", value: token)
if components.queryItems == nil {
components.queryItems = [apiTokenItem]
} else {
components.queryItems?.append(apiTokenItem)
}
request.url = components.url
} else {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else { guard let response = response as? HTTPURLResponse else {
throw PMError.FailedRequest(description: "No HTTP response given") throw DebridError.FailedRequest(description: "No HTTP response given")
} }
if response.statusCode >= 200, response.statusCode <= 299 { if response.statusCode >= 200, response.statusCode <= 299 {
return data return data
} else if response.statusCode == 401 { } else if response.statusCode == 401 {
deleteTokens() throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
} else { } else {
throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") 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
// Remove magnets that don't have an associated link for PM along with existing TTL logic
let sendMagnets = magnets.filter { magnet in
if magnet.link == nil {
return false
}
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
}
let availableMagnets = try await divideCacheRequests(magnets: sendMagnets)
// Split DDL requests into chunks of 10
for chunk in availableMagnets.chunked(into: 10) {
let tempIA = try await divideDDLRequests(magnetChunk: chunk)
IAValues += tempIA
}
}
// Function to divide and execute DDL endpoint requests in parallel
// Calls this for 10 requests at a time to not overwhelm API servers
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
for magnet in magnetChunk {
group.addTask {
try await self.fetchDDL(magnet: magnet)
}
}
var chunkedIA: [DebridIA] = []
for try await ia in group {
chunkedIA.append(ia)
}
return chunkedIA
}
return tempIA
}
// Grabs DDL links
private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
if magnet.hash == nil {
throw DebridError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
let content = rawResponse.content ?? []
if !content.isEmpty {
let files = content.map { file in
DebridIAFile(
id: 0,
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
streamUrlString: file.link
)
}
return DebridIA(
magnet: magnet,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
} else {
throw DebridError.EmptyData
} }
} }
// 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 {
@ -99,11 +256,11 @@ public class Premiumize {
} }
// 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 {
throw PMError.InvalidUrl throw DebridError.InvalidUrl
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
@ -112,7 +269,7 @@ public class Premiumize {
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data) let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
if rawResponse.response.isEmpty { if rawResponse.response.isEmpty {
throw PMError.EmptyData throw DebridError.EmptyData
} else { } else {
let availableMagnets = magnets.enumerated().compactMap { index, magnet in let availableMagnets = magnets.enumerated().compactMap { index, magnet in
if rawResponse.response[safe: index] == true { if rawResponse.response[safe: index] == true {
@ -126,65 +283,32 @@ public class Premiumize {
} }
} }
// Function to divide and execute DDL endpoint requests in parallel // MARK: - Downloading
// Calls this for 10 requests at a time to not overwhelm API servers
public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [IA] {
let tempIA = try await withThrowingTaskGroup(of: Premiumize.IA.self) { group in
for magnet in magnetChunk {
group.addTask {
try await self.fetchDDL(magnet: magnet)
}
}
var chunkedIA: [Premiumize.IA] = [] func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
for try await ia in group { // Store the item in PM cloud for later use
chunkedIA.append(ia) try await createTransfer(magnet: magnet)
}
return chunkedIA
}
return tempIA if let iaFile {
} return (iaFile, nil)
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
// Grabs DDL links return (firstFile, nil)
func fetchDDL(magnet: Magnet) async throws -> IA {
if magnet.hash == nil {
throw PMError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
if !rawResponse.content.isEmpty {
let files = rawResponse.content.map { file in
IAFile(
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
streamUrlString: file.link
)
}
return IA(
magnet: magnet,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
} else { } else {
throw PMError.EmptyData 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 PMError.FailedRequest(description: "The magnet link is invalid") throw DebridError.FailedRequest(description: "The magnet link is invalid")
} }
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
@ -199,24 +323,29 @@ public class Premiumize {
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }
func userItems() async throws -> [UserItem] { // MARK: - Cloud methods
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)
let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data) let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data)
if rawResponse.files.isEmpty { if rawResponse.files.isEmpty {
throw PMError.EmptyData throw DebridError.EmptyData
} }
return rawResponse.files // The "link" is the ID for Premiumize
cloudDownloads = rawResponse.files.map { file in
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 {
throw PMError.InvalidUrl throw DebridError.InvalidUrl
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
@ -227,16 +356,26 @@ public class Premiumize {
return rawResponse return rawResponse
} }
func deleteItem(itemID: String) async throws { func checkUserDownloads(link: String) async throws -> String? {
// Link is the cloud item ID
try await itemDetails(itemID: link).link
}
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")
var bodyComponents = URLComponents() var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "id", value: itemID)] bodyComponents.queryItems = [URLQueryItem(name: "id", value: downloadId)]
request.httpBody = bodyComponents.query?.data(using: .utf8) request.httpBody = bodyComponents.query?.data(using: .utf8)
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)
} }
// No user magnets for Premiumize
func getUserMagnets() {}
func deleteUserMagnet(cloudMagnetId: String?) {}
} }

View file

@ -6,30 +6,69 @@
// //
import Foundation import Foundation
import KeychainSwift
public class RealDebrid { class RealDebrid: PollingDebridSource, ObservableObject {
let jsonDecoder = JSONDecoder() let id = "RealDebrid"
let keychain = KeychainSwift() let abbreviation = "RD"
let website = "https://real-debrid.com"
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
let baseApiUrl = "https://api.real-debrid.com/rest/1.0" "You must pay to access this service. \n\n" +
let openSourceClientId = "X245A4XAIBGVM" "It is not recommended to use this service since media cache checks are not possible via the API. " +
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
let cachedStatus: [String] = ["downloaded"]
var authTask: Task<Void, Error>? var authTask: Task<Void, Error>?
@Published var authProcessing: Bool = false
// Check the manual token since getTokens() is async
var isLoggedIn: Bool {
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
}
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
private let openSourceClientId = "X245A4XAIBGVM"
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
// Fetches the device code from RD // Fetches the device code from RD
public func getVerificationInfo() async throws -> DeviceCodeResponse { 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),
@ -37,23 +76,33 @@ public class RealDebrid {
] ]
guard let url = urlComponents.url else { guard let url = urlComponents.url else {
throw RDError.InvalidUrl throw DebridError.InvalidUrl
} }
let request = URLRequest(url: url) let request = URLRequest(url: url)
do { do {
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
// Validate the URL before doing anything else
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data) let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
return rawResponse guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else {
throw DebridError.AuthQuery(description: "The verification URL is invalid")
}
// Spawn the polling task separately
authTask = Task {
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
}
return directVerificationUrl
} catch { } catch {
print("Couldn't get the new client creds!") print("Couldn't get the new client creds!")
throw RDError.AuthQuery(description: error.localizedDescription) throw DebridError.AuthQuery(description: error.localizedDescription)
} }
} }
// 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),
@ -61,55 +110,49 @@ public class RealDebrid {
] ]
guard let url = urlComponents.url else { guard let url = urlComponents.url else {
throw RDError.InvalidUrl throw DebridError.InvalidUrl
} }
let request = URLRequest(url: url) let request = URLRequest(url: url)
// Timer to poll RD API for credentials // Timer to poll RD API for credentials
authTask = Task { var count = 0
var count = 0
while count < 12 { while count < 12 {
if Task.isCancelled { if Task.isCancelled {
throw RDError.AuthQuery(description: "Token request cancelled.") throw DebridError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
} }
throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.") let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getApiTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
} }
if case let .failure(error) = await authTask?.result { throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
throw error
}
} }
// Fetch all tokens for the user and store in keychain // Fetch all tokens for the user and store in FerriteKeychain.shared
public func getTokens(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 RDError.EmptyData throw DebridError.EmptyData
} }
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else { guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
throw RDError.EmptyData throw DebridError.EmptyData
} }
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!) var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
@ -130,20 +173,20 @@ public class RealDebrid {
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data) let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken") FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn) let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
} }
public func fetchToken() 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 {
do { do {
if let refreshToken = keychain.get("RealDebrid.RefreshToken") { if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
try await getTokens(deviceCode: refreshToken) try await getApiTokens(deviceCode: refreshToken)
} }
} catch { } catch {
print(error) print(error)
@ -151,29 +194,43 @@ public class RealDebrid {
} }
} }
return keychain.get("RealDebrid.AccessToken") return FerriteKeychain.shared.get("RealDebrid.AccessToken")
} }
public func deleteTokens() async throws { // Adds a manual API key instead of web auth
keychain.delete("RealDebrid.RefreshToken") // Clear out existing refresh tokens and timestamps
keychain.delete("RealDebrid.ClientSecret") func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey")
}
// Deletes tokens from device and RD's servers
func logout() async {
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp") await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
// Run the request, doesn't matter if it fails // Run the request, doesn't matter if it fails
if let token = keychain.get("RealDebrid.AccessToken") { if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = try? await URLSession.shared.data(for: request) _ = try? await URLSession.shared.data(for: request)
keychain.delete("RealDebrid.AccessToken") FerriteKeychain.shared.delete("RealDebrid.AccessToken")
await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey")
} }
} }
// MARK: - Common request
// Wrapper request function which matches the responses and returns data // Wrapper request function which matches the responses and returns data
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await fetchToken() else { guard let token = await getToken() else {
throw RDError.InvalidToken throw DebridError.InvalidToken
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -181,99 +238,116 @@ public class RealDebrid {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else { guard let response = response as? HTTPURLResponse else {
throw RDError.FailedRequest(description: "No HTTP response given") throw DebridError.FailedRequest(description: "No HTTP response given")
} }
if response.statusCode >= 200, response.statusCode <= 299 { if response.statusCode >= 200, response.statusCode <= 299 {
return data return data
} else if response.statusCode == 401 { } else if response.statusCode == 401 {
try await deleteTokens() throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
} else { } else {
throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).") throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
} }
} }
// Checks if the magnet is streamable on RD // MARK: - Instant availability
// Currently does not work for batch links
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] {
var availableHashes: [RealDebrid.IA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.compactMap(\.hash).joined(separator: "/"))")!)
let data = try await performRequest(request: &request, requestName: #function) // Post-API changes
// Use user magnets to check for IA instead
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
// Does not account for torrent packs at the moment let sendMagnets = magnets.filter { magnet in
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data) if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
for (hash, response) in rawResponseDict { IAValues.remove(at: IAIndex)
guard let data = response.data else { return true
continue } else {
} return false
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)
} }
// RD files array
// Possibly sort this in the future, but not sure how at the moment
var files: [RealDebrid.IAFile] = []
for index in batches.indices {
let batchFiles = batches[index].files
for batchFileIndex in batchFiles.indices {
let batchFile = batchFiles[batchFileIndex]
if !files.contains(where: { $0.name == batchFile.fileName }) {
files.append(
RealDebrid.IAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
)
)
}
}
}
// TTL: 5 minutes
availableHashes.append(
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches
)
)
} else { } else {
availableHashes.append( return true
RealDebrid.IA( }
magnet: Magnet(hash: hash, link: nil), }
expiryTimeStamp: Date().timeIntervalSince1970 + 300
// Fetch the user magnets to the latest version
try await getUserMagnets()
for cloudMagnet in cloudMagnets {
if cachedStatus.contains(cloudMagnet.status),
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
{
IAValues.append(
DebridIA(
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: []
) )
) )
} }
} }
}
return availableHashes // MARK: - Downloading
// Wrapper function to fetch a download link from the API
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
var selectedMagnetId = ""
do {
// Don't queue a new job if the magnet already exists in the user's library
if let existingCloudMagnet = cloudMagnets.first(where: {
$0.hash == magnet.hash && cachedStatus.contains($0.status)
}) {
selectedMagnetId = existingCloudMagnet.id
} else {
selectedMagnetId = try await addMagnet(magnet: magnet)
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
}
let response = try await torrentInfo(debridID: selectedMagnetId)
let filteredFiles = response.files.filter { $0.selected == 1 }
// Need to return this to the user
if filteredFiles.count > 1, iaFile == nil {
var copiedIA = ia
copiedIA?.files = response.files.enumerated().compactMap { index, file in
DebridIAFile(
id: index,
name: file.path,
streamUrlString: response.links[safe: index]
)
}
return (nil, copiedIA)
}
// RealDebrid has 1 as the first ID for a file
let selectedFileId = iaFile?.id ?? 1
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
throw DebridError.EmptyUserMagnets
}
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
return (restrictedFile, nil)
} catch {
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
}
// Re-raise the error to the calling function
throw error
}
} }
// 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 RDError.FailedRequest(description: "The magnet link is invalid") throw DebridError.FailedRequest(description: "The magnet link is invalid")
} }
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!) var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
@ -292,7 +366,7 @@ public class RealDebrid {
} }
// 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")
@ -312,48 +386,31 @@ public class RealDebrid {
} }
// 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, selectedIndex: 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 the user know if a torrent is downloading // Let the user know if a magnet is downloading
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" { switch rawResponse.status {
return torrentLink case "downloaded":
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" { return rawResponse
throw RDError.EmptyTorrents case "downloading", "queued":
} else { throw DebridError.IsCaching
throw RDError.EmptyData default:
throw DebridError.EmptyUserMagnets
} }
} }
// Gets the user's torrent library
public func userTorrents() async throws -> [UserTorrentsResponse] {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
return rawResponse
}
// Deletes a torrent download from RD
public func deleteTorrent(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
// 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)
@ -363,18 +420,66 @@ public class RealDebrid {
return rawResponse.download return rawResponse.download
} }
// MARK: - Cloud methods
// Gets the user's cloud magnet library
func getUserMagnets() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
cloudMagnets = rawResponse.map { response in
DebridCloudMagnet(
id: response.id,
fileName: response.filename,
status: response.status,
hash: response.hash,
links: [response.id]
)
}
}
// Deletes a magnet download from RD
func deleteUserMagnet(cloudMagnetId: String?) async throws {
let deleteId: String
if let cloudMagnetId {
deleteId = cloudMagnetId
} else {
// Refresh the user magnet list
// The first file is the currently caching one
let _ = try await getUserMagnets()
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
throw DebridError.EmptyUserMagnets
}
deleteId = firstCloudMagnet.id
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
// Gets the user's downloads // Gets the user's downloads
public func userDownloads() async throws -> [UserDownloadsResponse] { 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
return rawResponse DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
}
} }
public func deleteDownload(debridID: String) async throws { // Not used
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!) func checkUserDownloads(link: String) -> String? {
link
}
func deleteUserDownload(downloadId: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
request.httpMethod = "DELETE" request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function) try await performRequest(request: &request, requestName: #function)

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

@ -19,6 +19,8 @@ public extension Action {
@NSManaged var name: String @NSManaged var name: String
@NSManaged var deeplink: String? @NSManaged var deeplink: String?
@NSManaged var version: Int16 @NSManaged var version: Int16
@NSManaged var about: String?
@NSManaged var website: String?
@NSManaged var requires: [String] @NSManaged var requires: [String]
@NSManaged var author: String @NSManaged var author: String
@NSManaged var enabled: Bool @NSManaged var enabled: Bool

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

@ -15,9 +15,10 @@ public extension Source {
} }
@NSManaged var id: UUID @NSManaged var id: UUID
@NSManaged var baseUrl: String? @NSManaged var about: String?
@NSManaged var website: String?
@NSManaged var dynamicWebsite: Bool
@NSManaged var fallbackUrls: [String]? @NSManaged var fallbackUrls: [String]?
@NSManaged var dynamicBaseUrl: Bool
@NSManaged var enabled: Bool @NSManaged var enabled: Bool
@NSManaged var name: String @NSManaged var name: String
@NSManaged var author: String @NSManaged var author: String

View file

@ -15,7 +15,8 @@ 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,6 +1,7 @@
<?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="author" attributeType="String" defaultValueString=""/> <attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="deeplink" optional="YES" attributeType="String"/> <attribute name="deeplink" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -9,6 +10,7 @@
<attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/> <attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/> <relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/>
</entity> </entity>
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES"> <entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
@ -53,9 +55,9 @@
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/> <relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
</entity> </entity>
<entity name="Source" representedClassName="Source" syncable="YES"> <entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/> <attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="baseUrl" optional="YES" attributeType="String"/> <attribute name="dynamicWebsite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="dynamicBaseUrl" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/> <attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
@ -64,6 +66,7 @@
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/> <attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/> <relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/> <relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/> <relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
@ -99,10 +102,11 @@
</entity> </entity>
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES"> <entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
<attribute name="rows" attributeType="String" defaultValueString=""/> <attribute name="rows" attributeType="String" defaultValueString=""/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/> <attribute name="searchUrl" optional="YES" attributeType="String"/>
<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"/>
@ -115,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"/>
@ -131,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"/>
@ -138,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

@ -0,0 +1,35 @@
//
// Color.swift
// Ferrite
//
// Created by Brian Dashore on 3/28/23.
//
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View file

@ -9,6 +9,6 @@ import Foundation
extension OperatingSystemVersion { extension OperatingSystemVersion {
func toString() -> String { func toString() -> String {
return "\(self.majorVersion).\(self.minorVersion).\(self.patchVersion)" "\(majorVersion).\(minorVersion).\(patchVersion)"
} }
} }

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

@ -0,0 +1,15 @@
//
// UIApplication.swift
// Ferrite
//
// Created by Brian Dashore on 3/27/23.
//
import UIKit
extension UIApplication {
// From https://stackoverflow.com/questions/69650504/how-to-get-rid-of-message-windows-was-deprecated-in-ios-15-0-use-uiwindowsc
var currentUIWindow: UIWindow? {
UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow }
}
}

View file

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

View file

@ -5,22 +5,20 @@
// 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
// From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
var result = self
body(&result)
return result
}
// 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))
} }
@ -32,12 +30,4 @@ extension View {
func inlinedList(inset: CGFloat) -> some View { func inlinedList(inset: CGFloat) -> some View {
modifier(InlinedListModifier(inset: inset)) modifier(InlinedListModifier(inset: inset))
} }
func viewDidAppear(_ callback: @escaping () -> Void) -> some View {
modifier(ViewDidAppearModifier(callback: callback))
}
func customScopeBar(_ content: @escaping () -> some View) -> some View {
modifier(CustomScopeBarModifier(scopeBarContent: content()))
}
} }

View file

@ -21,7 +21,7 @@ struct FerriteApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
MainView() MainView()
.backport.onAppear { .onAppear {
scrapingModel.logManager = logManager scrapingModel.logManager = logManager
debridManager.logManager = logManager debridManager.logManager = logManager
pluginManager.logManager = logManager pluginManager.logManager = logManager

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,43 +7,55 @@
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 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 tags: [PluginTagJson]? let listName: String?
let tags: [PluginTagJson]?
public init(name: String, init(name: String,
version: Int16, version: Int16,
minVersion: String?, minVersion: String?,
requires: [ActionRequirement], about: String?,
deeplink: [DeeplinkActionJson]?, website: String?,
author: String?, requires: [ActionRequirement],
listId: UUID?, deeplink: [DeeplinkActionJson]?,
tags: [PluginTagJson]?) author: String?,
listId: UUID?,
listName: String?,
tags: [PluginTagJson]?)
{ {
self.name = name self.name = name
self.version = version self.version = version
self.minVersion = minVersion self.minVersion = minVersion
self.about = about
self.website = website
self.requires = requires self.requires = requires
self.deeplink = deeplink self.deeplink = deeplink
self.author = author self.author = author
self.listId = listId self.listId = listId
self.listName = listName
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)
minVersion = try container.decodeIfPresent(String.self, forKey: .minVersion) minVersion = try container.decodeIfPresent(String.self, forKey: .minVersion)
about = try container.decodeIfPresent(String.self, forKey: .about)
website = try container.decodeIfPresent(String.self, forKey: .website)
requires = try container.decode([ActionRequirement].self, forKey: .requires) requires = try container.decode([ActionRequirement].self, forKey: .requires)
author = try container.decodeIfPresent(String.self, forKey: .author) author = try container.decodeIfPresent(String.self, forKey: .author)
listId = try container.decodeIfPresent(UUID.self, forKey: .listId) listId = nil
listName = nil
tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags) tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags)
if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) { if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) {
@ -56,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
@ -65,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) {
@ -80,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] {
@ -88,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,21 +7,7 @@
import Foundation import Foundation
public extension AllDebrid { extension AllDebrid {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum ADError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
// 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
@ -67,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
@ -85,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) {
@ -117,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
@ -130,6 +116,19 @@ public extension AllDebrid {
let link: String let link: String
} }
// MARK: - SavedLinksResponse
struct SavedLinksResponse: Codable {
let links: [SavedLink]
}
struct SavedLink: Codable, Hashable {
let link: String
let date: Int
let filename: String
let size: Int
}
// MARK: - InstantAvailabilityResponse // MARK: - InstantAvailabilityResponse
struct InstantAvailabilityResponse: Codable { struct InstantAvailabilityResponse: Codable {
@ -138,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]?
@ -146,24 +145,11 @@ 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 {
case name = "n" case name = "n"
} }
} }
// MARK: - InstantAvailablity client side structures
struct IA: Codable, Hashable {
let magnet: Magnet
let expiryTimeStamp: Double
var files: [IAFile]
}
struct IAFile: Codable, Hashable {
let id: Int
let fileName: String
}
} }

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,15 +10,15 @@ import Foundation
// MARK: - Universal IA enum (IA = InstantAvailability) // MARK: - Universal IA enum (IA = InstantAvailability)
public enum IAStatus: Codable, Hashable, Sendable { enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
case full case full = "Cached"
case partial case partial = "Batch"
case none case none = "Uncached"
} }
// 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?
@ -56,11 +56,13 @@ public struct Magnet: Codable, Hashable, Sendable {
self.hash = parseHash(hash) self.hash = parseHash(hash)
self.link = generateLink(hash: hash, title: title, trackers: trackers) self.link = generateLink(hash: hash, title: title, trackers: trackers)
} else if let link, hash == nil { } else if let link, hash == nil {
let (link, hash) = parseLink(link)
self.link = link self.link = link
self.hash = parseHash(extractHash(link: link)) self.hash = hash
} else { } else {
self.hash = parseHash(hash) self.hash = parseHash(hash)
self.link = link self.link = parseLink(link).link
} }
} }
@ -107,4 +109,36 @@ public struct Magnet: Codable, Hashable, Sendable {
return String(magnetHash).lowercased() return String(magnetHash).lowercased()
} }
} }
func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) {
let separator = "magnet:?xt=urn:btih:"
// Remove percent encoding from the link and ensure it's a magnet
guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else {
return (nil, nil)
}
// Isolate the magnet link if it's bundled with another protocol
let isolatedLink: String?
if decodedLink.starts(with: separator) {
isolatedLink = decodedLink
} else {
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

@ -0,0 +1,55 @@
//
// DebridModels.swift
// Ferrite
//
// Created by Brian Dashore on 6/2/24.
//
import Foundation
struct DebridIA: Hashable, Sendable {
let magnet: Magnet
let expiryTimeStamp: Double
var files: [DebridIAFile]
}
struct DebridIAFile: Hashable, Sendable {
let id: Int
let name: String
let streamUrlString: String?
let batchIds: [Int]
init(id: Int, name: String, streamUrlString: String? = nil, batchIds: [Int] = []) {
self.id = id
self.name = name
self.streamUrlString = streamUrlString
self.batchIds = batchIds
}
}
struct DebridCloudDownload: Hashable, Sendable {
let id: String
let fileName: String
let link: String
}
struct DebridCloudMagnet: Hashable, Sendable {
let id: String
let fileName: String
let status: String
let hash: String
let links: [String]
}
enum DebridError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyUserMagnets
case IsCaching
case FailedRequest(description: String)
case AuthQuery(description: String)
case NotImplemented
}

View file

@ -0,0 +1,20 @@
//
// FilterModels.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import Foundation
enum FilterType {
case source
case IA
case sort
}
enum SortFilter: String, Hashable, CaseIterable {
case seeders = "Seeders"
case leechers = "Leechers"
case size = "Size"
}

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
@ -29,6 +29,7 @@ extension PluginManager {
enum PluginManagerError: Error { enum PluginManagerError: Error {
case ListAddition(description: String) case ListAddition(description: String)
case ActionAddition(description: String) case ActionAddition(description: String)
case PluginFetch(description: String)
} }
struct AvailablePlugins { struct AvailablePlugins {

View file

@ -7,21 +7,7 @@
import Foundation import Foundation
public extension Premiumize { extension Premiumize {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum PMError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
// MARK: - CacheCheckResponse // MARK: - CacheCheckResponse
struct CacheCheckResponse: Codable { struct CacheCheckResponse: Codable {
@ -33,8 +19,7 @@ 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
} }
@ -45,27 +30,12 @@ public extension Premiumize {
let path: String let path: String
let size: Int let size: Int
let link: String let link: String
let streamLink: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case path, size, link case path, size, link
case streamLink = "stream_link"
} }
} }
// MARK: - InstantAvailability client side structures
struct IA: Codable, Hashable {
let magnet: Magnet
let expiryTimeStamp: Double
let files: [IAFile]
}
struct IAFile: Codable, Hashable {
let name: String
let streamUrlString: String
}
// MARK: - AllItemsResponse (listall endpoint) // MARK: - AllItemsResponse (listall endpoint)
struct AllItemsResponse: Codable { struct AllItemsResponse: Codable {

View file

@ -8,21 +8,7 @@
import Foundation import Foundation
public extension RealDebrid { extension RealDebrid {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum RDError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
// MARK: - device code endpoint // MARK: - device code endpoint
struct DeviceCodeResponse: Codable, Sendable { struct DeviceCodeResponse: Codable, Sendable {
@ -72,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) {
@ -81,23 +67,16 @@ 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
} }
// MARK: - Instant Availability client side structures // MARK: - Instant Availability batch structures (used for client-side conversion)
struct IA: Codable, Hashable, Sendable {
let magnet: Magnet
let expiryTimeStamp: Double
var files: [IAFile] = []
var batches: [IABatch] = []
}
struct IABatch: Codable, Hashable, Sendable { struct IABatch: Codable, Hashable, Sendable {
let files: [IABatchFile] let files: [IABatchFile]
@ -108,12 +87,6 @@ public extension RealDebrid {
let fileName: String let fileName: String
} }
struct IAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
// MARK: - addMagnet endpoint // MARK: - addMagnet endpoint
struct AddMagnetResponse: Codable, Sendable { struct AddMagnetResponse: Codable, Sendable {
@ -123,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
@ -144,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
@ -163,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,13 +8,47 @@
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?
let magnet: Magnet let magnet: Magnet
let seeders: String? let seeders: String?
let leechers: String? let leechers: String?
// Converts size to a double
func rawSize() -> Double? {
guard let size else {
return nil
}
let splitSize = size.split(separator: " ")
guard
let bytesString = splitSize.first,
let multipliedBytes = Double(bytesString),
let units = splitSize.last
else {
return nil
}
switch units.lowercased() {
case "gb":
return multipliedBytes * 1e9
case "gib":
return multipliedBytes * pow(1024, 3)
case "mb":
return multipliedBytes * 1e6
case "mib":
return multipliedBytes * pow(1024, 2)
case "kb":
return multipliedBytes * 1e3
case "kib":
return multipliedBytes * 1024
default:
return nil
}
}
} }
extension ScrapingViewModel { extension ScrapingViewModel {

View file

@ -7,49 +7,51 @@
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 baseUrl: String? let about: String?
let website: String?
let dynamicWebsite: Bool?
let fallbackUrls: [String]? let fallbackUrls: [String]?
let dynamicBaseUrl: Bool?
let trackers: [String]? let trackers: [String]?
let api: SourceApiJson? let api: SourceApiJson?
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 tags: [PluginTagJson]? let listName: String?
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?
@ -58,55 +60,58 @@ 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 magnetHash: SourceComplexQueryJson? let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson? let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson? let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson? let size: SourceComplexQueryJson?
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 magnetHash: SourceComplexQueryJson? let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson? let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson? let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson? let size: SourceComplexQueryJson?
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 magnet: SourceMagnetJson let magnet: SourceMagnetJson
let subName: SourceComplexQueryJson? let subName: SourceComplexQueryJson?
let title: SourceComplexQueryJson?
let size: SourceComplexQueryJson? let size: SourceComplexQueryJson?
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?
@ -115,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

@ -0,0 +1,83 @@
//
// Debrid.swift
// Ferrite
//
// Created by Brian Dashore on 6/1/24.
//
import Foundation
protocol DebridSource: AnyObservableObject {
// ID of the service
// var id: DebridInfo { get }
var id: String { get }
var abbreviation: String { get }
var website: String { get }
var description: String? { get }
var cachedStatus: [String] { get }
// Auth variables
var authProcessing: Bool { get set }
var isLoggedIn: Bool { get }
// Manual API key
var manualToken: String? { get }
// Instant availability variables
var IAValues: [DebridIA] { get set }
// Cloud variables
var cloudDownloads: [DebridCloudDownload] { get set }
var cloudMagnets: [DebridCloudMagnet] { get set }
var cloudTTL: Double { get set }
// Common authentication functions
func setApiKey(_ key: String)
func logout() async
// Instant availability functions
func instantAvailability(magnets: [Magnet]) async throws
// Fetches a download link from a source
// Include the instant availability information with the args
// Cloud magnets also checked here
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?)
// Unrestricts a locked file
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String
// User downloads functions
func getUserDownloads() async throws
func checkUserDownloads(link: String) async throws -> String?
func deleteUserDownload(downloadId: String) async throws
// User magnet functions
func getUserMagnets() async throws
func deleteUserMagnet(cloudMagnetId: String?) async throws
}
extension DebridSource {
var description: String? {
nil
}
var cachedStatus: [String] {
[]
}
}
protocol PollingDebridSource: DebridSource {
// Task reference for polling
var authTask: Task<Void, Error>? { get set }
// Fetches the Auth URL
func getAuthUrl() async throws -> URL
}
protocol OAuthDebridSource: DebridSource {
// Fetches the auth URL
func getAuthUrl() throws -> URL
// Handles an OAuth callback
func handleAuthCallback(url: URL) throws
}

View file

@ -8,12 +8,14 @@
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 }
var version: Int16 { get set } var version: Int16 { get set }
var author: String { get set } var author: String { get set }
var about: String? { get set }
var website: String? { get set }
var enabled: Bool { get set } var enabled: Bool { get set }
var tags: NSOrderedSet? { get set } var tags: NSOrderedSet? { get set }
func getTags() -> [PluginTagJson] func getTags() -> [PluginTagJson]
@ -25,11 +27,12 @@ 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 }
var listId: UUID? { get } var listId: UUID? { get }
var listName: String? { get }
var tags: [PluginTagJson]? { get } var tags: [PluginTagJson]? { get }
func getTags() -> [PluginTagJson] func getTags() -> [PluginTagJson]
} }

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,13 @@
//
// FerriteKeychain.swift
// Ferrite
//
// Created by Brian Dashore on 4/30/23.
//
import Foundation
import KeychainSwift
class FerriteKeychain {
static let shared = KeychainSwift()
}

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
}
}

147
Ferrite/Utils/Store.swift Normal file
View file

@ -0,0 +1,147 @@
//
// Store.swift
// Ferrite
//
//
// Originally created by William Baker on 09/06/2022.
// https://github.com/Tiny-Home-Consulting/Dependiject/blob/master/Dependiject/Store.swift
// Copyright (c) 2022 Tiny Home Consulting LLC. All rights reserved.
//
// Combined together by Brian Dashore
//
// TODO: Replace with Observable when minVersion >= iOS 17
//
import Combine
import SwiftUI
class ErasedObservableObject: ObservableObject {
let objectWillChange: AnyPublisher<Void, Never>
init(objectWillChange: AnyPublisher<Void, Never>) {
self.objectWillChange = objectWillChange
}
static func empty() -> ErasedObservableObject {
.init(objectWillChange: Empty().eraseToAnyPublisher())
}
}
protocol AnyObservableObject: AnyObject {
var objectWillChange: ObservableObjectPublisher { get }
}
// The generic type names were chosen to match the SwiftUI equivalents:
// - ObjectType from StateObject<ObjectType> and ObservedObject<ObjectType>
// - Subject from ObservedObject.Wrapper.subscript<Subject>(dynamicMember:)
// - S from Publisher.receive<S>(on:options:)
/// A property wrapper used to wrap injected observable objects.
///
/// This is similar to SwiftUI's
/// [`StateObject`](https://developer.apple.com/documentation/swiftui/stateobject), but without
/// compile-time type restrictions. The lack of compile-time restrictions means that `ObjectType`
/// may be a protocol rather than a class.
///
/// - Important: At runtime, the wrapped value must conform to ``AnyObservableObject``.
///
/// To pass properties of the observable object down the view hierarchy as bindings, use the
/// projected value:
/// ```swift
/// struct ExampleView: View {
/// @Store var viewModel = Factory.shared.resolve(ViewModelProtocol.self)
///
/// var body: some View {
/// TextField("username", text: $viewModel.username)
/// }
/// }
/// ```
/// Not all injected objects need this property wrapper. See the example projects for examples each
/// way.
@propertyWrapper
struct Store<ObjectType> {
/// The underlying object being stored.
let wrappedValue: ObjectType
// See https://github.com/Tiny-Home-Consulting/Dependiject/issues/38
fileprivate var _observableObject: ObservedObject<ErasedObservableObject>
@MainActor var observableObject: ErasedObservableObject {
_observableObject.wrappedValue
}
/// A projected value which has the same properties as the wrapped value, but presented as
/// bindings.
///
/// Use this to pass bindings down the view hierarchy:
/// ```swift
/// struct ExampleView: View {
/// @Store var viewModel = Factory.shared.resolve(ViewModelProtocol.self)
///
/// var body: some View {
/// TextField("username", text: $viewModel.username)
/// }
/// }
/// ```
var projectedValue: Wrapper {
Wrapper(self)
}
/// Create a stored value on a custom scheduler.
///
/// Use this init to schedule updates on a specific scheduler other than `DispatchQueue.main`.
init<S: Scheduler>(wrappedValue: ObjectType,
on scheduler: S,
schedulerOptions: S.SchedulerOptions? = nil)
{
self.wrappedValue = wrappedValue
if let observable = wrappedValue as? AnyObservableObject {
let objectWillChange = observable.objectWillChange
.receive(on: scheduler, options: schedulerOptions)
.eraseToAnyPublisher()
_observableObject = .init(initialValue: .init(objectWillChange: objectWillChange))
} else {
assertionFailure(
"Only use the Store property wrapper with objects conforming to AnyObservableObject."
)
_observableObject = .init(initialValue: .empty())
}
}
/// Create a stored value which publishes on the main thread.
///
/// To control when updates are published, see ``init(wrappedValue:on:schedulerOptions:)``.
init(wrappedValue: ObjectType) {
self.init(wrappedValue: wrappedValue, on: DispatchQueue.main)
}
/// An equivalent to SwiftUI's
/// [`ObservedObject.Wrapper`](https://developer.apple.com/documentation/swiftui/observedobject/wrapper)
/// type.
@dynamicMemberLookup
struct Wrapper {
private var store: Store
init(_ store: Store<ObjectType>) {
self.store = store
}
/// Returns a binding to the resulting value of a given key path.
subscript<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>
) -> Binding<Subject> {
Binding {
self.store.wrappedValue[keyPath: keyPath]
} set: {
self.store.wrappedValue[keyPath: keyPath] = $0
}
}
}
}
extension Store: DynamicProperty {
nonisolated mutating func 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)
} }
@ -186,14 +186,7 @@ public class BackupManager: ObservableObject {
PersistenceController.shared.save(backgroundContext) PersistenceController.shared.save(backgroundContext)
// if iOS 14 is available, sleep to prevent any issues with alerts await toggleRestoreCompletedAlert()
if #available(iOS 15, *) {
await toggleRestoreCompletedAlert()
} else {
try? await Task.sleep(seconds: 0.1)
await toggleRestoreCompletedAlert()
}
} catch { } catch {
await logManager?.error( await logManager?.error(
"Backup restore: \(error)", "Backup restore: \(error)",

File diff suppressed because it is too large Load diff

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,
@ -121,7 +121,7 @@ class LoggingManager: ObservableObject {
if let description { if let description {
toastDescription = description toastDescription = description
} else if showErrorToasts { } else if showErrorToasts {
toastDescription = "An error was logged" toastDescription = "An error was logged. Please look at logs in Settings."
} }
} }
@ -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
} }
@ -48,6 +48,36 @@ public class NavigationViewModel: ObservableObject {
@Published var selectedTitle: String = "" @Published var selectedTitle: String = ""
@Published var selectedBatchTitle: String = "" @Published var selectedBatchTitle: String = ""
// For filters
@Published var enabledFilters: Set<FilterType> = []
@Published var currentSortFilter: SortFilter?
@Published var currentSortOrder: SortOrder = .forward
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
switch currentSortFilter {
case .leechers:
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
return false
}
return currentSortOrder == .forward ? lhsLeechers > rhsLeechers : lhsLeechers < rhsLeechers
case .seeders:
guard let lhsSeeders = lhs.seeders, let rhsSeeders = rhs.seeders else {
return false
}
return currentSortOrder == .forward ? lhsSeeders > rhsSeeders : lhsSeeders < rhsSeeders
case .size:
guard let lhsSize = lhs.rawSize(), let rhsSize = rhs.rawSize() else {
return false
}
return currentSortOrder == .forward ? lhsSize > rhsSize : lhsSize < rhsSize
case .none:
return false
}
}
@Published var kodiExpanded: Bool = false @Published var kodiExpanded: Bool = false
@Published var currentChoiceSheet: ChoiceSheetType? @Published var currentChoiceSheet: ChoiceSheetType?
@ -58,15 +88,37 @@ public class NavigationViewModel: ObservableObject {
@Published var selectedTab: ViewTab = .search @Published var selectedTab: ViewTab = .search
// TODO: Maybe move these to their own StateObjects?
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
var selectedSource: Source?
// Used between service views and editor views in Settings // Used between service views and editor views in Settings
@Published var selectedPluginList: PluginList? @Published var selectedPluginList: PluginList?
@Published var selectedKodiServer: KodiServer? @Published var selectedKodiServer: KodiServer?
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks @Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
@Published var pluginPickerSelection: PluginPickerSegment = .sources @Published var pluginPickerSelection: PluginPickerSegment = .sources
@Published var searchPrompt: String = "Search"
@Published var lastSearchPromptIndex: Int = -1
private let searchBarTextArray: [String] = [
"What's on your mind?",
"Discover something interesting",
"Find an engaging show",
"Feeling adventurous?",
"Look for something new",
"The classics are a good idea"
]
func getSearchPrompt() {
if UserDefaults.standard.bool(forKey: "Behavior.UsesRandomSearchText") {
let num = Int.random(in: 0 ..< searchBarTextArray.count - 1)
if num == lastSearchPromptIndex {
lastSearchPromptIndex = num + 1
searchPrompt = searchBarTextArray[safe: num + 1] ?? "Search"
} else {
lastSearchPromptIndex = num
searchPrompt = searchBarTextArray[safe: num] ?? "Search"
}
} else {
lastSearchPromptIndex = -1
searchPrompt = "Search"
}
}
} }

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.
@ -7,14 +7,17 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Yams
public class PluginManager: ObservableObject { class PluginManager: ObservableObject {
var logManager: LoggingManager? var logManager: LoggingManager?
let kodi: Kodi = .init() let kodi: Kodi = .init()
@Published var availableSources: [SourceJson] = [] @Published var availableSources: [SourceJson] = []
@Published var availableActions: [ActionJson] = [] @Published var availableActions: [ActionJson] = []
@Published var filteredInstalledSources: Set<Source> = []
@Published var showActionErrorAlert = false @Published var showActionErrorAlert = false
@Published var actionErrorAlertMessage: String = "" @Published var actionErrorAlertMessage: String = ""
@ -22,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")
@ -94,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] = []
@ -102,7 +105,18 @@ public class PluginManager: ObservableObject {
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let (data, _) = try await URLSession.shared.data(for: request) let (data, _) = try await URLSession.shared.data(for: request)
let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data) let pluginResponse: PluginListJson?
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
pluginResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
} else {
pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
}
guard let pluginResponse else {
throw PluginManagerError.PluginFetch(description: "Could not decode plugin list data")
}
if let sources = pluginResponse.sources { if let sources = pluginResponse.sources {
// Faster and more performant to map instead of a for loop // Faster and more performant to map instead of a for loop
@ -112,9 +126,10 @@ public class PluginManager: ObservableObject {
name: inputJson.name, name: inputJson.name,
version: inputJson.version, version: inputJson.version,
minVersion: inputJson.minVersion, minVersion: inputJson.minVersion,
baseUrl: inputJson.baseUrl, about: inputJson.about,
website: inputJson.website,
dynamicWebsite: inputJson.dynamicWebsite,
fallbackUrls: inputJson.fallbackUrls, fallbackUrls: inputJson.fallbackUrls,
dynamicBaseUrl: inputJson.dynamicBaseUrl,
trackers: inputJson.trackers, trackers: inputJson.trackers,
api: inputJson.api, api: inputJson.api,
jsonParser: inputJson.jsonParser, jsonParser: inputJson.jsonParser,
@ -122,6 +137,7 @@ public class PluginManager: ObservableObject {
htmlParser: inputJson.htmlParser, htmlParser: inputJson.htmlParser,
author: pluginList.author, author: pluginList.author,
listId: pluginList.id, listId: pluginList.id,
listName: pluginList.name,
tags: inputJson.tags tags: inputJson.tags
) )
} else { } else {
@ -141,10 +157,13 @@ public class PluginManager: ObservableObject {
name: inputJson.name, name: inputJson.name,
version: inputJson.version, version: inputJson.version,
minVersion: inputJson.minVersion, minVersion: inputJson.minVersion,
about: inputJson.about,
website: inputJson.website,
requires: inputJson.requires, requires: inputJson.requires,
deeplink: filteredDeeplinks, deeplink: filteredDeeplinks,
author: pluginList.author, author: pluginList.author,
listId: pluginList.id, listId: pluginList.id,
listName: pluginList.name,
tags: inputJson.tags tags: inputJson.tags
) )
} else { } else {
@ -157,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() })
} }
@ -225,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] ?? []
@ -237,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
@ -247,10 +266,12 @@ public class PluginManager: ObservableObject {
} }
// Fetches sources using the background context // Fetches sources using the background context
public func fetchInstalledSources() -> [Source] { func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) { if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
return Array(filteredInstalledSources)
} else if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
return sources.compactMap { $0 } return sources.compactMap { $0 }
} else { } else {
return [] return []
@ -258,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 {
@ -311,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()
@ -334,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()
@ -359,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
@ -395,6 +416,8 @@ public class PluginManager: ObservableObject {
newAction.id = UUID() newAction.id = UUID()
newAction.name = actionJson.name newAction.name = actionJson.name
newAction.version = actionJson.version newAction.version = actionJson.version
newAction.website = actionJson.website
newAction.about = actionJson.about
newAction.author = actionJson.author ?? "Unknown" newAction.author = actionJson.author ?? "Unknown"
newAction.listId = actionJson.listId newAction.listId = actionJson.listId
newAction.requires = actionJson.requires.map(\.rawValue) newAction.requires = actionJson.requires.map(\.rawValue)
@ -425,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
@ -434,9 +457,9 @@ public class PluginManager: ObservableObject {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur // If there's no base URL and it isn't dynamic, return before any transactions occur
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false let dynamicWebsite = sourceJson.dynamicWebsite ?? false
if !dynamicBaseUrl, sourceJson.baseUrl == nil { if !dynamicWebsite, sourceJson.website == nil {
await logManager?.error("Not adding this source because base URL parameters are malformed. Please contact the source dev.") await logManager?.error("Not adding this source because website parameters are malformed. Please contact the source dev.")
return return
} }
@ -458,9 +481,10 @@ public class PluginManager: ObservableObject {
newSource.id = UUID() newSource.id = UUID()
newSource.name = sourceJson.name newSource.name = sourceJson.name
newSource.version = sourceJson.version newSource.version = sourceJson.version
newSource.dynamicBaseUrl = dynamicBaseUrl newSource.about = sourceJson.about
newSource.baseUrl = sourceJson.baseUrl newSource.website = sourceJson.website
newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls newSource.dynamicWebsite = dynamicWebsite
newSource.fallbackUrls = dynamicWebsite ? nil : sourceJson.fallbackUrls
newSource.author = sourceJson.author ?? "Unknown" newSource.author = sourceJson.author ?? "Unknown"
newSource.listId = sourceJson.listId newSource.listId = sourceJson.listId
newSource.trackers = sourceJson.trackers newSource.trackers = sourceJson.trackers
@ -511,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)
@ -546,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)
@ -554,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)
@ -582,14 +614,12 @@ public class PluginManager: ObservableObject {
newSourceJsonParser.subName = newSourceSubName newSourceJsonParser.subName = newSourceSubName
} }
if let titleJson = jsonParserJson.title { let newSourceTitle = SourceTitle(context: backgroundContext)
let newSourceTitle = SourceTitle(context: backgroundContext) newSourceTitle.query = jsonParserJson.title.query
newSourceTitle.query = titleJson.query newSourceTitle.attribute = jsonParserJson.title.attribute ?? "text"
newSourceTitle.attribute = titleJson.attribute ?? "text" newSourceTitle.discriminator = jsonParserJson.title.discriminator
newSourceTitle.discriminator = titleJson.discriminator
newSourceJsonParser.title = newSourceTitle newSourceJsonParser.title = newSourceTitle
}
if let sizeJson = jsonParserJson.size { if let sizeJson = jsonParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext) let newSourceSize = SourceSize(context: backgroundContext)
@ -616,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)
@ -624,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
@ -654,15 +691,13 @@ public class PluginManager: ObservableObject {
newSourceRssParser.subName = newSourceSubName newSourceRssParser.subName = newSourceSubName
} }
if let titleJson = rssParserJson.title { let newSourceTitle = SourceTitle(context: backgroundContext)
let newSourceTitle = SourceTitle(context: backgroundContext) newSourceTitle.query = rssParserJson.title.query
newSourceTitle.query = titleJson.query newSourceTitle.attribute = rssParserJson.title.attribute ?? "text"
newSourceTitle.attribute = titleJson.attribute ?? "text" newSourceTitle.discriminator = rssParserJson.title.discriminator
newSourceTitle.discriminator = titleJson.discriminator newSourceTitle.regex = rssParserJson.title.regex
newSourceTitle.regex = titleJson.regex
newSourceRssParser.title = newSourceTitle newSourceRssParser.title = newSourceTitle
}
if let sizeJson = rssParserJson.size { if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext) let newSourceSize = SourceSize(context: backgroundContext)
@ -690,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)
@ -706,16 +741,24 @@ public class PluginManager: ObservableObject {
newSourceHtmlParser.subName = newSourceSubName newSourceHtmlParser.subName = newSourceSubName
} }
// Adds a title complex query if present if let requestJson = htmlParserJson.request {
if let titleJson = htmlParserJson.title { print(requestJson)
let newSourceTitle = SourceTitle(context: backgroundContext) let newParserRequest = SourceRequest(context: backgroundContext)
newSourceTitle.query = titleJson.query newParserRequest.method = requestJson.method
newSourceTitle.attribute = titleJson.attribute ?? "text" newParserRequest.headers = requestJson.headers
newSourceTitle.regex = titleJson.regex newParserRequest.body = requestJson.body
newSourceHtmlParser.title = newSourceTitle newSourceHtmlParser.request = newParserRequest
} }
// Adds a title complex query
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = htmlParserJson.title.query
newSourceTitle.attribute = htmlParserJson.title.attribute ?? "text"
newSourceTitle.regex = htmlParserJson.title.regex
newSourceHtmlParser.title = newSourceTitle
// Adds a size complex query if present // Adds a size complex query if present
if let sizeJson = htmlParserJson.size { if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext) let newSourceSize = SourceSize(context: backgroundContext)
@ -752,25 +795,41 @@ 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(_ url: 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 url.isEmpty || !url.starts(with: "https://") && !url.starts(with: "http://") { if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.") throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
} }
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: url)!)) guard let url = URL(string: urlString) else {
let rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data) throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
}
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
let rawResponse: PluginListJson?
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
rawResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
} else {
rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
}
guard let rawResponse else {
throw PluginManagerError.ListAddition(description: "Could not decode the plugin list from URL \(urlString)")
}
if let existingPluginList { if let existingPluginList {
existingPluginList.urlString = url existingPluginList.urlString = urlString
existingPluginList.name = rawResponse.name existingPluginList.name = rawResponse.name
existingPluginList.author = rawResponse.author existingPluginList.author = rawResponse.author
try PersistenceController.shared.container.viewContext.save() try PersistenceController.shared.container.viewContext.save()
} else { } else {
let pluginListRequest = PluginList.fetchRequest() let pluginListRequest = PluginList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", url) let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name) let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate]) pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
pluginListRequest.fetchLimit = 1 pluginListRequest.fetchLimit = 1
@ -783,7 +842,7 @@ public class PluginManager: ObservableObject {
let newPluginList = PluginList(context: backgroundContext) let newPluginList = PluginList(context: backgroundContext)
newPluginList.id = UUID() newPluginList.id = UUID()
newPluginList.urlString = url newPluginList.urlString = urlString
newPluginList.name = rawResponse.name newPluginList.name = rawResponse.name
newPluginList.author = rawResponse.author newPluginList.author = rawResponse.author

View file

@ -22,22 +22,23 @@ class ScrapingViewModel: ObservableObject {
runningSearchTask = nil runningSearchTask = nil
} }
var cleanedSearchText: String = ""
@Published var searchResults: [SearchResult] = [] @Published var searchResults: [SearchResult] = []
// 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: ", "))",
@ -46,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: ", "))",
@ -55,19 +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)
} }
@Published var filteredSource: Source?
// 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 {
@ -79,10 +100,13 @@ class ScrapingViewModel: ObservableObject {
return return
} }
cleanedSearchText = searchText.lowercased()
if await !debridManager.enabledDebrids.isEmpty { if await !debridManager.enabledDebrids.isEmpty {
await debridManager.clearIAValues() await debridManager.clearIAValues()
} }
await clearCurrentSourceNames()
await clearSearchResults() await clearSearchResults()
await logManager?.updateIndeterminateToast("Loading sources", cancelAction: { await logManager?.updateIndeterminateToast("Loading sources", cancelAction: {
@ -101,7 +125,7 @@ class ScrapingViewModel: ObservableObject {
if source.enabled { if source.enabled {
group.addTask { group.addTask {
await self.updateCurrentSourceNames(source.name) await self.updateCurrentSourceNames(source.name)
let requestResult = await self.executeParser(source: source, searchText: searchText) let requestResult = await self.executeParser(source: source)
return (requestResult, source.name) return (requestResult, source.name)
} }
@ -142,8 +166,8 @@ class ScrapingViewModel: ObservableObject {
} }
} }
func executeParser(source: Source, searchText: String) async -> SearchRequestResult? { private func executeParser(source: Source) async -> SearchRequestResult? {
guard let baseUrl = source.baseUrl 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)")
return nil return nil
@ -152,50 +176,61 @@ class ScrapingViewModel: ObservableObject {
// Default to HTML scraping // Default to HTML scraping
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { guard let encodedQuery = cleanedSearchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
await sendSourceError("\(source.name): Could not process search query, invalid characters present.") await sendSourceError("\(source.name): Could not process search query, invalid characters present.")
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 let replacedSearchUrl = htmlParser.searchUrl.map {
.replacingOccurrences(of: "{query}", with: encodedQuery) substituteParams($0, with: params)
}
let data = await handleUrls( let data = await handleUrls(
baseUrl: baseUrl, 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,
let html = String(data: data, encoding: .utf8) let html = String(data: data, encoding: .utf8)
{ {
return await scrapeHtml(source: source, baseUrl: baseUrl, html: html) return await scrapeHtml(source: source, website: website, html: html)
} }
} }
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(
baseUrl: baseUrl, website: website,
replacedSearchUrl: replacedSearchUrl, replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls, fallbackUrls: source.fallbackUrls,
sourceName: source.name sourceName: source.name,
requestParams: rssParser.request
) )
} }
@ -207,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
@ -218,7 +252,7 @@ class ScrapingViewModel: ObservableObject {
replacement: "{clientId}", replacement: "{clientId}",
searchUrl: replacedSearchUrl, searchUrl: replacedSearchUrl,
apiUrl: sourceApi.apiUrl, apiUrl: sourceApi.apiUrl,
baseUrl: baseUrl, website: website,
sourceName: source.name) sourceName: source.name)
{ {
replacedSearchUrl = newSearchUrl replacedSearchUrl = newSearchUrl
@ -231,7 +265,7 @@ class ScrapingViewModel: ObservableObject {
replacement: "{secret}", replacement: "{secret}",
searchUrl: replacedSearchUrl, searchUrl: replacedSearchUrl,
apiUrl: sourceApi.apiUrl, apiUrl: sourceApi.apiUrl,
baseUrl: baseUrl, website: website,
sourceName: source.name) sourceName: source.name)
{ {
replacedSearchUrl = newSearchUrl replacedSearchUrl = newSearchUrl
@ -239,12 +273,13 @@ class ScrapingViewModel: ObservableObject {
} }
} }
let passedUrl = source.api?.apiUrl ?? baseUrl let passedUrl = source.api?.apiUrl ?? website
let data = await handleUrls( let data = await handleUrls(
baseUrl: 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 {
@ -259,14 +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(baseUrl: String, replacedSearchUrl: String, fallbackUrls: [String]?, sourceName: String) async -> Data? { private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? {
if let data = await fetchWebsiteData(urlString: baseUrl + replacedSearchUrl, sourceName: sourceName) { let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
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 {
if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl, sourceName: sourceName) { let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
return data return data
} }
} }
@ -275,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?,
baseUrl: 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
@ -292,13 +329,12 @@ 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,
let newValue = await fetchApiCredential( let newValue = await fetchApiCredential(
urlString: (apiUrl ?? baseUrl) + credentialUrl, urlString: (apiUrl ?? website) + credentialUrl,
credential: credential, credential: credential,
sourceName: sourceName sourceName: sourceName
) )
@ -317,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.")
@ -363,14 +399,31 @@ 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) 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!")
return nil return nil
} }
let request = URLRequest(url: url, timeoutInterval: 15) var timeout: Double = 15
let disableRequestTimeout = UserDefaults.standard.bool(forKey: "Behavior.DisableRequestTimeout")
if disableRequestTimeout {
timeout = Double.infinity
} else {
let requestTimeoutSecs = UserDefaults.standard.double(forKey: "Behavior.RequestTimeoutSecs")
if requestTimeoutSecs != 0 {
timeout = requestTimeoutSecs
}
}
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)
@ -393,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
} }
@ -467,11 +520,36 @@ class ScrapingViewModel: ObservableObject {
return SearchRequestResult(results: tempResults, magnets: magnets) return SearchRequestResult(results: tempResults, magnets: magnets)
} }
public func parseJsonResult(_ result: JSON, // TODO: Add regex parsing for API
jsonParser: SourceJsonParser, private func parseJsonResult(_ result: JSON,
source: Source, jsonParser: SourceJsonParser,
existingSearchResult: SearchResult? = nil) -> SearchResult? source: Source,
existingSearchResult: SearchResult? = nil) -> SearchResult?
{ {
// Enforce these parsers
guard let titleParser = jsonParser.title else {
return nil
}
var title: String? = existingSearchResult?.title
if let existingTitle = title,
let discriminatorQuery = titleParser.discriminator
{
let rawDiscriminator = result[discriminatorQuery.components(separatedBy: ".")].rawValue
if !(rawDiscriminator is NSNull) {
title = String(describing: rawDiscriminator) + existingTitle
}
} else if title == nil {
let rawTitle = result[titleParser.query].rawValue
title = rawTitle is NSNull ? nil : String(describing: rawTitle)
}
// Return if a title doesn't exist
if title == nil, jsonParser.subResults == nil, existingSearchResult == nil {
return nil
}
var magnetHash: String? = existingSearchResult?.magnet.hash var magnetHash: String? = existingSearchResult?.magnet.hash
if let magnetHashParser = jsonParser.magnetHash { if let magnetHashParser = jsonParser.magnetHash {
let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue
@ -487,25 +565,9 @@ class ScrapingViewModel: ObservableObject {
link = rawLink is NSNull ? nil : String(describing: rawLink) link = rawLink is NSNull ? nil : String(describing: rawLink)
} }
var title: String? = existingSearchResult?.title // Return if a magnet hash doesn't exist
if let titleParser = jsonParser.title {
if let existingTitle = existingSearchResult?.title,
let discriminatorQuery = titleParser.discriminator
{
let rawDiscriminator = result[discriminatorQuery.components(separatedBy: ".")].rawValue
if !(rawDiscriminator is NSNull) {
title = String(describing: rawDiscriminator) + existingTitle
}
} else if existingSearchResult?.title == nil {
let rawTitle = result[titleParser.query].rawValue
title = rawTitle is NSNull ? nil : String(describing: rawTitle)
}
}
// Return if no magnet hash exists
let magnet = Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers) let magnet = Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers)
if magnet.hash == nil { if magnet.hash == nil, jsonParser.subResults == nil, existingSearchResult == nil {
return nil return nil
} }
@ -553,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
} }
@ -573,6 +635,21 @@ class ScrapingViewModel: ObservableObject {
var magnets: [Magnet] = [] var magnets: [Magnet] = []
for item in items { for item in items {
// Enforce these parsers
guard let titleParser = rssParser.title else {
continue
}
guard let title = try? runRssComplexQuery(
item: item,
query: titleParser.query,
attribute: titleParser.attribute,
discriminator: titleParser.discriminator,
regexString: titleParser.regex
) else {
continue
}
// Parse magnet link or translate hash // Parse magnet link or translate hash
var magnetHash: String? var magnetHash: String?
if let magnetHashParser = rssParser.magnetHash { if let magnetHashParser = rssParser.magnetHash {
@ -596,17 +673,6 @@ class ScrapingViewModel: ObservableObject {
) )
} }
var title: String?
if let titleParser = rssParser.title {
title = try? runRssComplexQuery(
item: item,
query: titleParser.query,
attribute: titleParser.attribute,
discriminator: titleParser.discriminator,
regexString: titleParser.regex
)
}
// Fetches the subName for the source if there is one // Fetches the subName for the source if there is one
var subName: String? var subName: String?
if let subNameParser = rssParser.subName { if let subNameParser = rssParser.subName {
@ -666,7 +732,7 @@ class ScrapingViewModel: ObservableObject {
} }
let result = SearchResult( let result = SearchResult(
title: title ?? "No title", title: title,
source: subName.map { "\(source.name) - \($0)" } ?? source.name, source: subName.map { "\(source.name) - \($0)" } ?? source.name,
size: size ?? "", size: size ?? "",
magnet: magnet, magnet: magnet,
@ -684,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?
@ -708,17 +774,16 @@ class ScrapingViewModel: ObservableObject {
// A capture group must be used in the provided regex // A capture group must be used in the provided regex
if let regexString, if let regexString,
let parsedValue, let parsedValue
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
{ {
return regexValue return runRegex(parsedValue: parsedValue, regexString: regexString)
} else { } else {
return parsedValue return parsedValue
} }
} }
// HTML scraper // HTML scraper
public func scrapeHtml(source: Source, baseUrl: 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
} }
@ -740,18 +805,37 @@ class ScrapingViewModel: ObservableObject {
// If there's an error, continue instead of returning with nothing // If there's an error, continue instead of returning with nothing
for row in rows { for row in rows {
do { do {
// Fetches the magnet link // Enforce these parsers
// If the magnet is located on an external page, fetch the external page and grab the magnet link guard
// External page fetching affects source performance let magnetParser = htmlParser.magnetLink,
guard let magnetParser = htmlParser.magnetLink else { let titleParser = htmlParser.title
else {
continue continue
} }
// Fetches the episode/movie title
// Place here for filtering purposes
guard let title = try? runHtmlComplexQuery(
row: row,
query: titleParser.query,
attribute: titleParser.attribute,
regexString: titleParser.regex
) else {
continue
}
// Fetches the magnet link
// If the magnet is located on an external page, fetch the external page and grab the magnet link
// External page fetching affects source performance
var href: String var href: String
if let externalMagnetQuery = magnetParser.externalLinkQuery, !externalMagnetQuery.isEmpty { if let externalMagnetQuery = magnetParser.externalLinkQuery, !externalMagnetQuery.isEmpty {
guard let externalMagnetUrl = try row.select(externalMagnetQuery).first()?.attr("href") else {
continue
}
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
guard guard
let externalMagnetLink = try row.select(externalMagnetQuery).first()?.attr("href"), let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request),
let data = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink, sourceName: source.name),
let magnetHtml = String(data: data, encoding: .utf8) let magnetHtml = String(data: data, encoding: .utf8)
else { else {
continue continue
@ -786,17 +870,6 @@ class ScrapingViewModel: ObservableObject {
continue continue
} }
// Fetches the episode/movie title
var title: String?
if let titleParser = htmlParser.title {
title = try? runHtmlComplexQuery(
row: row,
query: titleParser.query,
attribute: titleParser.attribute,
regexString: titleParser.regex
)
}
var subName: String? var subName: String?
if let subNameParser = htmlParser.subName { if let subNameParser = htmlParser.subName {
subName = try? runHtmlComplexQuery( subName = try? runHtmlComplexQuery(
@ -847,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,
@ -859,7 +932,7 @@ class ScrapingViewModel: ObservableObject {
} }
let result = SearchResult( let result = SearchResult(
title: title ?? "No title", title: title,
source: subName.map { "\(source.name) - \($0)" } ?? source.name, source: subName.map { "\(source.name) - \($0)" } ?? source.name,
size: size ?? "", size: size ?? "",
magnet: magnet, magnet: magnet,
@ -882,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?
@ -898,18 +971,39 @@ class ScrapingViewModel: ObservableObject {
parsedValue = try result?.attr(attribute) parsedValue = try result?.attr(attribute)
} }
// A capture group must be used in the provided regex if let parsedValue,
if let regexString, let regexString
let parsedValue,
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
{ {
return regexValue return runRegex(parsedValue: parsedValue, regexString: regexString)
} else { } else {
return parsedValue return parsedValue
} }
} }
func parseSizeString(sizeString: String) -> String? { private func runRegex(parsedValue: String, regexString: String) -> String? {
// TODO: Maybe dynamically parse flags
let replacedRegexString = regexString
.replacingOccurrences(of: "{query}", with: cleanedSearchText)
guard
let matchedRegex = try? Regex(
replacedRegexString,
options: [.caseInsensitive, .anchorsMatchLines]
)
.firstMatch(in: parsedValue)
else {
return nil
}
// Is there a capture group present? Otherwise return the original matched string
if let group = matchedRegex.groups[safe: 0] {
return group.value
} else {
return parsedValue
}
}
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
@ -931,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

@ -31,7 +31,7 @@ struct AboutView: View {
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.") Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
.textCase(.none) .textCase(.none)
.foregroundColor(.label) .foregroundColor(.init(uiColor: .label))
.font(.body) .font(.body)
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 20) .padding(.bottom, 20)

View file

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

View file

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

View file

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

View file

@ -20,7 +20,7 @@ struct EmptyInstructionView: View {
.padding(.horizontal, 50) .padding(.horizontal, 50)
} }
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(.secondaryLabel) .foregroundColor(.init(uiColor: .secondaryLabel))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea() .ignoresSafeArea()
} }

View file

@ -1,27 +0,0 @@
//
// FilterLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 2/12/23.
//
import SwiftUI
struct FilterLabelView: View {
var name: String
var body: some View {
HStack(spacing: 4) {
Text(name)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
.font(.caption, weight: .medium)
.background(Capsule().foregroundColor(.secondarySystemFill))
}
}

View file

@ -8,27 +8,55 @@
import SwiftUI import SwiftUI
struct HybridSecureField: View { struct HybridSecureField: View {
enum Field: Hashable {
case plain
case secure
}
@Binding var text: String @Binding var text: String
var onCommit: () -> Void = {}
@State private var showPassword = false @State private var showPassword = false
@FocusState private var focusedField: Field?
private var isFieldDisabled: Bool = false
init(text: Binding<String>, onCommit: (() -> Void)? = nil, showPassword: Bool = false) {
_text = text
if let onCommit {
self.onCommit = onCommit
}
self.showPassword = showPassword
}
var body: some View { var body: some View {
HStack { HStack {
Group { Group {
if showPassword { if showPassword {
TextField("Password", text: $text) TextField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .plain)
} else { } else {
SecureField("Password", text: $text) SecureField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .secure)
} }
} }
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.autocapitalization(.none) .autocapitalization(.none)
.disabledAppearance(isFieldDisabled)
Button { Button {
showPassword.toggle() showPassword.toggle()
focusedField = showPassword ? .plain : .secure
} label: { } label: {
Image(systemName: self.showPassword ? "eye.slash.fill" : "eye.fill") Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.buttonStyle(.borderless)
} }
} }
} }
extension HybridSecureField {
func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
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)
.backport.onAppear { .onAppear {
withAnimation { withAnimation {
self.offset = 1 offset = 1
} }
} }
) )

View file

@ -1,29 +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 if #available(iOS 15, *) {
Text(title)
.listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 0))
} else {
Text(title)
}
}
}

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

@ -1,62 +0,0 @@
//
// SearchAppearance.swift
// Ferrite
//
// Created by Brian Dashore on 2/14/23.
//
import Introspect
import SwiftUI
struct CustomScopeBarModifier<V: View>: ViewModifier {
let scopeBarContent: V
@State private var hostingController: UIHostingController<V>?
func body(content: Content) -> some View {
if #available(iOS 15, *) {
content
.backport.introspectSearchController { searchController in
// MARK: One-time setup
guard hostingController == nil else { return }
searchController.hidesNavigationBarDuringPresentation = true
searchController.searchBar.showsScopeBar = true
searchController.searchBar.scopeButtonTitles = [""]
(searchController.searchBar.value(forKey: "_scopeBar") as? UIView)?.isHidden = true
let hostingController = UIHostingController(rootView: scopeBarContent)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
guard let containerView = searchController.searchBar.value(forKey: "_scopeBarContainerView") as? UIView else {
return
}
containerView.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.widthAnchor.constraint(equalTo: containerView.widthAnchor),
hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
hostingController.view.heightAnchor.constraint(equalTo: containerView.heightAnchor)
])
self.hostingController = hostingController
}
.introspectNavigationController { navigationController in
if #available(iOS 16, *) {
navigationController.viewControllers.first?.navigationItem.preferredSearchBarPlacement = .stacked
}
navigationController.navigationBar.prefersLargeTitles = true
navigationController.navigationBar.sizeToFit()
}
} else {
VStack {
scopeBarContent
content
Spacer()
}
}
}
}

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,17 +0,0 @@
//
// ViewDidAppearModifier.swift
// Ferrite
//
// Created by Brian Dashore on 2/8/23.
//
import SwiftUI
struct ViewDidAppearModifier: ViewModifier {
let callback: () -> Void
func body(content: Content) -> some View {
content
.background(ViewDidAppearHandler(callback: callback))
}
}

View file

@ -1,28 +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 {
if #available(iOS 16, *) {
NavigationStack {
content
}
} else {
NavigationView {
content
}
.navigationViewStyle(.stack)
}
}
}

View file

@ -21,7 +21,7 @@ struct Tag: View {
.padding(.vertical, verticalPadding) .padding(.vertical, verticalPadding)
.background( .background(
RoundedRectangle(cornerRadius: 5) RoundedRectangle(cornerRadius: 5)
.foregroundColor(color.map { $0 } ?? .tertiaryLabel) .foregroundColor(color.map { $0 } ?? .init(uiColor: .tertiaryLabel))
.opacity(0.3) .opacity(0.3)
) )
} }

View file

@ -8,38 +8,34 @@
import SwiftUI import SwiftUI
struct DebridLabelView: View { struct DebridLabelView: View {
@EnvironmentObject var debridManager: DebridManager @Store var debridSource: DebridSource
@State var cloudLinks: [String] = [] @State var cloudLinks: [String] = []
@State var tagColor: Color = .red
var magnet: Magnet? var magnet: Magnet?
var body: some View { var body: some View {
if let selectedDebridType = debridManager.selectedDebridType { Tag(
Tag( name: debridSource.abbreviation,
name: selectedDebridType.toString(abbreviated: true), color: getTagColor(),
color: getTagColor(), horizontalPadding: 5,
horizontalPadding: 5, verticalPadding: 3
verticalPadding: 3 )
)
}
} }
func getTagColor() -> Color { func getTagColor() -> Color {
if let magnet, cloudLinks.isEmpty { if let magnet, cloudLinks.isEmpty {
switch debridManager.matchMagnetHash(magnet) { guard let match = debridSource.IAValues.first(where: { magnet.hash == $0.magnet.hash }) else {
case .full: return .red
return Color.green
case .partial:
return Color.orange
case .none:
return Color.red
} }
return match.files.count > 1 ? .orange : .green
} else if cloudLinks.count == 1 { } else if cloudLinks.count == 1 {
return Color.green return .green
} else if cloudLinks.count > 1 { } else if cloudLinks.count > 1 {
return Color.orange return .orange
} else { } else {
return Color.red return .red
} }
} }
} }

View file

@ -1,32 +0,0 @@
//
// DebridChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 11/26/22.
//
import SwiftUI
struct DebridPickerView<Content: View>: View {
@EnvironmentObject var debridManager: DebridManager
@ViewBuilder var label: Content
var body: some View {
Menu {
Picker("", selection: $debridManager.selectedDebridType) {
Text("None")
.tag(nil as DebridType?)
ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in
if debridManager.enabledDebrids.contains(debridType) {
Text(debridType.toString())
.tag(DebridType?.some(debridType))
}
}
}
} label: {
label
}
}
}

View file

@ -0,0 +1,24 @@
//
// FilterAmountLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 4/11/23.
//
import SwiftUI
struct FilterAmountLabelView: View {
@Environment(\.colorScheme) var colorScheme
var amount: Int
var body: some View {
Text(String(amount))
.padding(5)
.foregroundColor(colorScheme == .light ? .white : .accentColor)
.background {
Circle()
.foregroundColor(colorScheme == .light ? .accentColor : .white)
}
}
}

View file

@ -0,0 +1,44 @@
//
// FilterLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 2/12/23.
//
import SwiftUI
struct FilterLabelView: View {
@Environment(\.colorScheme) var colorScheme
var name: String?
var fallbackName: String
var count: Int?
var body: some View {
HStack(spacing: 4) {
if let count, count > 1 {
FilterAmountLabelView(amount: count)
}
Text(count ?? 1 == 1 ? name ?? fallbackName : fallbackName)
.opacity(count ?? 0 > 0 ? 1 : 0.6)
.foregroundColor(count ?? 0 > 0 && colorScheme == .light ? .accentColor : .primary)
Image(systemName: "chevron.down")
.foregroundColor(count ?? 0 > 0 ? (colorScheme == .light ? .accentColor : .primary) : .init(uiColor: .tertiaryLabel))
}
.padding(.horizontal, 9)
.padding(.vertical, count ?? 1 > 1 ? 2 : 7)
.font(
.caption
.weight(.medium)
)
.background(
Capsule()
.foregroundColor(
count ?? 0 > 0 ? .accentColor : .init(uiColor: .secondarySystemFill)
)
.opacity(count ?? 0 > 0 && colorScheme == .light ? 0.1 : 1)
)
}
}

View file

@ -0,0 +1,67 @@
//
// IAFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import SwiftUI
// TODO: Make this use multiple selections
struct IAFilterView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
var body: some View {
Menu {
Button {
debridManager.filteredIAStatus = []
} label: {
Text("Any")
if debridManager.filteredIAStatus.isEmpty {
Image(systemName: "checkmark")
}
}
ForEach(IAStatus.allCases, id: \.self) { status in
let containsIAStatus = debridManager.filteredIAStatus.contains(status)
Button {
if containsIAStatus {
debridManager.filteredIAStatus.remove(status)
} else {
debridManager.filteredIAStatus.insert(status)
}
} label: {
Text(status.rawValue)
if containsIAStatus {
Image(systemName: "checkmark")
}
}
}
} label: {
FilterLabelView(
name: debridManager.filteredIAStatus.first?.rawValue,
fallbackName: "Cache Status",
count: debridManager.filteredIAStatus.count
)
}
.id(debridManager.filteredIAStatus)
.onChange(of: debridManager.filteredIAStatus) { newSources in
if newSources.isEmpty {
navModel.enabledFilters.remove(.IA)
} else {
navModel.enabledFilters.insert(.IA)
}
}
.onChange(of: navModel.enabledFilters) { newFilters in
if newFilters.isEmpty {
Task {
try? await Task.sleep(seconds: 0.25)
debridManager.filteredIAStatus = []
}
}
}
}
}

View file

@ -0,0 +1,44 @@
//
// SelectedDebridFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import SwiftUI
struct SelectedDebridFilterView<Content: View>: View {
@EnvironmentObject var debridManager: DebridManager
@ViewBuilder var label: Content
var body: some View {
Menu {
Button {
debridManager.selectedDebridSource = nil
} label: {
Text("None")
if debridManager.selectedDebridSource == nil {
Image(systemName: "checkmark")
}
}
ForEach(debridManager.debridSources, id: \.id) { debridSource in
if debridSource.isLoggedIn {
Button {
debridManager.selectedDebridSource = debridSource
} label: {
Text(debridSource.id)
if debridManager.selectedDebridSource?.id == debridSource.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
label
}
}
}

View file

@ -0,0 +1,73 @@
//
// SortFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/14/23.
//
import SwiftUI
struct SortFilterView: View {
@EnvironmentObject var navModel: NavigationViewModel
var body: some View {
Menu {
Button {
navModel.currentSortFilter = nil
navModel.currentSortOrder = .forward
} label: {
HStack {
Text("None")
if navModel.currentSortFilter == nil {
Image(systemName: "checkmark")
}
}
}
ForEach(SortFilter.allCases, id: \.self) { sortFilter in
Button {
navModel.currentSortFilter = sortFilter
navModel.currentSortOrder = navModel.currentSortOrder == .forward ? .reverse : .forward
} label: {
HStack {
Text(sortFilter.rawValue)
if navModel.currentSortFilter == sortFilter {
Image(systemName: navModel.currentSortOrder == .forward ? "chevron.down" : "chevron.up")
}
}
}
}
} label: {
FilterLabelView(
name: "Sort\(navModel.currentSortFilter.map { ": \($0.rawValue)" } ?? "")",
fallbackName: "Sort",
count: navModel.currentSortFilter == nil ? 0 : 1
)
}
.id(navModel.currentSortFilter)
.onChange(of: navModel.currentSortFilter) { newFilter in
navModel.currentSortOrder = .forward
if newFilter == nil {
navModel.enabledFilters.remove(.sort)
} else {
navModel.enabledFilters.insert(.sort)
}
}
.onChange(of: navModel.enabledFilters) { newFilters in
if newFilters.isEmpty {
Task {
try? await Task.sleep(seconds: 0.25)
navModel.currentSortFilter = nil
}
}
}
}
}
struct SortFilterView_Previews: PreviewProvider {
static var previews: some View {
SortFilterView()
}
}

View file

@ -0,0 +1,74 @@
//
// SourceFilterView.swift
// Ferrite
//
// Created by Brian Dashore on 4/10/23.
//
import SwiftUI
// TODO: Make this use multiple selections
struct SourceFilterView: View {
@EnvironmentObject var pluginManager: PluginManager
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Source.entity(),
sortDescriptors: []
) var sources: FetchedResults<Source>
var body: some View {
Menu {
Button {
pluginManager.filteredInstalledSources = []
} label: {
Text("All")
if pluginManager.filteredInstalledSources.isEmpty {
Image(systemName: "checkmark")
}
}
ForEach(sources, id: \.self) { source in
let containsSource = pluginManager.filteredInstalledSources.contains(source)
if source.enabled {
Button {
if containsSource {
pluginManager.filteredInstalledSources.remove(source)
} else {
pluginManager.filteredInstalledSources.insert(source)
}
} label: {
Text(source.name)
if containsSource {
Image(systemName: "checkmark")
}
}
}
}
} label: {
FilterLabelView(
name: pluginManager.filteredInstalledSources.first?.name,
fallbackName: "Source",
count: pluginManager.filteredInstalledSources.count
)
}
.id(pluginManager.filteredInstalledSources)
.onChange(of: pluginManager.filteredInstalledSources) { newSources in
if newSources.isEmpty {
navModel.enabledFilters.remove(.source)
} else {
navModel.enabledFilters.insert(.source)
}
}
.onChange(of: navModel.enabledFilters) { newFilters in
if newFilters.isEmpty {
Task {
try? await Task.sleep(seconds: 0.25)
pluginManager.filteredInstalledSources = []
}
}
}
}
}

View file

@ -8,84 +8,75 @@
import SwiftUI import SwiftUI
struct BookmarksView: View { struct BookmarksView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager @EnvironmentObject var debridManager: DebridManager
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
@Binding var searchText: String @Binding var searchText: String
@Binding var bookmarksEmpty: Bool
@State private var viewTask: Task<Void, Never>? var bookmarks: FetchedResults<Bookmark>
@State private var bookmarkPredicate: NSPredicate?
var body: some View { var body: some View {
DynamicFetchRequest( List {
predicate: bookmarkPredicate, if !bookmarks.isEmpty {
sortDescriptors: [NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)] ForEach(bookmarks, id: \.self) { bookmark in
) { (bookmarks: FetchedResults<Bookmark>) in SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
List { }
if !bookmarks.isEmpty { .onDelete { offsets in
ForEach(bookmarks, id: \.self) { bookmark in for index in offsets {
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark) if let bookmark = bookmarks[safe: index] {
} PersistenceController.shared.delete(bookmark, context: backgroundContext)
.onDelete { offsets in NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
for index in offsets {
if let bookmark = bookmarks[safe: index] {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
}
} }
} }
.onMove { source, destination in
var changedBookmarks = bookmarks.map { $0 }
changedBookmarks.move(fromOffsets: source, toOffset: destination)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
}
PersistenceController.shared.save()
}
} }
} .onMove { source, destination in
.listStyle(.insetGrouped) var changedBookmarks = bookmarks.map { $0 }
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 15 : -25)
.backport.onAppear {
bookmarksEmpty = bookmarks.isEmpty
if debridManager.enabledDebrids.count > 0 { changedBookmarks.move(fromOffsets: source, toOffset: destination)
viewTask = Task {
let magnets = bookmarks.compactMap { for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
if let magnetHash = $0.magnetHash { changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
return Magnet(hash: magnetHash, link: $0.magnetLink)
} else {
return nil
}
}
await debridManager.populateDebridIA(magnets)
} }
PersistenceController.shared.save()
} }
} }
.onDisappear {
viewTask?.cancel()
}
.onChange(of: bookmarks.count) { newCount in
bookmarksEmpty = newCount == 0
}
} }
.backport.onAppear { .onAppear {
applyPredicate() fetchPredicate()
} }
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
applyPredicate() fetchPredicate()
}
.listStyle(.insetGrouped)
.safeAreaInset(edge: .top, spacing: 0) {
Spacer()
.frame(height: 15)
}
.task {
await matchAgainstIA()
}
.refreshable {
await matchAgainstIA()
} }
} }
func applyPredicate() { func fetchPredicate() {
bookmarkPredicate = 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

@ -0,0 +1,60 @@
//
// CloudDownloadView.swift
// Ferrite
//
// Created by Brian Dashore on 6/6/24.
//
import SwiftUI
struct CloudDownloadView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
@Store var debridSource: DebridSource
@Binding var searchText: String
var body: some View {
DisclosureGroup("Downloads") {
ForEach(debridSource.cloudDownloads.filter {
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
}, id: \.self) { cloudDownload in
Button(cloudDownload.fileName) {
navModel.resultFromCloud = true
navModel.selectedTitle = cloudDownload.fileName
var historyEntry = HistoryEntryJson(
name: cloudDownload.fileName,
source: debridSource.id
)
debridManager.currentDebridTask = Task {
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link)
if !debridManager.downloadUrl.isEmpty {
historyEntry.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyEntry, performSave: true)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
}
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let cloudDownload = debridSource.cloudDownloads[safe: index] {
Task {
await debridManager.deleteCloudDownload(cloudDownload)
}
}
}
}
}
}
}

View file

@ -1,84 +1,93 @@
// //
// AllDebridCloudView.swift // CloudMagnetView.swift
// Ferrite // Ferrite
// //
// Created by Brian Dashore on 1/5/23. // Created by Brian Dashore on 6/6/24.
// //
import SwiftUI import SwiftUI
struct AllDebridCloudView: View { struct CloudMagnetView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager @EnvironmentObject var pluginManager: PluginManager
@Store var debridSource: DebridSource
@Binding var searchText: String @Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
var body: some View { var body: some View {
DisclosureGroup("Magnets") { DisclosureGroup("Magnets") {
ForEach(debridManager.allDebridCloudMagnets.filter { ForEach(debridSource.cloudMagnets.filter {
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
}, id: \.id) { magnet in }, id: \.self) { cloudMagnet in
Button { Button {
if magnet.status == "Ready", !magnet.links.isEmpty { if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty {
navModel.resultFromCloud = true navModel.resultFromCloud = true
navModel.selectedTitle = magnet.filename navModel.selectedTitle = cloudMagnet.fileName
var historyInfo = HistoryEntryJson( var historyInfo = HistoryEntryJson(
name: magnet.filename, name: cloudMagnet.fileName,
source: DebridType.allDebrid.toString() source: debridSource.id
) )
Task { Task {
if magnet.links.count == 1 { let magnet = Magnet(hash: cloudMagnet.hash, link: nil)
if let lockedLink = magnet.links[safe: 0]?.link { await debridManager.populateDebridIA([magnet])
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: lockedLink) if debridManager.selectDebridResult(magnet: magnet) {
// Is this a batch?
if cloudMagnet.links.count == 1 {
await debridManager.fetchDebridDownload(magnet: magnet)
// Bump to batch
if debridManager.requiresUnrestrict {
navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch
return
}
if !debridManager.downloadUrl.isEmpty { if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo, performSave: true) PersistenceController.shared.createHistory(historyInfo, performSave: true)
pluginManager.runDefaultAction( pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl, urlString: debridManager.downloadUrl,
navModel: navModel navModel: navModel
) )
} }
} } else {
} else { navModel.selectedMagnet = magnet
let magnet = Magnet(hash: magnet.hash, link: nil)
// Do not clear old IA values
await debridManager.populateDebridIA([magnet])
if debridManager.selectDebridResult(magnet: magnet) {
navModel.selectedHistoryInfo = historyInfo navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch navModel.currentChoiceSheet = .batch
} }
} }
} }
} }
} label: { } label: {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(magnet.filename) Text(cloudMagnet.fileName)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(4)
HStack { HStack {
Text(magnet.status) Text(cloudMagnet.status.capitalizingFirstLetter())
Spacer() Spacer()
DebridLabelView(cloudLinks: magnet.links.map(\.link)) DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links)
} }
.font(.caption) .font(.caption)
} }
} }
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.backport.tint(.black) .tint(.primary)
} }
.onDelete { offsets in .onDelete { offsets in
for index in offsets { for index in offsets {
if let magnet = debridManager.allDebridCloudMagnets[safe: index] { if let cloudMagnet = debridSource.cloudMagnets[safe: index] {
Task { Task {
await debridManager.deleteAdMagnet(magnetId: magnet.id) await debridManager.deleteUserMagnet(cloudMagnet)
} }
} }
} }

View file

@ -1,63 +0,0 @@
//
// PremiumizeCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 1/2/23.
//
import SwiftUI
import SwiftUIX
struct PremiumizeCloudView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var pluginManager: PluginManager
@Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
var body: some View {
DisclosureGroup("Items") {
ForEach(debridManager.premiumizeCloudItems.filter {
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
}, id: \.id) { item in
Button(item.name) {
Task {
navModel.resultFromCloud = true
navModel.selectedTitle = item.name
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: item.id)
if !debridManager.downloadUrl.isEmpty {
PersistenceController.shared.createHistory(
HistoryEntryJson(
name: item.name,
url: debridManager.downloadUrl,
source: DebridType.premiumize.toString()
),
performSave: true
)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
}
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.backport.tint(.black)
}
.onDelete { offsets in
for index in offsets {
if let item = debridManager.premiumizeCloudItems[safe: index] {
Task {
await debridManager.deletePmItem(id: item.id)
}
}
}
}
}
}
}

View file

@ -1,128 +0,0 @@
//
// RealDebridCloudView.swift
// Ferrite
//
// Created by Brian Dashore on 12/31/22.
//
import SwiftUI
struct RealDebridCloudView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var pluginManager: PluginManager
@Binding var searchText: String
@State private var viewTask: Task<Void, Never>?
var body: some View {
Group {
DisclosureGroup("Downloads") {
ForEach(debridManager.realDebridCloudDownloads.filter {
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
}, id: \.self) { downloadResponse in
Button(downloadResponse.filename) {
navModel.resultFromCloud = true
navModel.selectedTitle = downloadResponse.filename
debridManager.downloadUrl = downloadResponse.download
PersistenceController.shared.createHistory(
HistoryEntryJson(
name: downloadResponse.filename,
url: downloadResponse.download,
source: DebridType.realDebrid.toString()
),
performSave: true
)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
}
.backport.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let downloadResponse = debridManager.realDebridCloudDownloads[safe: index] {
Task {
await debridManager.deleteRdDownload(downloadID: downloadResponse.id)
}
}
}
}
}
DisclosureGroup("Torrents") {
ForEach(debridManager.realDebridCloudTorrents.filter {
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
}, id: \.self) { torrentResponse in
Button {
if torrentResponse.status == "downloaded", !torrentResponse.links.isEmpty {
navModel.resultFromCloud = true
navModel.selectedTitle = torrentResponse.filename
var historyInfo = HistoryEntryJson(
name: torrentResponse.filename,
source: DebridType.realDebrid.toString()
)
Task {
if torrentResponse.links.count == 1 {
if let torrentLink = torrentResponse.links[safe: 0] {
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink)
if !debridManager.downloadUrl.isEmpty {
historyInfo.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyInfo, performSave: true)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
navModel: navModel
)
}
}
} else {
let magnet = Magnet(hash: torrentResponse.hash, link: nil)
// Do not clear old IA values
await debridManager.populateDebridIA([magnet])
if debridManager.selectDebridResult(magnet: magnet) {
navModel.selectedHistoryInfo = historyInfo
navModel.currentChoiceSheet = .batch
}
}
}
}
} label: {
VStack(alignment: .leading, spacing: 10) {
Text(torrentResponse.filename)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(4)
HStack {
Text(torrentResponse.status.capitalizingFirstLetter())
Spacer()
DebridLabelView(cloudLinks: torrentResponse.links)
}
.font(.caption)
}
}
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
.backport.tint(.primary)
}
.onDelete { offsets in
for index in offsets {
if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] {
Task {
await debridManager.deleteRdTorrent(torrentID: torrentResponse.id)
}
}
}
}
}
}
}
}

View file

@ -10,37 +10,25 @@ import SwiftUI
struct DebridCloudView: View { struct DebridCloudView: View {
@EnvironmentObject var debridManager: DebridManager @EnvironmentObject var debridManager: DebridManager
@Binding var searchText: String @Store var debridSource: DebridSource
@State private var viewTask: Task<Void, Never>? @Binding var searchText: String
var body: some View { var body: some View {
List { List {
switch debridManager.selectedDebridType { CloudDownloadView(debridSource: debridSource, searchText: $searchText)
case .realDebrid: CloudMagnetView(debridSource: debridSource, searchText: $searchText)
RealDebridCloudView(searchText: $searchText)
case .premiumize:
PremiumizeCloudView(searchText: $searchText)
case .allDebrid:
AllDebridCloudView(searchText: $searchText)
case .none:
EmptyView()
}
} }
.listStyle(.plain) .listStyle(.plain)
.backport.onAppear { .task {
viewTask = Task { await debridManager.fetchDebridCloud()
await debridManager.fetchDebridCloud()
}
} }
.onDisappear { .refreshable {
viewTask?.cancel() await debridManager.fetchDebridCloud(bypassTTL: true)
} }
.onChange(of: debridManager.selectedDebridType) { newType in .onChange(of: debridManager.selectedDebridSource?.id) { newType in
viewTask?.cancel()
if newType != nil { if newType != nil {
viewTask = Task { Task {
await debridManager.fetchDebridCloud() await debridManager.fetchDebridCloud()
} }
} }

View file

@ -16,26 +16,27 @@ struct HistoryActionsView: View {
Button("Clear") { Button("Clear") {
showActionSheet.toggle() showActionSheet.toggle()
} }
.backport.tint(.red) .tint(.red)
.backport.confirmationDialog( .confirmationDialog(
"Clear watch history",
isPresented: $showActionSheet, isPresented: $showActionSheet,
title: "Clear watch history", titleVisibility: .visible
message: "This is an irreversible action!", ) {
buttons: [ Button("Past day", role: .destructive) {
AlertButton("Past day", role: .destructive) { deleteHistory(.day)
deleteHistory(.day) }
}, Button("Past week", role: .destructive) {
AlertButton("Past week", role: .destructive) { deleteHistory(.week)
deleteHistory(.week) }
}, Button("Past month", role: .destructive) {
AlertButton("Past month", role: .destructive) { deleteHistory(.month)
deleteHistory(.month) }
}, Button("All time", role: .destructive) {
AlertButton("All time", role: .destructive) { deleteHistory(.allTime)
deleteHistory(.allTime) }
} } message: {
] Text("This is an irreversible action!")
) }
} }
func deleteHistory(_ deleteRange: HistoryDeleteRange) { func deleteHistory(_ deleteRange: HistoryDeleteRange) {

View file

@ -84,7 +84,7 @@ struct HistoryButtonView: View {
} }
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
} }
.backport.tint(.primary) .tint(.primary)
.disableInteraction(navModel.currentChoiceSheet != nil) .disableInteraction(navModel.currentChoiceSheet != nil)
} }

View file

@ -17,29 +17,24 @@ struct HistoryView: View {
] ]
) var history: FetchedResults<History> ) var history: FetchedResults<History>
var allHistoryEntries: FetchedResults<HistoryEntry>
@Binding var searchText: String @Binding var searchText: String
@Binding var historyEmpty: Bool
@State private var historyPredicate: NSPredicate? @State private var historyPredicate: NSPredicate?
var body: some View { var body: some View {
DynamicFetchRequest(predicate: historyPredicate) { (allEntries: FetchedResults<HistoryEntry>) in List {
List { if !history.isEmpty {
if !history.isEmpty { ForEach(groupedHistory(history), id: \.self) { historyGroup in
ForEach(groupedHistory(history), id: \.self) { historyGroup in HistorySectionView(allEntries: allHistoryEntries, historyGroup: historyGroup)
HistorySectionView(allEntries: allEntries, historyGroup: historyGroup)
}
} }
} }
.listStyle(.insetGrouped)
} }
.backport.onAppear { .listStyle(.insetGrouped)
historyEmpty = history.isEmpty .onAppear {
applyPredicate() applyPredicate()
} }
.onChange(of: history.count) { newCount in
historyEmpty = newCount == 0
}
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
applyPredicate() applyPredicate()
} }
@ -47,11 +42,11 @@ struct HistoryView: View {
func applyPredicate() { func applyPredicate() {
if searchText.isEmpty { if searchText.isEmpty {
historyPredicate = nil allHistoryEntries.nsPredicate = nil
} else { } else {
let namePredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased()) let namePredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased())
let subNamePredicate = NSPredicate(format: "subName CONTAINS[cd] %@", searchText.lowercased()) let subNamePredicate = NSPredicate(format: "subName CONTAINS[cd] %@", searchText.lowercased())
historyPredicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, subNamePredicate]) allHistoryEntries.nsPredicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, subNamePredicate])
} }
} }
@ -81,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

@ -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.
@ -10,10 +10,11 @@ import SwiftUI
struct InstalledPluginButtonView<P: Plugin>: View { struct InstalledPluginButtonView<P: Plugin>: View {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@ObservedObject var installedPlugin: P @ObservedObject var installedPlugin: P
@Binding var showPluginOptions: Bool
@Binding var selectedPlugin: P?
var body: some View { var body: some View {
Toggle(isOn: Binding<Bool>( Toggle(isOn: Binding<Bool>(
get: { installedPlugin.enabled }, get: { installedPlugin.enabled },
@ -24,7 +25,7 @@ struct InstalledPluginButtonView<P: Plugin>: View {
)) { )) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack { HStack(spacing: 5) {
Text(installedPlugin.name) Text(installedPlugin.name)
Text("v\(installedPlugin.version)") Text("v\(installedPlugin.version)")
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -32,39 +33,30 @@ struct InstalledPluginButtonView<P: Plugin>: View {
Text("by \(installedPlugin.author)") Text("by \(installedPlugin.author)")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1)
} }
if let tags = installedPlugin.getTags(), !tags.isEmpty { let tags = installedPlugin.getTags()
if !tags.isEmpty {
PluginTagsView(tags: tags) PluginTagsView(tags: tags)
} }
} }
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.contextMenu { .contextMenu {
if let installedSource = installedPlugin as? Source { Button {
Button { selectedPlugin = installedPlugin
navModel.selectedSource = installedSource showPluginOptions.toggle()
navModel.showSourceSettings.toggle() } label: {
} label: { Text("Options")
Text("Settings") Image(systemName: "gear")
Image(systemName: "gear")
}
} }
if #available(iOS 15.0, *) { Button(role: .destructive) {
Button(role: .destructive) { PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
PersistenceController.shared.delete(installedPlugin, context: backgroundContext) } label: {
} label: { Text("Remove")
Text("Remove") Image(systemName: "trash")
Image(systemName: "trash")
}
} else {
Button {
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
} label: {
Text("Remove")
Image(systemName: "trash")
}
} }
} }
} }

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.
@ -8,44 +8,62 @@
import SwiftUI import SwiftUI
struct PluginCatalogButtonView<PJ: PluginJson>: View { struct PluginCatalogButtonView<PJ: PluginJson>: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var pluginManager: PluginManager @EnvironmentObject var pluginManager: PluginManager
let availablePlugin: PJ let availablePlugin: PJ
let doUpsert: Bool let needsUpdate: Bool
var body: some View { var body: some View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack { HStack(spacing: 5) {
Text(availablePlugin.name) Text(availablePlugin.name)
Text("v\(availablePlugin.version)") Text("v\(availablePlugin.version)")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Text("by \(availablePlugin.author ?? "No author")") Group {
.foregroundColor(.secondary) Text("by \(availablePlugin.author ?? "No author")")
Text(availablePlugin.listName.map { "from \($0)" } ?? "an unknown list")
.font(.caption)
}
.foregroundColor(.secondary)
.lineLimit(1)
} }
if let tags = availablePlugin.getTags(), !tags.isEmpty { let tags = availablePlugin.getTags()
if !tags.isEmpty {
PluginTagsView(tags: tags) PluginTagsView(tags: tags)
} }
} }
Spacer() Spacer()
Button("Install") { Button(needsUpdate ? "UPDATE" : "INSTALL") {
Task { Task {
if let availableSource = availablePlugin as? SourceJson { if let availableSource = availablePlugin as? SourceJson {
await pluginManager.installSource(sourceJson: availableSource, doUpsert: doUpsert) await pluginManager.installSource(sourceJson: availableSource, doUpsert: needsUpdate)
} else if let availableAction = availablePlugin as? ActionJson { } else if let availableAction = availablePlugin as? ActionJson {
await pluginManager.installAction(actionJson: availableAction, doUpsert: doUpsert) await pluginManager.installAction(actionJson: availableAction, doUpsert: needsUpdate)
} else { } else {
return return
} }
} }
} }
.font(
.footnote
.weight(.bold)
)
.padding(.horizontal, 7)
.padding(.vertical, 5)
.background(colorScheme == .light ? Color(uiColor: .secondarySystemBackground) : Color(uiColor: .tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.borderless)
.padding(.vertical, 2) .padding(.vertical, 2)
} }
} }

View file

@ -0,0 +1,30 @@
//
// PluginInfoAboutView.swift
// Ferrite
//
// Created by Brian Dashore on 4/2/23.
//
import SwiftUI
struct PluginInfoAboutView<P: Plugin>: View {
@ObservedObject var selectedPlugin: P
var body: some View {
Section("Description") {
VStack(alignment: .leading, spacing: 10) {
if let pluginAbout = selectedPlugin.about {
if pluginAbout.last == "\n" {
Text(pluginAbout.dropLast())
} else {
Text(pluginAbout)
}
}
if let pluginWebsite = selectedPlugin.website {
Link("Website", destination: URL(string: pluginWebsite) ?? URL(string: "https://kingbri.dev/ferrite")!)
}
}
}
}
}

View file

@ -0,0 +1,54 @@
//
// PluginInfoMetaView.swift
// Ferrite
//
// Created by Brian Dashore on 4/2/23.
//
import SwiftUI
struct PluginInfoMetaView<P: Plugin>: View {
@ObservedObject var selectedPlugin: P
@FetchRequest(
entity: PluginList.entity(),
sortDescriptors: []
) var pluginLists: FetchedResults<PluginList>
var body: some View {
Section("Metadata") {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
Text(selectedPlugin.name)
Text("v\(selectedPlugin.version)")
.foregroundColor(.secondary)
}
Text("by \(selectedPlugin.author)")
.foregroundColor(.secondary)
Group {
Text("ID: \(selectedPlugin.id)")
if let pluginList = pluginLists.first(where: { $0.id == selectedPlugin.listId }) {
Text("List: \(pluginList.name)")
Text("List ID: \(pluginList.id.uuidString)")
} else {
Text("No plugin list found. This source should be removed.")
}
}
.foregroundColor(.secondary)
.font(.caption)
}
let tags = selectedPlugin.getTags()
if !tags.isEmpty {
PluginTagsView(tags: tags)
}
}
.padding(.vertical, 2)
}
}
}

View file

@ -12,75 +12,82 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
let backgroundContext = PersistenceController.shared.backgroundContext let backgroundContext = PersistenceController.shared.backgroundContext
@FetchRequest(
entity: PluginList.entity(),
sortDescriptors: []
) var pluginLists: FetchedResults<PluginList>
var installedPlugins: FetchedResults<P>
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@Binding var searchText: String @Binding var searchText: String
@Binding var pluginsEmpty: Bool
@State private var isEditingSearch = false @State private var isEditingSearch = false
@State private var isSearching = false @State private var isSearching = false
@State private var sourcePredicate: NSPredicate? @State private var sourcePredicate: NSPredicate?
@State private var showPluginOptions = false
@State private var selectedPlugin: P?
var body: some View { var body: some View {
DynamicFetchRequest(predicate: sourcePredicate) { (installedPlugins: FetchedResults<P>) in List {
List { let filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins(
if forType: PJ.self,
let filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins( installedPlugins: installedPlugins,
forType: PJ.self, searchText: searchText
installedPlugins: installedPlugins, )
searchText: searchText if !filteredUpdatedPlugins.isEmpty {
), Section("Updates") {
!filteredUpdatedPlugins.isEmpty ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
{ PluginCatalogButtonView(availablePlugin: updatedPlugin, needsUpdate: true)
Section(header: InlineHeader("Updates")) {
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
PluginCatalogButtonView(availablePlugin: updatedPlugin, doUpsert: true)
}
} }
} }
}
if !installedPlugins.isEmpty { if !installedPlugins.isEmpty {
Section(header: InlineHeader("Installed")) { Section("Installed") {
ForEach(installedPlugins, id: \.self) { source in ForEach(installedPlugins, id: \.self) { installedPlugin in
InstalledPluginButtonView(installedPlugin: source) InstalledPluginButtonView(
} installedPlugin: installedPlugin,
showPluginOptions: $showPluginOptions,
selectedPlugin: $selectedPlugin
)
} }
} }
}
if let filteredAvailablePlugins = pluginManager.fetchFilteredPlugins(
let filteredAvailablePlugins = pluginManager.fetchFilteredPlugins( forType: PJ.self,
forType: PJ.self, installedPlugins: installedPlugins,
installedPlugins: installedPlugins, searchText: searchText
searchText: searchText )
), if !filteredAvailablePlugins.isEmpty {
!filteredAvailablePlugins.isEmpty Section("Catalog") {
{ ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
Section(header: InlineHeader("Catalog")) { PluginCatalogButtonView(availablePlugin: availablePlugin, needsUpdate: false)
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
PluginCatalogButtonView(availablePlugin: availablePlugin, doUpsert: false)
}
} }
} }
} }
.inlinedList(inset: 0) }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.sheet(isPresented: $navModel.showSourceSettings) { .onAppear {
if String(describing: P.self) == "Source" { fetchPredicate()
SourceSettingsView() }
.environmentObject(navModel) .onChange(of: searchText) { _ in
} fetchPredicate()
} }
.backport.onAppear { // Alternatively, place the sheet in the parent view
pluginsEmpty = installedPlugins.isEmpty .refreshable {
} await pluginManager.fetchPluginsFromUrl()
.onChange(of: searchText) { _ in }
sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText) .sheet(isPresented: $showPluginOptions) {
} PluginInfoView(selectedPlugin: $selectedPlugin)
.onChange(of: installedPlugins.count) { newCount in
pluginsEmpty = newCount == 0
}
.id(UUID())
} }
} }
func fetchPredicate() {
installedPlugins.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
}
} }

View file

@ -0,0 +1,44 @@
//
// PluginInfoView.swift
// Ferrite
//
// Created by Brian Dashore on 3/24/23.
//
import SwiftUI
struct PluginInfoView<P: Plugin>: View {
@Environment(\.dismiss) var dismiss
@Binding var selectedPlugin: P?
var body: some View {
NavigationStack {
List {
if let selectedPlugin {
PluginInfoMetaView(selectedPlugin: selectedPlugin)
if selectedPlugin.about != nil || selectedPlugin.website != nil {
PluginInfoAboutView(selectedPlugin: selectedPlugin)
}
if let selectedSource = selectedPlugin as? Source {
SourceSettingsView(selectedSource: selectedSource)
}
}
}
.listStyle(.insetGrouped)
.onDisappear {
PersistenceController.shared.save()
}
.navigationTitle("Plugin Options")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}

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.
@ -14,7 +14,7 @@ struct PluginTagsView: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack { HStack {
ForEach(tags, id: \.self) { tag in ForEach(tags, id: \.self) { tag in
Tag(name: tag.name, color: tag.colorHex.map { Color(hexadecimal: $0) }) Tag(name: tag.name, color: tag.colorHex.map { Color(hex: $0) })
} }
} }
} }

View file

@ -0,0 +1,54 @@
//
// SourceSettingsApiView.swift
// Ferrite
//
// Created by Brian Dashore on 3/24/23.
//
import SwiftUI
struct SourceSettingsApiView: View {
@ObservedObject var selectedSourceApi: SourceApi
@State private var tempClientId: String = ""
@State private var tempClientSecret: String = ""
enum Field {
case secure, plain
}
var body: some View {
Section(
header: Text("API credentials"),
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.")
) {
if let clientId = selectedSourceApi.clientId, clientId.dynamic {
TextField("Client ID", text: $tempClientId, onEditingChanged: { isFocused in
if !isFocused {
clientId.value = tempClientId
clientId.timeStamp = Date()
}
})
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onAppear {
tempClientId = clientId.value ?? ""
}
}
if let clientSecret = selectedSourceApi.clientSecret, clientSecret.dynamic {
TextField("Token", text: $tempClientSecret, onEditingChanged: { isFocused in
if !isFocused {
clientSecret.value = tempClientSecret
clientSecret.timeStamp = Date()
}
})
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onAppear {
tempClientSecret = clientSecret.value ?? ""
}
}
}
}
}

View file

@ -0,0 +1,36 @@
//
// SourceSettingsBaseUrlView.swift
// Ferrite
//
// Created by Brian Dashore on 3/24/23.
//
import SwiftUI
struct SourceSettingsBaseUrlView: View {
@ObservedObject var selectedSource: Source
@State private var tempSite: String = ""
var body: some View {
Section(
header: Text("Base URL"),
footer: Text("Enter the base URL of your server.")
) {
TextField("https://...", text: $tempSite, onEditingChanged: { isFocused in
if !isFocused {
if tempSite.last == "/" {
selectedSource.website = String(tempSite.dropLast())
} else {
selectedSource.website = tempSite
}
}
})
.keyboardType(.URL)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onAppear {
tempSite = selectedSource.website ?? ""
}
}
}
}

View file

@ -0,0 +1,33 @@
//
// SourceSettingsMethodView.swift
// Ferrite
//
// Created by Brian Dashore on 3/24/23.
//
import SwiftUI
struct SourceSettingsMethodView: View {
@ObservedObject var selectedSource: Source
var body: some View {
Section("Fetch method") {
Picker("", selection: $selectedSource.preferredParser) {
if selectedSource.jsonParser != nil {
Text("Website API").tag(SourcePreferredParser.siteApi.rawValue)
}
if selectedSource.rssParser != nil {
Text("RSS").tag(SourcePreferredParser.rss.rawValue)
}
if selectedSource.htmlParser != nil {
Text("Web scraping").tag(SourcePreferredParser.scraping.rawValue)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
.tint(.primary)
}
}

View file

@ -8,207 +8,19 @@
import SwiftUI import SwiftUI
struct SourceSettingsView: View { struct SourceSettingsView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: PluginList.entity(),
sortDescriptors: []
) var pluginLists: FetchedResults<PluginList>
var body: some View {
NavView {
List {
if let selectedSource = navModel.selectedSource {
Section(header: InlineHeader("Info")) {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(selectedSource.name)
Text("v\(selectedSource.version)")
.foregroundColor(.secondary)
}
Text("by \(selectedSource.author)")
.foregroundColor(.secondary)
Group {
Text("ID: \(selectedSource.id)")
if let pluginList = pluginLists.first(where: { $0.id == selectedSource.listId })
{
Text("List: \(pluginList.name)")
Text("List ID: \(pluginList.id.uuidString)")
} else {
Text("No plugin list found. This source should be removed.")
}
}
.foregroundColor(.secondary)
.font(.caption)
}
if let tags = selectedSource.getTags(), !tags.isEmpty {
PluginTagsView(tags: tags)
}
}
.padding(.vertical, 2)
}
if selectedSource.dynamicBaseUrl {
SourceSettingsBaseUrlView(selectedSource: selectedSource)
}
if let sourceApi = selectedSource.api,
sourceApi.clientId?.dynamic ?? false || sourceApi.clientSecret?.dynamic ?? false
{
SourceSettingsApiView(selectedSourceApi: sourceApi)
}
SourceSettingsMethodView(selectedSource: selectedSource)
}
}
.listStyle(.insetGrouped)
.onDisappear {
PersistenceController.shared.save()
}
.navigationTitle("Source Settings")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}
struct SourceSettingsBaseUrlView: View {
@ObservedObject var selectedSource: Source
@State private var tempBaseUrl: String = ""
var body: some View {
Section(
header: InlineHeader("Base URL"),
footer: Text("Enter the base URL of your server.")
) {
TextField("https://...", text: $tempBaseUrl, onEditingChanged: { isFocused in
if !isFocused {
if tempBaseUrl.last == "/" {
selectedSource.baseUrl = String(tempBaseUrl.dropLast())
} else {
selectedSource.baseUrl = tempBaseUrl
}
}
})
.keyboardType(.URL)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.backport.onAppear {
tempBaseUrl = selectedSource.baseUrl ?? ""
}
}
}
}
struct SourceSettingsApiView: View {
@ObservedObject var selectedSourceApi: SourceApi
@State private var tempClientId: String = ""
@State private var tempClientSecret: String = ""
enum Field {
case secure, plain
}
var body: some View {
Section(
header: InlineHeader("API credentials"),
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.")
) {
if let clientId = selectedSourceApi.clientId, clientId.dynamic {
TextField("Client ID", text: $tempClientId, onEditingChanged: { isFocused in
if !isFocused {
clientId.value = tempClientId
clientId.timeStamp = Date()
}
})
.autocorrectionDisabled(true)
.autocapitalization(.none)
.backport.onAppear {
tempClientId = clientId.value ?? ""
}
}
if let clientSecret = selectedSourceApi.clientSecret, clientSecret.dynamic {
TextField("Token", text: $tempClientSecret, onEditingChanged: { isFocused in
if !isFocused {
clientSecret.value = tempClientSecret
clientSecret.timeStamp = Date()
}
})
.autocorrectionDisabled(true)
.autocapitalization(.none)
.backport.onAppear {
tempClientSecret = clientSecret.value ?? ""
}
}
}
}
}
struct SourceSettingsMethodView: View {
@ObservedObject var selectedSource: Source @ObservedObject var selectedSource: Source
var body: some View { var body: some View {
Section(header: InlineHeader("Fetch method")) { if selectedSource.dynamicWebsite {
if selectedSource.jsonParser != nil { SourceSettingsBaseUrlView(selectedSource: selectedSource)
Button {
selectedSource.preferredParser = SourcePreferredParser.siteApi.rawValue
} label: {
HStack {
Text("Website API")
Spacer()
if SourcePreferredParser.siteApi.rawValue == selectedSource.preferredParser {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
if selectedSource.rssParser != nil {
Button {
selectedSource.preferredParser = SourcePreferredParser.rss.rawValue
} label: {
HStack {
Text("RSS")
Spacer()
if SourcePreferredParser.rss.rawValue == selectedSource.preferredParser {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
if selectedSource.htmlParser != nil {
Button {
selectedSource.preferredParser = SourcePreferredParser.scraping.rawValue
} label: {
HStack {
Text("Web scraping")
Spacer()
if SourcePreferredParser.scraping.rawValue == selectedSource.preferredParser {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
} }
.backport.tint(.primary)
if let sourceApi = selectedSource.api,
sourceApi.clientId?.dynamic ?? false || sourceApi.clientSecret?.dynamic ?? false
{
SourceSettingsApiView(selectedSourceApi: sourceApi)
}
SourceSettingsMethodView(selectedSource: selectedSource)
} }
} }

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