Compare commits

...

170 commits
v0.5.1 ... 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
kingbri
ff13884b2b Application: Use the plist minVersion to restrict updates
It's more reliable to use the plist which contains the min version
to determine if an update notification should be shown.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-23 11:52:52 -04:00
kingbri
c719d2cd87 Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-23 11:06:40 -04:00
kingbri
254058928f Library: Fix debrid cloud fetch in iOS 14
onAppear wasn't being called with the current implementation of the
cloud tab in library. Fix this to listen to the selectedDebridType
variable instead of relying on the onAppear call of a view.y

Also do some further project cleanup and LOC removal

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-23 11:04:30 -04:00
kingbri
e0784b3cec Ferrite: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-21 13:56:50 -04:00
kingbri
6e95c6072c Settings: Fix Default actions and Kodi
Fix how default actions are picked and add in default app actions
as options for both debrid and magnet defaults. Kodi shows the
action choice sheet with the DisclosureGroup dropped down.

The new Kodi server framework also wasn't implemented in the Kodi
wrapper. Fix that.

Finally, add some iOS 14 fixes and repair the autocorrect search
setting to actually work.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-21 13:56:09 -04:00
kingbri
edfba1c62e Ferrite: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-19 21:26:42 -04:00
kingbri
69d2f6babe Actions: Add OS-specific checks
Plugin lists are universal across clients and OS-specific cases
should be appropriately handled.

OSes can now be added to the deeplink action case and Ferrite scans
for either iOS-specific or fallthrough actions.

There must be 1 action that corresponds to an OS and/or 1 fallback
case, otherwise the action will not show in the plugins list.

Also remove some extraneous files.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-19 21:24:24 -04:00
kingbri
20c55316b0 Kodi: Add multi-server support
Multiple servers can be added to Ferrite to playback from any Kodi
server that the user wants. This also adds the ability to have
friendly names which makes it easier to select what server to play on.

Each server shows the user whether it's online or not through Kodi's
JSONRPC ping method.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-18 16:30:27 -04:00
kingbri
d2d7d7364f Logging: Add exportability to logs
Logs can be exported into their own files. I'm still debating on
writing a continuous stream to a logfile that persists on app
crashes.

Also make the show error toasts toggle only apply to generic errors
and not customized error toasts.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-10 15:01:00 -05:00
kingbri
7202a95bb2 Ferrite: Parallel tasks and logging
Make all tasks run in parallel to increase responsiveness and efficiency
when fetching new data.

However, parallel tasks means that toast errors are no longer feasible.
Instead, add a logging system which has a more detailed view of
app messages and direct the user there if there is an error.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-09 18:48:28 -05:00
kingbri
a0632b0c16 Updates: Make updater restricted to minVersion
For versions that require iOS 15 and up, don't notify previous iOS 14
users about a new update. This requires a minVersion attribute in
the shared Application class.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-06 00:07:13 -05:00
kingbri
618041714d CustomScopeBar: Fix search bar placement
iOS 16 has a new search bar placement value which applies the searchbar
in the top-right corner with iPads. Searchable does this automatically,
but SwiftUIx's modifier needs this fix.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-05 23:51:21 -05:00
kingbri
d504369e98 Settings: Add debrid info views
Add some extra info for debrid services so they look similar to the
Kodi view.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-05 15:21:58 -05:00
kingbri
b8799be896 Ferrite: Add Kodi support
Adds support for playing links on a preset Kodi server. This is
less featured than the Ferrite companion, but should still work
without a problem.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-05 14:11:52 -05:00
kingbri
438e48be66 Ferrite: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-03 15:09:34 -05:00
kingbri
8c8e9d0215 Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-02 11:23:14 -05:00
kingbri
282783c460 Plugins: Fix list sync in view
Plugin entries were not syncing properly inside the plugins view.
Instead of adding events to update filtered lists, make it so that
these filtered lists are updated on state changes.

This is the intended method of reactive programming and removes
complexity from filtering logic.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-02 11:06:57 -05:00
kingbri
f622b7af05 Actions: Fix default action settings
Since actions use a new API, update default actions to use the
same API rather than the legacy models. If an action is removed,
a prompt will tell the user to change their default debrid/magnet
action and default to the choice sheet.

Also add extra UI fixes and cleanup.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-03-01 18:31:29 -05:00
kingbri
0661ed66f3 Search: Fix picker overlay and position
iOS 14 requires the scope bar modifier to be on the first subview of
the NavView. This is because a VStack wraps the content.

A bug was that the segmented picker was being overlaid due to the
scope bar modifier having an AppStorage call. The AppStorage call
updated the modifier which for some reason added another HostingView.

I am not sure why this happens, but avoid using AppStorage in the modifier
to make sure this doesn't happen again.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-28 19:42:20 -05:00
kingbri
bcdacdae06 Actions: Add release action
Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-27 15:32:57 -05:00
kingbri
f8b6ea6ba7 Search: Add random text to the searchbar
It's redundant to have the navgiation title of the Search view as
"Search" and the searchbar to have the same title. Make the searchbar
have randomized quotes (from a fixed set) that doesn't repeat cases.

Maybe make this customizable in the future?

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-27 14:24:47 -05:00
kingbri
cbe3d17be1 Ferrite: Fix search and add progressive loading
The searchbar had a lot of lag when scrolling down the search
results view. This was due to a shared searchText variable which
updated every time the searchbar text changed and caused UI blocking.

Migrate searchText to a local variable and destroy the child
SearchResultsView as it's not needed at this time (may come back
with v0.7 due to searchable).

Also sources now display results progressively without a ProgressView
blocking when each source loads which allows the user to view media
faster.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-27 12:22:36 -05:00
kingbri
4a87d86e76 Ferrite: Add fixes for Introspect v0.2.2
Introspect now properly updates on every view lifecycle change, so
add a check to a reference on the UIHostingController and see if it
has been instantiated already.

Also clean up view struct names to reflect what is a modifier.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-18 11:39:03 -05:00
kingbri
988e607027 Dependencies: Use Introspect v0.2.1
v0.2.2 causes issues with UI, wait until a fix is released.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-16 22:39:47 -05:00
kingbri
291aa0fe46 Ferrite: Use InlineHeader in all sections
Makes section presentation uniform across all iOS versions since iOS
15 defaults are wonky.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-16 18:52:29 -05:00
kingbri
cb4d935008 Sources: Fix magnet link parsing with RSS
Using the new magnet type auto-populates hashes and links

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-16 18:34:09 -05:00
kingbri
0f081d0716 Ferrite: Modify UI
The overall UI of Ferrite has been changed to make animations smoother
and streamline the experiences.

A new search filter interface has been added for all iOS versions,
but iOS 15 and up have smooth UI applied due to bugs with searchbars
in iOS 14 (which shouldn't even have a searchbar in the first place).

Also fix the plugin fetching logic to not listen to a combine publisher
and instead use a notification that is easier to control.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-16 18:34:05 -05:00
kingbri
88a2dc9742 Sources: Add subName and fixup
The subName parameter is for aggregate sources that pull from a
child website. Make it so it's possible to include that child
site in parsers.

Also remove the magnet link/hash requirement since it's filtered out
anyways after results are fetched.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-08 22:33:27 -05:00
kingbri
41572362c7 PluginManager: Fix multiple plugin list bug
Only entries from the first plugin list would be shown in plugins.
This was because the availableSources array was set every time a list
was iterated upon.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-08 17:08:55 -05:00
kingbri
9ff7f5a7d5 Ferrite: Fix iOS 14 onAppear bug
onAppear does not fire properly on iOS 14 due to a longstanding
bug in SwiftUI. Add a UIKit onAppear hook for listening to these
events and implement inside the backport namespace.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-08 14:41:14 -05:00
kingbri
4512318e8f Ferrite: Add actions, plugins, and tags
Plugins are now a unified format for both sources and actions. Actions
dictate what to do with a link and can now be added through a plugin
JSON file.

Backups have also been versioned to improve performance and add action
support.

Tags are used to give small amounts of information before a user
installs a plugin.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-02-08 12:09:37 -05:00
kingbri
6b0f90178b Backport: Update alert
The alert backport was updated for simplicity. Reflect this inside
Ferrite's source code.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-11 12:23:50 -05:00
kingbri
e31f9a07fe FetchRequest: Add descriptors
Adding the ability to send sort descriptors adds more flexibility
for the DynamicFetchRequest backport. Use this to fix bookmarks sorting.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-08 14:05:07 -05:00
kingbri
6456b34210 Ferrite: Bump version
Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-07 18:47:27 -05:00
kingbri
f960efc1ed Update README
Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-07 18:47:27 -05:00
kingbri
39c4a10a72 Ferrite: format
Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-07 18:47:27 -05:00
kingbri
e8f62e3cdc Library: Add searching and cleanup
Add a searchbar to filter through various library entries so it's
easier to find items.

Also add fixes for < iOS 16 devices and fix up searchbar constraints.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-07 18:47:05 -05:00
kingbri
2258036f7b Debrid: Add support for AllDebrid cloud and cleanup
This commit adds support for viewing a user's AllDebrid magnet list.
AllDebrid does not save unlocked links, but they do save which magnets
a user has queried.

Also clean up various functions in DebridManager.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-06 16:12:08 -05:00
kingbri
90ed4f8353 Debrid: Unify errors
Error handling can be tedious with debrid because network errors
to ignore are present (such as -999). Create a function to properly
display the errors to the user and log them via print statments.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-05 21:10:28 -05:00
kingbri
025d3797dc Premiumize: Perform a transfer if a link is present
This is required for PM's cloud since transfers will also add the
files to a user's cloud rather than just fetching the DDL link.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-04 14:33:38 -05:00
kingbri
5a4e98f10d Ferrite: Switch to a Magnet struct
Magnets are expressed in two different ways: a hash and a link. Both
of these mean the same thing with a magnet link giving more information
if required.

However, there was a disconnect if a hash was present or a link was
present and required many steps to check which was available. Unify
magnets by creating a parent structure that attempts to extract
the hash or create a link in the event that either parameter isn't
provided.

Replace everything except bookmarks (to prevent CoreData complaints
and unnecessary abstraction) to use the new Magnet system.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-04 12:49:20 -05:00
kingbri
9f54397b77 Library: Add support for Premiumize cloud
Add the ability to view a user's Premiumize files in Ferrite. Files
can be deleted from a user's account directly in Ferrite's list.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-03 14:22:20 -05:00
kingbri
9b7bc55a25 Library: Add support for RealDebrid cloud
RealDebrid saves a user's unrestricted links and "torrents" (magnet
links in this case). Add the ability to see and queue a user's RD
library in Ferrite itself.

This required a further abstraction of the debrid manager to allow
for more types other than search results to be passed to various
functions.

Deleting an item from RD's cloud list deletes the item from RD as well.

NOTE: This does not track download progress, but it does show if a
magnet is currently being downloaded or not.

Signed-off-by: kingbri <bdashore3@proton.me>
2023-01-02 15:13:32 -05:00
kingbri
b0850d43d7 Search: Fix inline navigation title
Transitioning the navigation title to inline while editing search
broke for older OS versions. Make a more bulletproof solution to
inline the navigation title when the user commits a search as that's
a definitive event to listen to.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-31 12:35:28 -05:00
kingbri
15ad8c5581 Bookmarks: Fix context menu removal
NotificationCenter posts to other instances of the same button
caused the existing bookmark reference to be nullified for every other
button.

Add a comparison between object IDs to make sure the correct instances
are being nullified.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-31 12:33:51 -05:00
kingbri
47ef72bf13 MagnetChoiceView: Fix titles
Titles weren't cleared on sheet dismiss which caused conflicts between
batches and single files. Fix this.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-14 16:33:18 -05:00
kingbri
04e4503c86 Premiumize: Split cache check requests by 100
Premiumize has a dynamic upper limit of 107-110 (from testing)
hashes per request. To play it safe, cache requests are split by
100 and executed in parallel as 2 (or more) requests.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-09 14:15:39 -05:00
kingbri
55226e5628 Build: Add scripts for commit and nightly information
This automatically embeds the commit hash and if the build is an
actions nightly or not which will help with debugging crashes.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-06 12:37:15 -05:00
kingbri
32e5e21d3c DebridManager: Fix magnet links not loading
I mistakenly added an and condition which caused all debrid services
to fail unless the user is using Premiumize.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-06 10:35:18 -05:00
kingbri
17867db40c Debrid: Add Premiumize support and cleanup
Premiumize is another debrid provider. Add support in addition
to other debrid services.

Add a unified Magnet type that encloses both the link and hash
when needed for certain services.

A universal ASAuthenticationSession has been added to make implicit
authentication easier for services that support it.

Clean up declarations of certain variables that were mismanaged
during the debrid decentralization process.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-12-05 18:10:10 -05:00
kingbri
2322d3af67 Debrid: Decentralize and add AllDebrid support
AllDebrid is another debrid provider. Add support to Ferrite in
addition to RealDebrid.

The overall debrid login backend has changed to accomodate for a more
agnostic app structure where more services can be added as needed.

Also add some cosmetic changes to search so filters can be added while
searching for a phrase.

Signed-off-by: kingbri <bdashore3@proton.me>
2022-11-27 18:18:09 -05:00
kingbri
06d4f8e84e RealDebrid, Github: Reorganize models
Prep for more debrid services

Signed-off-by: kingbri <bdashore3@proton.me>
2022-11-25 14:49:24 -05:00
179 changed files with 10506 additions and 3854 deletions

View file

@ -6,26 +6,27 @@ on:
jobs:
build:
runs-on: macos-12
runs-on: macos-14
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest
xcode-version: latest-stable
- name: Get commit SHA
id: commitinfo
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
env:
IS_NIGHTLY: YES
- name: Package ipa
run: |
mkdir Payload
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa Payload
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
- name: Upload artifacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
if-no-files-found: error

36
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Build and upload release ipa
on:
release:
types:
- created
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Build
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
env:
IS_NIGHTLY: NO
- name: Get app version
run: |
echo "app_version=$(/usr/libexec/plistbuddy -c Print:CFBundleShortVersionString: build/Ferrite.xcarchive/Products/Applications/Ferrite.app/Info.plist)" >> $GITHUB_ENV
- name: Package ipa
run: |
mkdir Payload
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_v${{ env.app_version }}.ipa Payload
- name: Create ipa zip
run: |
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
- name: Upload release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip

View file

@ -1 +1 @@
5.7
5.8

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,396 @@
//
// AllDebridWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 11/25/22.
//
import Foundation
class AllDebrid: PollingDebridSource, ObservableObject {
let id = "AllDebrid"
let abbreviation = "AD"
let website = "https://alldebrid.com"
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " +
"You must pay to access this service. \n\n" +
"It is not recommended to use this service since media cache checks are not possible via the API. " +
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
let cachedStatus: [String] = ["Ready"]
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
func getAuthUrl() async throws -> URL {
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
let request = URLRequest(url: url)
do {
let (data, _) = try await URLSession.shared.data(for: request)
// 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 {
print("Couldn't get pin information!")
throw DebridError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches API keys
func getApiKey(checkID: String, pin: String) async throws {
let queryItems = [
URLQueryItem(name: "agent", value: appName),
URLQueryItem(name: "check", value: checkID),
URLQueryItem(name: "pin", value: pin)
]
let request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
// Timer to poll AD API for key
authTask = Task {
var count = 0
while count < 12 {
if Task.isCancelled {
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(ADResponse<ApiKeyResponse>.self, from: data).data
// If there's an API key from the response, end the task successfully
if let apiKeyResponse = rawResponse {
FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
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 {
throw error
}
}
// Adds a manual API key instead of web auth
func setApiKey(_ key: String) {
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
@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 AllDebrid in Settings.")
} else {
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Builds a URL for further requests
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: urlString) else {
throw DebridError.InvalidUrl
}
components.queryItems = [
URLQueryItem(name: "agent", value: appName)
] + queryItems
if let url = components.url {
return url
} else {
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
func addMagnet(magnet: Magnet) async throws -> Int {
guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid")
}
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [
URLQueryItem(name: "magnets[]", value: magnetLink)
]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
if let magnet = rawResponse.magnets[safe: 0] {
if !magnet.ready {
throw DebridError.IsCaching
}
return magnet.id
} else {
throw DebridError.InvalidResponse
}
}
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
let queryItems = [
URLQueryItem(name: "id", value: magnetId)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
return rawResponse
}
// Known as unlockLink in AD's API
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
let queryItems = [
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
]
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: "unlockLink")
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
return rawResponse.link
}
func saveLink(link: String) async throws {
let 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 rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
cloudMagnets = rawResponse.magnets.map { magnetResponse in
DebridCloudMagnet(
id: String(magnetResponse.id),
fileName: magnetResponse.filename,
status: magnetResponse.status,
hash: magnetResponse.hash,
links: magnetResponse.links.map(\.link)
)
}
}
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,22 +7,22 @@
import Foundation
public class Github {
public func fetchLatestRelease() async throws -> GithubRelease? {
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases/latest")!
class Github {
func fetchLatestRelease() async throws -> Release? {
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")!
let (data, _) = try await URLSession.shared.data(from: url)
let rawResponse = try JSONDecoder().decode(GithubRelease.self, from: data)
let rawResponse = try JSONDecoder().decode(Release.self, from: data)
return rawResponse
}
public func fetchReleases() async throws -> [GithubRelease]? {
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases")!
func fetchReleases() async throws -> [Release]? {
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")!
let (data, _) = try await URLSession.shared.data(from: url)
let rawResponse = try JSONDecoder().decode([GithubRelease].self, from: data)
let rawResponse = try JSONDecoder().decode([Release].self, from: data)
return rawResponse
}
}

View file

@ -0,0 +1,129 @@
//
// KodiWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import Foundation
class Kodi {
private let encoder = JSONEncoder()
// Used to add server to CoreData. Not part of API
func addServer(urlString: String,
friendlyName: String?,
username: String?,
password: String?,
existingServer: KodiServer? = nil) throws
{
let backgroundContext = PersistenceController.shared.backgroundContext
if !urlString.starts(with: "http://"), !urlString.starts(with: "https://") {
throw KodiError.ServerAddition(description: "Could not add Kodi server because the URL is invalid.")
}
var name = ""
if let friendlyName {
name = friendlyName
} else {
var components = URLComponents(string: urlString)
components?.scheme = nil
components?.path = ""
guard let cleanedName = components?.url?.description.dropFirst(2) else {
throw KodiError.ServerAddition(description: "An invalid friendly name for this Kodi server was generated.")
}
name = String(cleanedName)
}
if existingServer == nil {
let existingServerRequest = KodiServer.fetchRequest()
existingServerRequest.fetchLimit = 1
// If a server with the same name or URL exists, error out
let namePredicate = NSPredicate(format: "name == %@", name)
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
existingServerRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, urlPredicate])
if (try? backgroundContext.fetch(existingServerRequest).first) != nil {
throw KodiError.ServerAddition(description: "An existing kodi server with the same name or URL was found. Please try editing an existing server instead.")
}
}
let newServerObject = existingServer ?? KodiServer(context: backgroundContext)
newServerObject.urlString = urlString
newServerObject.name = name
if let username, let password {
newServerObject.username = username
newServerObject.password = password
}
try backgroundContext.save()
}
func ping(server: KodiServer) async throws {
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody = RPCPayload(
method: "JSONRPC.Ping",
params: nil
)
if let username = server.username, let password = server.password {
request.setValue("Basic \(Data("\(username):\(password)".utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
}
request.httpBody = try encoder.encode(requestBody)
let (_, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw KodiError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode == 401 {
throw KodiError.FailedRequest(description: "Your Kodi account details for server \(server.name) are invalid. Please check your credentials in Settings > Kodi.")
} else if response.statusCode <= 200, response.statusCode >= 299 {
throw KodiError.FailedRequest(description: "The Kodi request failed with status code \(response.statusCode).")
}
}
func sendVideoUrl(urlString: String, server: KodiServer) async throws {
if URL(string: urlString) == nil {
throw KodiError.InvalidPlaybackUrl
}
let requestBody = RPCPayload(
method: "Player.Open",
params: Params(item: Item(file: urlString))
)
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let username = server.username, let password = server.password {
request.setValue("Basic \(Data("\(username):\(password)".utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
}
request.httpBody = try encoder.encode(requestBody)
let (_, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw KodiError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode == 401 {
throw KodiError.FailedRequest(description: "Your Kodi account details are invalid. Please check your credentials in Settings > Kodi.")
} else if response.statusCode <= 200, response.statusCode >= 299 {
throw KodiError.FailedRequest(description: "The Kodi request failed with status code \(response.statusCode).")
}
}
}

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

@ -0,0 +1,381 @@
//
// PremiumizeWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 11/28/22.
//
import Foundation
class Premiumize: OAuthDebridSource, ObservableObject {
let id = "Premiumize"
let abbreviation = "PM"
let website = "https://premiumize.me"
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
"You must pay to access the service."
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
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)!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "response_type", value: "token"),
URLQueryItem(name: "state", value: UUID().uuidString)
]
if let url = urlComponents.url {
return url
} else {
throw DebridError.InvalidUrl
}
}
func handleAuthCallback(url: URL) throws {
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let callbackFragment = callbackComponents?.fragment else {
throw DebridError.InvalidResponse
}
var fragmentComponents = URLComponents()
fragmentComponents.query = callbackFragment
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
throw DebridError.InvalidToken
}
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
func logout() {
FerriteKeychain.shared.delete("Premiumize.AccessToken")
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
}
// 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
}
// 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)
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 Premiumize 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
// 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
// Calls this for 100 hashes at a time due to API limits
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
for chunk in magnets.chunked(into: 100) {
group.addTask {
try await self.checkCache(magnets: chunk)
}
}
var chunkedMagnets: [Magnet] = []
for try await magnetArray in group {
chunkedMagnets += magnetArray
}
return chunkedMagnets
}
return availableMagnets
}
// Parent function for initial checking of the cache
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
guard let url = urlComponents.url else {
throw DebridError.InvalidUrl
}
var request = URLRequest(url: url)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
if rawResponse.response.isEmpty {
throw DebridError.EmptyData
} else {
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
if rawResponse.response[safe: index] == true {
return magnet
} else {
return nil
}
}
return availableMagnets
}
}
// MARK: - Downloading
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
// Store the item in PM cloud for later use
try await createTransfer(magnet: magnet)
if let iaFile {
return (iaFile, nil)
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
return (firstFile, nil)
} else {
throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
}
}
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
guard let streamUrlString = restrictedFile.streamUrlString else {
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
}
return streamUrlString
}
private func createTransfer(magnet: Magnet) async throws {
guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnetLink)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
try await performRequest(request: &request, requestName: #function)
}
// MARK: - Cloud methods
func getUserDownloads() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data)
if rawResponse.files.isEmpty {
throw DebridError.EmptyData
}
// The "link" is the ID for Premiumize
cloudDownloads = rawResponse.files.map { file in
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
}
}
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
guard let url = urlComponents.url else {
throw DebridError.InvalidUrl
}
var request = URLRequest(url: url)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ItemDetailsResponse.self, from: data)
return rawResponse
}
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")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "id", value: downloadId)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
try await performRequest(request: &request, requestName: #function)
}
// No user magnets for Premiumize
func getUserMagnets() {}
func deleteUserMagnet(cloudMagnetId: String?) {}
}

View file

@ -6,31 +6,69 @@
//
import Foundation
import KeychainSwift
public enum RealDebridError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
public class RealDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
let openSourceClientId = "X245A4XAIBGVM"
class RealDebrid: PollingDebridSource, ObservableObject {
let id = "RealDebrid"
let abbreviation = "RD"
let website = "https://real-debrid.com"
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
"You must pay to access this service. \n\n" +
"It is not recommended to use this service since media cache checks are not possible via the API. " +
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
"This service does not inform if a magnet link is a batch before downloading."
let cachedStatus: [String] = ["downloaded"]
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
private func setUserDefaultsValue(_ value: Any, forKey: String) {
UserDefaults.standard.set(value, forKey: forKey)
}
@MainActor
private func removeUserDefaultsValue(forKey: String) {
UserDefaults.standard.removeObject(forKey: forKey)
}
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth
// Fetches the device code from RD
public func getVerificationInfo() async throws -> DeviceCodeResponse {
func getAuthUrl() async throws -> URL {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
@ -38,23 +76,33 @@ public class RealDebrid {
]
guard let url = urlComponents.url else {
throw RealDebridError.InvalidUrl
throw DebridError.InvalidUrl
}
let request = URLRequest(url: url)
do {
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)
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 {
print("Couldn't get the new client creds!")
throw RealDebridError.AuthQuery(description: error.localizedDescription)
throw DebridError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches the user's client ID and secret
public func getDeviceCredentials(deviceCode: String) async throws {
func getDeviceCredentials(deviceCode: String) async throws {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
@ -62,55 +110,49 @@ public class RealDebrid {
]
guard let url = urlComponents.url else {
throw RealDebridError.InvalidUrl
throw DebridError.InvalidUrl
}
let request = URLRequest(url: url)
// Timer to poll RD API for credentials
authTask = Task {
var count = 0
var count = 0
while count < 20 {
if Task.isCancelled {
throw RealDebridError.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 {
UserDefaults.standard.set(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
}
while count < 12 {
if Task.isCancelled {
throw DebridError.AuthQuery(description: "Token request cancelled.")
}
throw RealDebridError.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 error
}
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
// Fetch all tokens for the user and store in keychain
public func getTokens(deviceCode: String) async throws {
// Fetch all tokens for the user and store in FerriteKeychain.shared
func getApiTokens(deviceCode: String) async throws {
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
throw RealDebridError.EmptyData
throw DebridError.EmptyData
}
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
throw RealDebridError.EmptyData
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
throw DebridError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
@ -131,20 +173,20 @@ public class RealDebrid {
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
UserDefaults.standard.set(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")
if Date().timeIntervalSince1970 > accessTokenStamp {
do {
if let refreshToken = keychain.get("RealDebrid.RefreshToken") {
try await getTokens(deviceCode: refreshToken)
if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
try await getApiTokens(deviceCode: refreshToken)
}
} catch {
print(error)
@ -152,29 +194,43 @@ public class RealDebrid {
}
}
return keychain.get("RealDebrid.AccessToken")
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
}
public func deleteTokens() async throws {
keychain.delete("RealDebrid.RefreshToken")
keychain.delete("RealDebrid.ClientSecret")
UserDefaults.standard.removeObject(forKey: "RealDebrid.ClientId")
UserDefaults.standard.removeObject(forKey: "RealDebrid.AccessTokenStamp")
// Adds a manual API key instead of web auth
// Clear out existing refresh tokens and timestamps
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.AccessTokenStamp")
// 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")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = 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
@discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await fetchToken() else {
throw RealDebridError.InvalidToken
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await getToken() else {
throw DebridError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -182,97 +238,118 @@ public class RealDebrid {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw RealDebridError.FailedRequest(description: "No HTTP response given")
throw DebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
try await deleteTokens()
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
} else {
throw RealDebridError.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
// Currently does not work for batch links
public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] {
var availableHashes: [RealDebridIA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!)
// MARK: - Instant availability
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 rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
for (hash, response) in rawResponseDict {
guard let data = response.data else {
continue
}
if data.rd.isEmpty {
continue
}
// Is this a batch
if data.rd.count > 1 || data.rd[0].count > 1 {
// Batch array
let batches = data.rd.map { fileDict in
let batchFiles: [RealDebridIABatchFile] = fileDict.map { key, value in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
RealDebridIABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
return RealDebridIABatch(files: batchFiles)
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
}
// RD files array
// Possibly sort this in the future, but not sure how at the moment
var files: [RealDebridIAFile] = []
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(
RealDebridIAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
)
)
}
}
}
// TTL: 5 minutes
availableHashes.append(
RealDebridIA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches
)
)
} else {
availableHashes.append(
RealDebridIA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300
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: []
)
)
}
}
}
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
public func addMagnet(magnetLink: String) async throws -> String {
func addMagnet(magnet: Magnet) async throws -> String {
guard let magnetLink = magnet.link else {
throw DebridError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -289,7 +366,7 @@ public class RealDebrid {
}
// Queues the magnet link for downloading
public func selectFiles(debridID: String, fileIds: [Int]) async throws {
func selectFiles(debridID: String, fileIds: [Int]) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
@ -309,48 +386,31 @@ public class RealDebrid {
}
// 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)")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
// Let the user know if a torrent is downloading
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
return torrentLink
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
throw RealDebridError.EmptyTorrents
} else {
throw RealDebridError.EmptyData
// Let the user know if a magnet is downloading
switch rawResponse.status {
case "downloaded":
return rawResponse
case "downloading", "queued":
throw DebridError.IsCaching
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
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
@ -360,13 +420,68 @@ public class RealDebrid {
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
public func userDownloads() async throws -> [UserDownloadsResponse] {
func getUserDownloads() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
cloudDownloads = rawResponse.map { response in
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
}
}
return rawResponse
// Not used
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"
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

@ -1,30 +0,0 @@
//
// Application.swift
// Ferrite
//
// Created by Brian Dashore on 9/16/22.
//
// A thread-safe UIApplication alternative for specifying app properties
//
import Foundation
public class Application {
static let shared = Application()
var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}
var appBuild: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
}
var buildType: String {
#if DEBUG
return "Debug"
#else
return "Release"
#endif
}
}

View file

@ -0,0 +1,13 @@
//
// Action+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 1/12/23.
//
//
import CoreData
import Foundation
@objc(Action)
public class Action: NSManagedObject, Plugin {}

View file

@ -0,0 +1,68 @@
//
// Action+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 2/6/23.
//
//
import CoreData
import Foundation
public extension Action {
@nonobjc class func fetchRequest() -> NSFetchRequest<Action> {
NSFetchRequest<Action>(entityName: "Action")
}
@NSManaged var id: UUID
@NSManaged var listId: UUID?
@NSManaged var name: String
@NSManaged var deeplink: String?
@NSManaged var version: Int16
@NSManaged var about: String?
@NSManaged var website: String?
@NSManaged var requires: [String]
@NSManaged var author: String
@NSManaged var enabled: Bool
@NSManaged var tags: NSOrderedSet?
func getTags() -> [PluginTagJson] {
requires.map { PluginTagJson(name: $0, colorHex: nil) } + tagArray.map { $0.toJson() }
}
}
// MARK: Generated accessors for tags
public extension Action {
@objc(insertObject:inTagsAtIndex:)
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged func removeFromTags(_ values: NSOrderedSet)
}
extension Action: Identifiable {}

View file

@ -10,16 +10,4 @@ import CoreData
import Foundation
@objc(Bookmark)
public class Bookmark: NSManagedObject {
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnetLink: magnetLink,
magnetHash: magnetHash,
seeders: seeders,
leechers: leechers
)
}
}
class Bookmark: NSManagedObject {}

View file

@ -9,7 +9,7 @@
import CoreData
import Foundation
public extension Bookmark {
extension Bookmark {
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
NSFetchRequest<Bookmark>(entityName: "Bookmark")
}
@ -22,6 +22,17 @@ public extension Bookmark {
@NSManaged var source: String
@NSManaged var title: String?
@NSManaged var orderNum: Int16
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnet: Magnet(hash: magnetHash, link: magnetLink),
seeders: seeders,
leechers: leechers
)
}
}
extension Bookmark: Identifiable {}

View file

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

View file

@ -0,0 +1,23 @@
//
// KodiServer+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 3/6/23.
//
//
import CoreData
import Foundation
public extension KodiServer {
@nonobjc class func fetchRequest() -> NSFetchRequest<KodiServer> {
NSFetchRequest<KodiServer>(entityName: "KodiServer")
}
@NSManaged var urlString: String
@NSManaged var name: String
@NSManaged var username: String?
@NSManaged var password: String?
}
extension KodiServer: Identifiable {}

View file

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

View file

@ -0,0 +1,23 @@
//
// PluginList+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
//
import CoreData
import Foundation
public extension PluginList {
@nonobjc class func fetchRequest() -> NSFetchRequest<PluginList> {
NSFetchRequest<PluginList>(entityName: "PluginList")
}
@NSManaged var author: String
@NSManaged var id: UUID
@NSManaged var name: String
@NSManaged var urlString: String
}
extension PluginList: Identifiable {}

View file

@ -0,0 +1,13 @@
//
// PluginTag+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
//
import CoreData
import Foundation
@objc(PluginTag)
public class PluginTag: NSManagedObject {}

View file

@ -0,0 +1,27 @@
//
// PluginTag+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
//
import CoreData
import Foundation
public extension PluginTag {
@nonobjc class func fetchRequest() -> NSFetchRequest<PluginTag> {
NSFetchRequest<PluginTag>(entityName: "PluginTag")
}
@NSManaged var colorHex: String?
@NSManaged var name: String
@NSManaged var parentAction: Action?
@NSManaged var parentSource: Source?
internal func toJson() -> PluginTagJson {
PluginTagJson(name: name, colorHex: colorHex)
}
}
extension PluginTag: Identifiable {}

View file

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

View file

@ -2,7 +2,7 @@
// Source+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 8/3/22.
// Created by Brian Dashore on 2/6/23.
//
//
@ -15,9 +15,10 @@ public extension Source {
}
@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 dynamicBaseUrl: Bool
@NSManaged var enabled: Bool
@NSManaged var name: String
@NSManaged var author: String
@ -29,6 +30,45 @@ public extension Source {
@NSManaged var jsonParser: SourceJsonParser?
@NSManaged var api: SourceApi?
@NSManaged var trackers: [String]?
@NSManaged var tags: NSOrderedSet?
func getTags() -> [PluginTagJson] {
tagArray.map { $0.toJson() }
}
}
// MARK: Generated accessors for tags
public extension Source {
@objc(insertObject:inTagsAtIndex:)
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
@objc(removeObjectFromTagsAtIndex:)
@NSManaged func removeFromTags(at idx: Int)
@objc(insertTags:atIndexes:)
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
@objc(removeTagsAtIndexes:)
@NSManaged func removeFromTags(at indexes: NSIndexSet)
@objc(replaceObjectInTagsAtIndex:withObject:)
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
@objc(replaceTagsAtIndexes:withTags:)
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
@objc(addTagsObject:)
@NSManaged func addToTags(_ value: PluginTag)
@objc(removeTagsObject:)
@NSManaged func removeFromTags(_ value: PluginTag)
@objc(addTags:)
@NSManaged func addToTags(_ values: NSOrderedSet)
@objc(removeTags:)
@NSManaged func removeFromTags(_ values: NSOrderedSet)
}
extension Source: Identifiable {}

View file

@ -15,13 +15,15 @@ public extension SourceHtmlParser {
}
@NSManaged var rows: String
@NSManaged var searchUrl: String
@NSManaged var searchUrl: String?
@NSManaged var request: SourceRequest?
@NSManaged var magnetHash: SourceMagnetHash?
@NSManaged var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source?
@NSManaged var seedLeech: SourceSeedLeech?
@NSManaged var size: SourceSize?
@NSManaged var title: SourceTitle?
@NSManaged var subName: SourceSubName?
}
extension SourceHtmlParser: Identifiable {}

View file

@ -17,12 +17,14 @@ public extension SourceJsonParser {
@NSManaged var results: String?
@NSManaged var subResults: String?
@NSManaged var searchUrl: String
@NSManaged var request: SourceRequest?
@NSManaged var magnetHash: SourceMagnetHash?
@NSManaged var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source?
@NSManaged var seedLeech: SourceSeedLeech?
@NSManaged var size: SourceSize?
@NSManaged var title: SourceTitle?
@NSManaged var subName: SourceSubName?
}
extension SourceJsonParser: Identifiable {}

View file

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

View file

@ -1,23 +0,0 @@
//
// SourceList+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 7/30/22.
//
//
import CoreData
import Foundation
public extension SourceList {
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceList> {
NSFetchRequest<SourceList>(entityName: "SourceList")
}
@NSManaged var id: UUID
@NSManaged var author: String
@NSManaged var name: String
@NSManaged var urlString: String
}
extension SourceList: Identifiable {}

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,12 +17,14 @@ public extension SourceRssParser {
@NSManaged var items: String
@NSManaged var rssUrl: String?
@NSManaged var searchUrl: String
@NSManaged var request: SourceRequest?
@NSManaged var magnetHash: SourceMagnetHash?
@NSManaged var magnetLink: SourceMagnetLink?
@NSManaged var parentSource: Source?
@NSManaged var seedLeech: SourceSeedLeech?
@NSManaged var size: SourceSize?
@NSManaged var title: SourceTitle?
@NSManaged var subName: SourceSubName?
}
extension SourceRssParser: Identifiable {}

View file

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>FerriteDB.xcdatamodel</string>
<string>FerriteDB_v2.xcdatamodel</string>
</dict>
</plist>

View file

@ -0,0 +1,187 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Action" representedClassName="Action" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="deeplink" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<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"/>
</entity>
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="magnetHash" optional="YES" attributeType="String"/>
<attribute name="magnetLink" optional="YES" attributeType="String"/>
<attribute name="orderNum" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<attribute name="size" optional="YES" attributeType="String"/>
<attribute name="source" attributeType="String" defaultValueString=""/>
<attribute name="title" optional="YES" attributeType="String"/>
</entity>
<entity name="History" representedClassName="History" syncable="YES">
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="dateString" optional="YES" attributeType="String"/>
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HistoryEntry" inverseName="parentHistory" inverseEntity="HistoryEntry"/>
</entity>
<entity name="HistoryEntry" representedClassName="HistoryEntry" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="source" optional="YES" attributeType="String"/>
<attribute name="subName" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
</entity>
<entity name="KodiServer" representedClassName="KodiServer" syncable="YES">
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="password" optional="YES" attributeType="String"/>
<attribute name="urlString" attributeType="String" defaultValueString=""/>
<attribute name="username" optional="YES" attributeType="String"/>
</entity>
<entity name="PluginList" representedClassName="PluginList" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="urlString" attributeType="String" defaultValueString=""/>
</entity>
<entity name="PluginTag" representedClassName="PluginTag" syncable="YES">
<attribute name="colorHex" optional="YES" attributeType="String"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="parentAction" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Action" inverseName="tags" inverseEntity="Action"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
</entity>
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="dynamicWebsite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<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="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="rssParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentSource" inverseEntity="PluginTag"/>
</entity>
<entity name="SourceApi" representedClassName="SourceApi" syncable="YES" codeGenerationType="class">
<attribute name="apiUrl" optional="YES" attributeType="String"/>
<relationship name="clientId" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientId" inverseName="parentApi" inverseEntity="SourceApiClientId"/>
<relationship name="clientSecret" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientSecret" inverseName="parentApi" inverseEntity="SourceApiClientSecret"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="api" inverseEntity="Source"/>
</entity>
<entity name="SourceApiClientId" representedClassName="SourceApiClientId" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientId" inverseEntity="SourceApi"/>
</entity>
<entity name="SourceApiClientSecret" representedClassName="SourceApiClientSecret" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientSecret" inverseEntity="SourceApi"/>
</entity>
<entity name="SourceApiCredential" representedClassName="SourceApiCredential" isAbstract="YES" syncable="YES" codeGenerationType="class">
<attribute name="dynamic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="expiryLength" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="query" optional="YES" attributeType="String"/>
<attribute name="responseType" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="urlString" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="String"/>
</entity>
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString="text"/>
<attribute name="discriminator" optional="YES" attributeType="String"/>
<attribute name="query" attributeType="String" defaultValueString=""/>
<attribute name="regex" optional="YES" attributeType="String"/>
</entity>
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
<attribute name="rows" 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="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentHtmlParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentHtmlParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceJsonParser" representedClassName="SourceJsonParser" syncable="YES">
<attribute name="results" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
<attribute name="searchUrl" optional="YES" attributeType="String"/>
<attribute name="subResults" optional="YES" attributeType="String"/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentJsonParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentJsonParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceMagnetHash" representedClassName="SourceMagnetHash" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetHash" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetHash" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetHash" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceMagnetLink" representedClassName="SourceMagnetLink" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<attribute name="externalLinkQuery" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetLink" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRequest" representedClassName="SourceRequest" syncable="YES">
<attribute name="body" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData"/>
<attribute name="headers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String: String]"/>
<attribute name="method" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="request" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="request" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="request" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/>
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentRssParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentRssParser" inverseEntity="SourceTitle"/>
</entity>
<entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES">
<attribute name="attribute" attributeType="String" defaultValueString=""/>
<attribute name="combined" optional="YES" attributeType="String"/>
<attribute name="discriminator" optional="YES" attributeType="String"/>
<attribute name="leecherRegex" optional="YES" attributeType="String"/>
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="seederRegex" optional="YES" attributeType="String"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="seedLeech" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="seedLeech" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="size" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="size" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceSubName" representedClassName="SourceSubName" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="subName" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="subName" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="subName" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="title" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="title" inverseEntity="SourceRssParser"/>
</entity>
</model>

View file

@ -91,7 +91,7 @@ struct PersistenceController {
save()
}
func createBookmark(_ bookmarkJson: BookmarkJson) {
func createBookmark(_ bookmarkJson: BookmarkJson, performSave: Bool) {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "source == %@ AND title == %@ AND magnetLink == %@",
@ -112,31 +112,32 @@ struct PersistenceController {
newBookmark.magnetLink = bookmarkJson.magnetLink
newBookmark.seeders = bookmarkJson.seeders
newBookmark.leechers = bookmarkJson.leechers
if performSave {
save(backgroundContext)
}
}
func createHistory(entryJson: HistoryEntryJson, date: Double?) {
func createHistory(_ entryJson: HistoryEntryJson, performSave: Bool, isBackup: Bool = false, date: Double? = nil) {
let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date()
let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate)
let newHistoryEntry = HistoryEntry(context: backgroundContext)
newHistoryEntry.source = entryJson.source
newHistoryEntry.name = entryJson.name
newHistoryEntry.url = entryJson.url
newHistoryEntry.subName = entryJson.subName
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
let historyRequest = History.fetchRequest()
historyRequest.predicate = NSPredicate(format: "dateString = %@", historyDateString)
var existingHistory: History?
// Safely add entries to a parent history if it exists
if var histories = try? backgroundContext.fetch(historyRequest) {
for (i, history) in histories.enumerated() {
let existingEntries = history.entryArray.filter { $0.url == newHistoryEntry.url && $0.name == newHistoryEntry.name }
let existingEntries = history.entryArray.filter { $0.url == entryJson.url && $0.name == entryJson.name }
// Maybe add !isBackup here
if !existingEntries.isEmpty {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
if isBackup {
continue
} else {
for entry in existingEntries {
PersistenceController.shared.delete(entry, context: backgroundContext)
}
}
}
@ -146,13 +147,24 @@ struct PersistenceController {
}
}
newHistoryEntry.parentHistory = histories.first ?? History(context: backgroundContext)
} else {
newHistoryEntry.parentHistory = History(context: backgroundContext)
existingHistory = histories.first
}
let newHistoryEntry = HistoryEntry(context: backgroundContext)
newHistoryEntry.source = entryJson.source
newHistoryEntry.name = entryJson.name
newHistoryEntry.url = entryJson.url
newHistoryEntry.subName = entryJson.subName
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
newHistoryEntry.parentHistory = existingHistory ?? History(context: backgroundContext)
newHistoryEntry.parentHistory?.dateString = historyDateString
newHistoryEntry.parentHistory?.date = historyDate
if performSave {
save(backgroundContext)
}
}
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {
@ -196,8 +208,7 @@ struct PersistenceController {
return predicate
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
// Wrapper to batch delete history objects
func batchDeleteHistory(range: HistoryDeleteRange) throws {
let predicate = getHistoryPredicate(range: range)
@ -209,6 +220,13 @@ struct PersistenceController {
throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?")
}
try batchDelete("History", predicate: predicate)
}
// Always use the background context to batch delete
// Merge changes into both contexts to update views
func batchDelete(_ entity: String, predicate: NSPredicate? = nil) throws {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeObjectIDs

View file

@ -0,0 +1,17 @@
//
// Array.swift
// Ferrite
//
// Created by Brian Dashore on 12/4/22.
//
import Foundation
extension Array {
// From https://www.hackingwithswift.com/example-code/language/how-to-split-an-array-into-chunks
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0 ..< Swift.min($0 + size, count)])
}
}
}

View file

@ -0,0 +1,18 @@
//
// Bundle.swift
// Ferrite
//
// Created by Brian Dashore on 12/6/22.
//
import Foundation
extension Bundle {
var commitHash: String? {
infoDictionary?["GitCommitHash"] as? String
}
var isNightly: Bool {
infoDictionary?["IsNightly"] as? Bool ?? false
}
}

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

@ -7,6 +7,7 @@
import Foundation
// A static DateFormatter is better than initializing new ones
extension DateFormatter {
static let historyDateFormatter: DateFormatter = {
let df = DateFormatter()

View file

@ -0,0 +1,14 @@
//
// OperatingSystemVersion.swift
// Ferrite
//
// Created by Brian Dashore on 3/23/23.
//
import Foundation
extension OperatingSystemVersion {
func toString() -> String {
"\(majorVersion).\(minorVersion).\(patchVersion)"
}
}

View file

@ -0,0 +1,26 @@
//
// Set.swift
// Ferrite
//
// Created by Brian Dashore on 11/26/22.
//
import Foundation
extension Set: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode(Set<Element>.self, from: data)
else { return nil }
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}

View file

@ -4,12 +4,21 @@
//
// Created by Brian Dashore on 8/31/22.
//
// From https://stackoverflow.com/a/59307884
//
import Foundation
extension String {
// From https://www.hackingwithswift.com/example-code/strings/how-to-capitalize-the-first-letter-of-a-string
func capitalizingFirstLetter() -> String {
prefix(1).capitalized + dropFirst()
}
mutating func capitalizeFirstLetter() {
self = capitalizingFirstLetter()
}
// From https://stackoverflow.com/a/59307884
private func compare(toVersion targetVersion: String) -> ComparisonResult {
let versionDelimiter = "."
var result: ComparisonResult = .orderedSame

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

@ -0,0 +1,14 @@
//
// UIDevice.swift
// Ferrite
//
// Created by Brian Dashore on 2/16/23.
//
import UIKit
extension UIDevice {
var hasNotch: Bool {
UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
}
}

View file

@ -5,45 +5,29 @@
// Created by Brian Dashore on 8/15/22.
//
import Introspect
import SwiftUI
extension View {
// MARK: Custom introspect functions
// 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)
func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View {
inject(UIKitIntrospectionView(
selector: { introspectionView in
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
return nil
}
return Introspect.previousSibling(containing: UICollectionView.self, from: viewHost)
},
customize: customize
))
return result
}
// MARK: Modifiers
func conditionalContextMenu(id: some Hashable,
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
{
modifier(ConditionalContextMenu(internalContent, id: id))
}
func conditionalId(_ id: some Hashable) -> some View {
modifier(ConditionalId(id: id))
}
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
modifier(DisabledAppearance(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
}
func disableInteraction(_ disabled: Bool) -> some View {
modifier(DisableInteraction(disabled: disabled))
modifier(DisableInteractionModifier(disabled: disabled))
}
func inlinedList() -> some View {
modifier(InlinedList())
func inlinedList(inset: CGFloat) -> some View {
modifier(InlinedListModifier(inset: inset))
}
}

View file

@ -12,27 +12,27 @@ struct FerriteApp: App {
let persistenceController = PersistenceController.shared
@StateObject var scrapingModel: ScrapingViewModel = .init()
@StateObject var toastModel: ToastViewModel = .init()
@StateObject var logManager: LoggingManager = .init()
@StateObject var debridManager: DebridManager = .init()
@StateObject var navModel: NavigationViewModel = .init()
@StateObject var sourceManager: SourceManager = .init()
@StateObject var pluginManager: PluginManager = .init()
@StateObject var backupManager: BackupManager = .init()
var body: some Scene {
WindowGroup {
MainView()
.onAppear {
scrapingModel.toastModel = toastModel
debridManager.toastModel = toastModel
sourceManager.toastModel = toastModel
backupManager.toastModel = toastModel
navModel.toastModel = toastModel
scrapingModel.logManager = logManager
debridManager.logManager = logManager
pluginManager.logManager = logManager
backupManager.logManager = logManager
navModel.logManager = logManager
}
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(toastModel)
.environmentObject(logManager)
.environmentObject(navModel)
.environmentObject(sourceManager)
.environmentObject(pluginManager)
.environmentObject(backupManager)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}

View file

@ -15,6 +15,19 @@
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Ferrite</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ferrite</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>

View file

@ -0,0 +1,106 @@
//
// ActionModels.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import Foundation
struct ActionJson: Codable, Hashable, PluginJson {
let name: String
let version: Int16
let minVersion: String?
let about: String?
let website: String?
let requires: [ActionRequirement]
let deeplink: [DeeplinkActionJson]?
let author: String?
let listId: UUID?
let listName: String?
let tags: [PluginTagJson]?
init(name: String,
version: Int16,
minVersion: String?,
about: String?,
website: String?,
requires: [ActionRequirement],
deeplink: [DeeplinkActionJson]?,
author: String?,
listId: UUID?,
listName: String?,
tags: [PluginTagJson]?)
{
self.name = name
self.version = version
self.minVersion = minVersion
self.about = about
self.website = website
self.requires = requires
self.deeplink = deeplink
self.author = author
self.listId = listId
self.listName = listName
self.tags = tags
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
version = try container.decode(Int16.self, forKey: .version)
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)
author = try container.decodeIfPresent(String.self, forKey: .author)
listId = nil
listName = nil
tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags)
if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) {
deeplink = [DeeplinkActionJson(os: [], scheme: deeplinkString)]
} else if let deeplinkAction = try? container.decode([DeeplinkActionJson].self, forKey: .deeplink) {
deeplink = deeplinkAction
} else {
deeplink = nil
}
}
}
struct DeeplinkActionJson: Codable, Hashable {
let os: [String]
let scheme: String
init(os: [String], scheme: String) {
self.os = os
self.scheme = scheme
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let os = try? container.decode(String.self, forKey: .os) {
self.os = [os]
} else if let os = try? container.decode([String].self, forKey: .os) {
self.os = os
} else {
os = []
}
scheme = try container.decode(String.self, forKey: .scheme)
}
}
extension ActionJson {
// Fetches all tags without optional requirement
// Avoids the need for extra tag additions in DB
func getTags() -> [PluginTagJson] {
requires.map { PluginTagJson(name: $0.rawValue, colorHex: nil) } + (tags.map { $0 } ?? [])
}
}
enum ActionRequirement: String, Codable {
case magnet
case debrid
}

View file

@ -0,0 +1,155 @@
//
// AllDebridModels.swift
// Ferrite
//
// Created by Brian Dashore on 11/25/22.
//
import Foundation
extension AllDebrid {
// MARK: - Generic AllDebrid response
// Uses a generic parametr for whatever underlying response is present
struct ADResponse<ADData: Codable>: Codable {
let status: String
let data: ADData
}
// MARK: - PinResponse
struct PinResponse: Codable {
let pin, check: String
let expiresIn: Int
let userURL, baseURL, checkURL: String
enum CodingKeys: String, CodingKey {
case pin, check
case expiresIn = "expires_in"
case userURL = "user_url"
case baseURL = "base_url"
case checkURL = "check_url"
}
}
// MARK: - ApiKeyResponse
struct ApiKeyResponse: Codable {
let apikey: String
let activated: Bool
let expiresIn: Int
enum CodingKeys: String, CodingKey {
case apikey, activated
case expiresIn = "expires_in"
}
}
// MARK: - AddMagnetResponse
struct AddMagnetResponse: Codable {
let magnets: [AddMagnetData]
}
// MARK: - AddMagnetData
struct AddMagnetData: Codable {
let magnet, hash, name, filenameOriginal: String
let size: Int
let ready: Bool
let id: Int
enum CodingKeys: String, CodingKey {
case magnet, hash, name
case filenameOriginal = "filename_original"
case size, ready, id
}
}
// MARK: - MagnetStatusResponse
struct MagnetStatusResponse: Codable {
let magnets: [MagnetStatusData]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
magnets = [data]
} else if let data = try? container.decode([MagnetStatusData].self, forKey: .magnets) {
magnets = data
} else {
magnets = []
}
}
}
// MARK: - MagnetStatusData
struct MagnetStatusData: Codable {
let id: Int
let filename: String
let size: Int
let hash, status: String
let statusCode, downloaded, uploaded, seeders: Int
let downloadSpeed, processingPerc, uploadSpeed, uploadDate: Int
let completionDate: Int
let links: [MagnetStatusLink]
let type: String
let notified: Bool
let version: Int
}
// MARK: - MagnetStatusLink
// Abridged for required parameters
struct MagnetStatusLink: Codable {
let link: String
let filename: String
let size: Int
}
// MARK: - UnlockLinkResponse
// Abridged for required parameters
struct UnlockLinkResponse: Codable {
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
struct InstantAvailabilityResponse: Codable {
let magnets: [InstantAvailabilityMagnet]
}
// MARK: - IAMagnetResponse
struct InstantAvailabilityMagnet: Codable {
let magnet, hash: String
let instant: Bool
let files: [InstantAvailabilityFile]?
}
// MARK: - IAFileResponse
struct InstantAvailabilityFile: Codable {
let name: String
enum CodingKeys: String, CodingKey {
case name = "n"
}
}
}

View file

@ -7,16 +7,32 @@
import Foundation
public struct Backup: Codable {
// Version is optional until v1 is phased out
struct Backup: Codable {
let version: Int?
var bookmarks: [BookmarkJson]?
var history: [HistoryJson]?
var sourceNames: [String]?
var sourceLists: [SourceListBackupJson]?
var actionNames: [String]?
var pluginListUrls: [String]?
// MARK: Remove once v1 backups are unsupported
var sourceLists: [PluginListBackupJson]?
}
// MARK: - CoreData translation
typealias BookmarkJson = SearchResult
// Don't typealias to search result as this is a reflection of CoreData's struct
struct BookmarkJson: Codable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let seeders: String?
let leechers: String?
}
// Date is an epoch timestamp
struct HistoryJson: Codable {
@ -26,15 +42,15 @@ struct HistoryJson: Codable {
}
struct HistoryEntryJson: Codable {
let name: String
let subName: String?
let url: String
let timeStamp: Double?
var name: String? = nil
var subName: String? = nil
var url: String? = nil
var timeStamp: Double? = nil
let source: String?
}
// Differs from SourceListJson
struct SourceListBackupJson: Codable {
// Differs from PluginListJson
struct PluginListBackupJson: Codable {
let name: String
let author: String
let id: String

View file

@ -0,0 +1,144 @@
//
// DebridManagerModels.swift
// Ferrite
//
// Created by Brian Dashore on 11/27/22.
//
import Base32
import Foundation
// MARK: - Universal IA enum (IA = InstantAvailability)
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
case full = "Cached"
case partial = "Batch"
case none = "Uncached"
}
// MARK: - Enum for debrid differentiation. 0 is nil
enum DebridType: Int, Codable, Hashable, CaseIterable {
case realDebrid = 1
case allDebrid = 2
case premiumize = 3
func toString(abbreviated: Bool = false) -> String {
switch self {
case .realDebrid:
return abbreviated ? "RD" : "RealDebrid"
case .allDebrid:
return abbreviated ? "AD" : "AllDebrid"
case .premiumize:
return abbreviated ? "PM" : "Premiumize"
}
}
func website() -> String {
switch self {
case .realDebrid:
return "https://real-debrid.com"
case .allDebrid:
return "https://alldebrid.com"
case .premiumize:
return "https://premiumize.me"
}
}
}
// Wrapper struct for magnet links to contain both the link and hash for easy access
struct Magnet: Codable, Hashable, Sendable {
var hash: String?
var link: String?
init(hash: String?, link: String?, title: String? = nil, trackers: [String]? = nil) {
if let hash, link == nil {
self.hash = parseHash(hash)
self.link = generateLink(hash: hash, title: title, trackers: trackers)
} else if let link, hash == nil {
let (link, hash) = parseLink(link)
self.link = link
self.hash = hash
} else {
self.hash = parseHash(hash)
self.link = parseLink(link).link
}
}
func generateLink(hash: String, title: String?, trackers: [String]?) -> String {
var magnetLinkArray = ["magnet:?xt=urn:btih:", hash]
if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
magnetLinkArray.append("&dn=\(encodedTitle)")
}
if let trackers {
for trackerUrl in trackers {
if URL(string: trackerUrl) != nil,
let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
{
magnetLinkArray.append("&tr=\(encodedUrlString)")
}
}
}
return magnetLinkArray.joined()
}
func extractHash(link: String) -> String? {
if let firstSplit = link.split(separator: ":")[safe: 3],
let tempHash = firstSplit.split(separator: "&")[safe: 0]
{
return String(tempHash)
} else {
return nil
}
}
// Is this a Base32hex hash?
func parseHash(_ magnetHash: String?) -> String? {
guard let magnetHash else {
return nil
}
if magnetHash.count == 32 {
let decryptedMagnetHash = base32DecodeToData(String(magnetHash))
return decryptedMagnetHash?.hexEncodedString()
} else {
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,12 +7,14 @@
import Foundation
public struct GithubRelease: Codable, Hashable, Sendable {
let htmlUrl: String
let tagName: String
extension Github {
struct Release: Codable, Hashable, Sendable {
let htmlUrl: String
let tagName: String
enum CodingKeys: String, CodingKey {
case htmlUrl = "html_url"
case tagName = "tag_name"
enum CodingKeys: String, CodingKey {
case htmlUrl = "html_url"
case tagName = "tag_name"
}
}
}

View file

@ -0,0 +1,39 @@
//
// KodiModels.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import Foundation
extension Kodi {
enum KodiError: Error {
case ServerAddition(description: String)
case InvalidBaseUrl
case InvalidPlaybackUrl
case InvalidPostBody
case FailedRequest(description: String)
}
// MARK: - RPC payload
struct RPCPayload: Encodable {
let jsonrpc: String = "2.0"
let id: String = "1"
let method: String
let params: Params?
}
// MARK: - RPC Params
struct Params: Codable {
let item: Item
}
// MARK: - RPC Item
struct Item: Codable {
let file: 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

@ -0,0 +1,39 @@
//
// PluginModels.swift
// Ferrite
//
// Created by Brian Dashore on 1/11/23.
//
import Foundation
struct PluginListJson: Codable {
let name: String
let author: String
var sources: [SourceJson]?
var actions: [ActionJson]?
}
// Color: Hex value
public struct PluginTagJson: Codable, Hashable, Sendable {
let name: String
let colorHex: String?
enum CodingKeys: String, CodingKey {
case name
case colorHex = "color"
}
}
extension PluginManager {
enum PluginManagerError: Error {
case ListAddition(description: String)
case ActionAddition(description: String)
case PluginFetch(description: String)
}
struct AvailablePlugins {
let availableSources: [SourceJson]
let availableActions: [ActionJson]
}
}

View file

@ -0,0 +1,74 @@
//
// PremiumizeModels.swift
// Ferrite
//
// Created by Brian Dashore on 11/28/22.
//
import Foundation
extension Premiumize {
// MARK: - CacheCheckResponse
struct CacheCheckResponse: Codable {
let status: String
let response: [Bool]
}
// MARK: - DDLResponse
struct DDLResponse: Codable {
let status: String
let content: [DDLData]?
let filename: String
let filesize: Int
}
// MARK: Content
struct DDLData: Codable {
let path: String
let size: Int
let link: String
enum CodingKeys: String, CodingKey {
case path, size, link
}
}
// MARK: - AllItemsResponse (listall endpoint)
struct AllItemsResponse: Codable {
let status: String
let files: [UserItem]
}
// MARK: User Items
// Abridged for required parameters
struct UserItem: Codable {
let id: String
let name: String
let mimeType: String
enum CodingKeys: String, CodingKey {
case id, name
case mimeType = "mime_type"
}
}
// MARK: - ItemDetailsResponse
// Abridged for required parameters
struct ItemDetailsResponse: Codable {
let id: String
let name: String
let link: String
let mimeType: String
enum CodingKeys: String, CodingKey {
case id, name, link
case mimeType = "mime_type"
}
}
}

View file

@ -8,187 +8,170 @@
import Foundation
// MARK: - device code endpoint
extension RealDebrid {
// MARK: - device code endpoint
public struct DeviceCodeResponse: Codable, Sendable {
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
struct DeviceCodeResponse: Codable, Sendable {
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case interval
case expiresIn = "expires_in"
case verificationURL = "verification_url"
case directVerificationURL = "direct_verification_url"
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case interval
case expiresIn = "expires_in"
case verificationURL = "verification_url"
case directVerificationURL = "direct_verification_url"
}
}
}
// MARK: - device credentials endpoint
// MARK: - device credentials endpoint
public struct DeviceCredentialsResponse: Codable, Sendable {
let clientID, clientSecret: String?
struct DeviceCredentialsResponse: Codable, Sendable {
let clientID, clientSecret: String?
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
}
}
}
// MARK: - token endpoint
// MARK: - token endpoint
public struct TokenResponse: Codable, Sendable {
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
struct TokenResponse: Codable, Sendable {
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case tokenType = "token_type"
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case tokenType = "token_type"
}
}
}
// MARK: - instantAvailability endpoint
// MARK: - instantAvailability endpoint
// Thanks Skitty!
public struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
// Thanks Skitty!
struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) {
self.data = data
if let data = try? container.decode(InstantAvailabilityData.self) {
self.data = data
}
}
}
struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String
var filesize: Int
}
// MARK: - Instant Availability batch structures (used for client-side conversion)
struct IABatch: Codable, Hashable, Sendable {
let files: [IABatchFile]
}
struct IABatchFile: Codable, Hashable, Sendable {
let id: Int
let fileName: String
}
// MARK: - addMagnet endpoint
struct AddMagnetResponse: Codable, Sendable {
let id: String
let uri: String
}
// MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
let split, progress: Int
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String?
let speed: Int?
let seeders: Int?
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended, speed, seeders
}
}
struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
}
struct UserTorrentsResponse: Codable, Hashable, Sendable {
let id, filename, hash: String
let bytes: Int
let host: String
let split, progress: Int
let status, added: String
let links: [String]
let speed, seeders: Int?
let ended: String?
}
// MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks, crc: Int
let download: String
let streamable: Int
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, crc, download, streamable
}
}
// MARK: - User downloads list
struct UserDownloadsResponse: Codable, Hashable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks: Int
let download: String
let streamable: Int
let generated: String
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, download, streamable, generated
}
}
}
// MARK: - Instant Availability client side structures
struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String
var filesize: Int
}
public struct RealDebridIA: Codable, Hashable, Sendable {
let hash: String
let expiryTimeStamp: Double
var files: [RealDebridIAFile] = []
var batches: [RealDebridIABatch] = []
}
public struct RealDebridIABatch: Codable, Hashable, Sendable {
let files: [RealDebridIABatchFile]
}
public struct RealDebridIABatchFile: Codable, Hashable, Sendable {
let id: Int
let fileName: String
}
public struct RealDebridIAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
public enum RealDebridIAStatus: Codable, Hashable, Sendable {
case full
case partial
case none
}
// MARK: - addMagnet endpoint
public struct AddMagnetResponse: Codable, Sendable {
let id: String
let uri: String
}
// MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
let split, progress: Int
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String?
let speed: Int?
let seeders: Int?
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended, speed, seeders
}
}
struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
}
public struct UserTorrentsResponse: Codable, Sendable {
let id, filename, hash: String
let bytes: Int
let host: String
let split, progress: Int
let status, added: String
let links: [String]
let speed, seeders: Int?
let ended: String?
}
// MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks, crc: Int
let download: String
let streamable: Int
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, crc, download, streamable
}
}
// MARK: - User downloads list
public struct UserDownloadsResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks: Int
let download: String
let streamable: Int
let generated: String
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, download, streamable, generated
}
}

View file

@ -7,12 +7,56 @@
import Foundation
public struct SearchResult: Hashable, Codable, Sendable {
// A raw search result structure displayed on the UI
struct SearchResult: Codable, Hashable, Sendable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let magnet: Magnet
let seeders: 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 {
// Contains both search results and magnet links for scalability
struct SearchRequestResult: Sendable {
let results: [SearchResult]
let magnets: [Magnet]
}
struct ScrapingError: Error {}
}

View file

@ -2,31 +2,18 @@
// SettingsModels.swift
// Ferrite
//
// Created by Brian Dashore on 8/11/22.
// Created by Brian Dashore on 3/20/23.
//
import Foundation
public enum DefaultMagnetActionType: Int, CaseIterable {
// Let the user choose
case none = 0
enum DefaultAction: Codable, CaseIterable, Hashable {
static var allCases: [DefaultAction] {
[.none, .share, .kodi, .custom(name: "", listId: "")]
}
// Open in actions come first
case webtor = 1
// Sharing actions come last
case shareMagnet = 2
}
public enum DefaultDebridActionType: Int, CaseIterable {
// Let the user choose
case none = 0
// Open in actions come first
case outplayer = 1
case vlc = 2
case infuse = 3
// Sharing actions come last
case shareDownload = 4
case none
case share
case kodi
case custom(name: String, listId: String)
}

View file

@ -7,47 +7,51 @@
import Foundation
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
case json
case text
}
public struct SourceListJson: Codable, Sendable {
let name: String
let author: String
var sources: [SourceJson]
}
public struct SourceJson: Codable, Hashable, Sendable {
struct SourceJson: Codable, Hashable, Sendable, PluginJson {
let name: String
let version: Int16
let minVersion: String?
let baseUrl: String?
let about: String?
let website: String?
let dynamicWebsite: Bool?
let fallbackUrls: [String]?
var dynamicBaseUrl: Bool?
var author: String?
var listId: UUID?
let trackers: [String]?
let api: SourceApiJson?
let jsonParser: SourceJsonParserJson?
let rssParser: SourceRssParserJson?
let htmlParser: SourceHtmlParserJson?
let author: String?
let listId: UUID?
let listName: String?
let tags: [PluginTagJson]?
}
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
extension SourceJson {
// Fetches all tags without optional requirement
func getTags() -> [PluginTagJson] {
tags ?? []
}
}
enum SourcePreferredParser: Int16, CaseIterable, Sendable {
// case none = 0
case scraping = 1
case rss = 2
case siteApi = 3
}
public struct SourceApiJson: Codable, Hashable, Sendable {
struct SourceApiJson: Codable, Hashable, Sendable {
let apiUrl: String?
let clientId: SourceApiCredentialJson?
let clientSecret: SourceApiCredentialJson?
}
public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let query: String?
let value: String?
let dynamic: Bool?
@ -56,52 +60,58 @@ public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
let expiryLength: Double?
}
public struct SourceJsonParserJson: Codable, Hashable, Sendable {
struct SourceJsonParserJson: Codable, Hashable, Sendable {
let searchUrl: String
let request: SourceRequestJson?
let results: String?
let subResults: String?
let magnetHash: SouceComplexQueryJson?
let magnetLink: SouceComplexQueryJson?
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let title: SourceComplexQueryJson
let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
public struct SourceRssParserJson: Codable, Hashable, Sendable {
struct SourceRssParserJson: Codable, Hashable, Sendable {
let rssUrl: String?
let searchUrl: String
let request: SourceRequestJson?
let items: String
let magnetHash: SouceComplexQueryJson?
let magnetLink: SouceComplexQueryJson?
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let title: SourceComplexQueryJson
let magnetHash: SourceComplexQueryJson?
let magnetLink: SourceComplexQueryJson?
let subName: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String
struct SourceHtmlParserJson: Codable, Hashable, Sendable {
let searchUrl: String?
let request: SourceRequestJson?
let rows: String
let title: SourceComplexQueryJson
let magnet: SourceMagnetJson
let title: SouceComplexQueryJson?
let size: SouceComplexQueryJson?
let subName: SourceComplexQueryJson?
let size: SourceComplexQueryJson?
let sl: SourceSLJson?
}
public struct SouceComplexQueryJson: Codable, Hashable, Sendable {
struct SourceComplexQueryJson: Codable, Hashable, Sendable {
let query: String
let discriminator: String?
let attribute: String?
let regex: String?
}
public struct SourceMagnetJson: Codable, Hashable, Sendable {
struct SourceMagnetJson: Codable, Hashable, Sendable {
let query: String
let attribute: String
let regex: String?
let externalLinkQuery: String?
}
public struct SourceSLJson: Codable, Hashable, Sendable {
struct SourceSLJson: Codable, Hashable, Sendable {
let seeders: String?
let leechers: String?
let combined: String?
@ -110,3 +120,9 @@ public struct SourceSLJson: Codable, Hashable, Sendable {
let seederRegex: String?
let leecherRegex: String?
}
struct SourceRequestJson: Codable, Hashable, Sendable {
let method: String?
let headers: [String: String]?
let body: String?
}

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

@ -0,0 +1,38 @@
//
// Plugin.swift
// Ferrite
//
// Created by Brian Dashore on 1/25/23.
//
import CoreData
import Foundation
protocol Plugin: ObservableObject, NSManagedObject {
var id: UUID { get set }
var listId: UUID? { get set }
var name: String { get set }
var version: Int16 { get set }
var author: String { get set }
var about: String? { get set }
var website: String? { get set }
var enabled: Bool { get set }
var tags: NSOrderedSet? { get set }
func getTags() -> [PluginTagJson]
}
extension Plugin {
var tagArray: [PluginTag] {
tags?.array as? [PluginTag] ?? []
}
}
protocol PluginJson: Hashable {
var name: String { get }
var version: Int16 { get }
var author: String? { get }
var listId: UUID? { get }
var listName: String? { get }
var tags: [PluginTagJson]? { get }
func getTags() -> [PluginTagJson]
}

View file

@ -0,0 +1,47 @@
//
// Application.swift
// Ferrite
//
// Created by Brian Dashore on 9/16/22.
//
// A thread-safe UIApplication alternative for specifying app properties
//
import Foundation
class Application {
static let shared = Application()
// OS name for Plugins to read. Lowercase for ease of use
let os = "ios"
// Minimum OS version that Ferrite runs on
var minVersion: String {
Bundle.main.object(forInfoDictionaryKey: "MinimumOSVersion") as? String ?? "0.0"
}
// Grabs the current user's OS version
let osVersion: OperatingSystemVersion = ProcessInfo().operatingSystemVersion
// Application version and build variables
var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}
var appBuild: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
}
// Debug = development, Nightly = actions, Release = stable
var buildType: String {
#if DEBUG
return "Debug"
#else
if Bundle.main.isNightly {
return "Nightly"
} else {
return "Release"
}
#endif
}
}

View file

@ -0,0 +1,36 @@
//
// CodableWrapper.swift
// Ferrite
//
// Created by Brian Dashore on 3/20/23.
//
// From https://forums.swift.org/t/rawrepresentable-conformance-leads-to-crash/51912/4
// Prevents recursion when using Codable with RawRepresentable without needing manual conformance
import Foundation
struct CodableWrapper<Value: Codable> {
var value: Value
}
extension CodableWrapper: RawRepresentable {
var rawValue: String {
guard
let data = try? JSONEncoder().encode(value),
let string = String(data: data, encoding: .utf8)
else {
return ""
}
return string
}
init?(rawValue: String) {
guard
let data = rawValue.data(using: .utf8),
let decoded = try? JSONDecoder().decode(Value.self, from: data)
else {
return nil
}
value = decoded
}
}

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,18 +7,36 @@
import Foundation
public class BackupManager: ObservableObject {
var toastModel: ToastViewModel?
class BackupManager: ObservableObject {
// Constant variable for backup versions
private let latestBackupVersion: Int = 2
var logManager: LoggingManager?
@Published var showRestoreAlert = false
@Published var showRestoreCompletedAlert = false
@Published var restoreCompletedMessage: [String] = []
@Published var backupUrls: [URL] = []
@Published var backupSourceNames: [String] = []
@Published var selectedBackupUrl: URL?
func createBackup() {
var backup = Backup()
@MainActor
private func updateRestoreCompletedMessage(newString: String) {
restoreCompletedMessage.append(newString)
}
@MainActor
private func toggleRestoreCompletedAlert() {
showRestoreCompletedAlert.toggle()
}
@MainActor
private func updateBackupUrls(newUrl: URL) {
backupUrls.append(newUrl)
}
func createBackup() async {
var backup = Backup(version: latestBackupVersion)
let backgroundContext = PersistenceController.shared.backgroundContext
let bookmarkRequest = Bookmark.fetchRequest()
@ -68,16 +86,14 @@ public class BackupManager: ObservableObject {
backup.sourceNames = sources.map(\.name)
}
let sourceListRequest = SourceList.fetchRequest()
if let sourceLists = try? backgroundContext.fetch(sourceListRequest) {
backup.sourceLists = sourceLists.map {
SourceListBackupJson(
name: $0.name,
author: $0.author,
id: $0.id.uuidString,
urlString: $0.urlString
)
}
let actionRequest = Action.fetchRequest()
if let actions = try? backgroundContext.fetch(actionRequest) {
backup.actionNames = actions.map(\.name)
}
let pluginListRequest = PluginList.fetchRequest()
if let pluginLists = try? backgroundContext.fetch(pluginListRequest) {
backup.pluginListUrls = pluginLists.map(\.urlString)
}
do {
@ -91,18 +107,21 @@ public class BackupManager: ObservableObject {
let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb")
try encodedJson.write(to: writeUrl)
backupUrls.append(writeUrl)
await updateBackupUrls(newUrl: writeUrl)
} catch {
print(error)
await logManager?.error("Backup: \(error)")
}
}
// Backup is in local documents directory, so no need to restore it from the shared URL
func restoreBackup() {
// Pass the pluginManager reference since it's not used throughout the class like logManager
func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async {
guard let backupUrl = selectedBackupUrl else {
Task {
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
}
await logManager?.error(
"Backup restore: Could not find backup in app directory.",
description: "Could not find the selected backup in the local directory."
)
return
}
@ -110,64 +129,69 @@ public class BackupManager: ObservableObject {
let backgroundContext = PersistenceController.shared.backgroundContext
do {
// Delete all relevant entities to prevent issues with restoration if overwrite is selected
if doOverwrite {
try PersistenceController.shared.batchDelete("Bookmark")
try PersistenceController.shared.batchDelete("History")
try PersistenceController.shared.batchDelete("HistoryEntry")
try PersistenceController.shared.batchDelete("PluginList")
try PersistenceController.shared.batchDelete("Source")
try PersistenceController.shared.batchDelete("Action")
}
let file = try Data(contentsOf: backupUrl)
let backup = try JSONDecoder().decode(Backup.self, from: file)
if let bookmarks = backup.bookmarks {
for bookmark in bookmarks {
PersistenceController.shared.createBookmark(bookmark)
PersistenceController.shared.createBookmark(bookmark, performSave: false)
}
}
if let storedHistories = backup.history {
for storedHistory in storedHistories {
for storedEntry in storedHistory.entries {
PersistenceController.shared.createHistory(entryJson: storedEntry, date: storedHistory.date)
PersistenceController.shared.createHistory(
storedEntry,
performSave: false,
isBackup: true,
date: storedHistory.date
)
}
}
}
if let storedLists = backup.sourceLists {
let version = backup.version ?? -1
if let storedLists = backup.sourceLists, version < 2 {
// Only present in v1 or no version backups
for list in storedLists {
let sourceListRequest = SourceList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", list.urlString)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", list.author, list.name)
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
sourceListRequest.fetchLimit = 1
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
continue
}
let newSourceList = SourceList(context: backgroundContext)
newSourceList.name = list.name
newSourceList.urlString = list.urlString
newSourceList.id = UUID(uuidString: list.id) ?? UUID()
newSourceList.author = list.author
try await pluginManager.addPluginList(list.urlString, existingPluginList: nil)
}
} else if let pluginListUrls = backup.pluginListUrls {
// v2 and up
for listUrl in pluginListUrls {
try await pluginManager.addPluginList(listUrl, existingPluginList: nil)
}
}
backupSourceNames = backup.sourceNames ?? []
if let sourceNames = backup.sourceNames {
await updateRestoreCompletedMessage(newString: sourceNames.isEmpty ? "No sources need to be reinstalled" : "Reinstall sources: \(sourceNames.joined(separator: ", "))")
}
if let actionNames = backup.actionNames {
await updateRestoreCompletedMessage(newString: actionNames.isEmpty ? "No actions need to be reinstalled" : "Reinstall actions: \(actionNames.joined(separator: ", "))")
}
PersistenceController.shared.save(backgroundContext)
// if iOS 14 is available, sleep to prevent any issues with alerts
if #available(iOS 15, *) {
showRestoreCompletedAlert.toggle()
} else {
Task {
try? await Task.sleep(seconds: 0.1)
Task { @MainActor in
showRestoreCompletedAlert.toggle()
}
}
}
await toggleRestoreCompletedAlert()
} catch {
Task {
await toastModel?.updateToastDescription("Backup restore: \(error)")
}
await logManager?.error(
"Backup restore: \(error)",
description: "A backup restore error was logged"
)
}
}
@ -184,7 +208,8 @@ public class BackupManager: ObservableObject {
}
} catch {
Task {
await toastModel?.updateToastDescription("Backup removal: \(error)")
await logManager?.error("Backup removal: \(error)")
print("Backup removal: \(error)")
}
}
}
@ -213,7 +238,7 @@ public class BackupManager: ObservableObject {
selectedBackupUrl = localBackupPath
} catch {
Task {
await toastModel?.updateToastDescription("Backup copy: \(error)")
await logManager?.error("Backup copy: \(error)")
}
}
}

View file

@ -9,225 +9,485 @@ import Foundation
import SwiftUI
@MainActor
public class DebridManager: ObservableObject {
class DebridManager: ObservableObject {
// Linked classes
var toastModel: ToastViewModel?
let realDebrid: RealDebrid = .init()
var logManager: LoggingManager?
@Published var realDebrid: RealDebrid = .init()
@Published var allDebrid: AllDebrid = .init()
@Published var premiumize: Premiumize = .init()
@Published var torbox: TorBox = .init()
@Published var offcloud: OffCloud = .init()
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
// UI Variables
@Published var showWebView: Bool = false
@Published var showLoadingProgress: Bool = false
@Published var showAuthSession: Bool = false
@Published var enabledDebrids: [DebridSource] = []
// Service agnostic variables
var currentDebridTask: Task<Void, Never>?
// RealDebrid auth variables
@Published var realDebridEnabled: Bool = false {
@Published var selectedDebridSource: DebridSource? {
didSet {
UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled")
UserDefaults.standard.set(selectedDebridSource?.id ?? "", forKey: "Debrid.PreferredService")
}
}
@Published var realDebridAuthProcessing: Bool = false
var realDebridAuthUrl: String = ""
var selectedDebridItem: DebridIA?
var selectedDebridFile: DebridIAFile?
var requiresUnrestrict: Bool = false
// RealDebrid fetch variables
@Published var realDebridIAValues: [RealDebridIA] = []
var realDebridDownloadUrl: String = ""
// TODO: Figure out a way to remove this var
private var selectedOAuthDebridSource: OAuthDebridSource?
@Published var filteredIAStatus: Set<IAStatus> = []
var currentDebridTask: Task<Void, Never>?
var downloadUrl: String = ""
var authUrl: URL?
@Published var showDeleteAlert: Bool = false
// TODO: Switch to an individual item based sheet system to remove these variables
var selectedRealDebridItem: RealDebridIA?
var selectedRealDebridFile: RealDebridIAFile?
var selectedRealDebridID: String?
@Published var showWebLoginAlert: Bool = false
@Published var showNotImplementedAlert: Bool = false
@Published var notImplementedMessage: String = ""
init() {
realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled")
// Update the UI for debrid services that are enabled
enabledDebrids = debridSources.filter(\.isLoggedIn)
// Set the preferred service. Contains migration logic for earlier versions
if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") {
let debridServiceId: String?
if let preferredServiceInt = Int(rawPreferredService) {
debridServiceId = migratePreferredService(preferredServiceInt)
} else {
debridServiceId = rawPreferredService
}
// Only set the debrid source if it's logged in
// Otherwise remove the key
let tempDebridSource = debridSources.first { $0.id == debridServiceId }
if tempDebridSource?.isLoggedIn ?? false {
selectedDebridSource = tempDebridSource
} else {
UserDefaults.standard.removeObject(forKey: "Debrid.PreferredService")
}
}
}
public func populateDebridHashes(_ resultHashes: [String]) async {
do {
let now = Date()
// TODO: Remove after v0.8.0
// Function to migrate the preferred service to the new string ID format
private func migratePreferredService(_ idInt: Int) -> String? {
// Undo the EnabledDebrids key
UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray")
// If a hash isn't found in the IA, update it
// If the hash is expired, remove it and update it
let sendHashes = resultHashes.filter { hash in
if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }) {
if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp {
realDebridIAValues.remove(at: IAIndex)
return true
} else {
return false
}
return DebridType(rawValue: idInt)?.toString()
}
// Wrapper function to match error descriptions
// Error can be suppressed to end user but must be printed in logs
private func sendDebridError(
_ error: Error,
prefix: String,
presentError: Bool = true,
cancelString: String? = nil
) async {
let error = error as NSError
if presentError {
switch error.code {
case -1009:
logManager?.info(
"DebridManager: The connection is offline",
description: "The connection is offline"
)
case -999:
if let cancelString {
logManager?.info(cancelString, description: cancelString)
} else {
return true
break
}
default:
logManager?.error("\(prefix): \(error)")
}
if !sendHashes.isEmpty {
let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes)
realDebridIAValues += fetchedIAValues
}
} catch {
let error = error as NSError
if error.code != -999 {
toastModel?.updateToastDescription("RealDebrid hash error: \(error)")
}
print("RealDebrid hash error: \(error)")
}
}
public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus {
guard let result else {
// Cleans all cached IA values in the event of a full IA refresh
func clearIAValues() {
for debridSource in debridSources {
debridSource.IAValues = []
}
}
// Clears all selected files and items
func clearSelectedDebridItems() {
selectedDebridItem = nil
selectedDebridFile = nil
}
// Common function to populate hashes for debrid services
func populateDebridIA(_ resultMagnets: [Magnet]) async {
for debridSource in debridSources {
if !debridSource.isLoggedIn {
continue
}
// Don't exit the function if the API fetch errors
do {
try await debridSource.instantAvailability(magnets: resultMagnets)
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) IA fetch error")
}
}
}
// Common function to match a magnet hash with a provided debrid service
func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
guard let magnetHash = magnet.hash else {
return .none
}
guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else {
return .none
}
if debridMatch.batches.isEmpty {
return .full
if let selectedDebridSource,
let match = selectedDebridSource.IAValues.first(where: { magnetHash == $0.magnet.hash })
{
return match.files.count > 1 ? .partial : .full
} else {
return .partial
return .none
}
}
public func setSelectedRdResult(result: SearchResult) -> Bool {
guard let magnetHash = result.magnetHash else {
toastModel?.updateToastDescription("Could not find the torrent magnet hash")
func selectDebridResult(magnet: Magnet) -> Bool {
guard let magnetHash = magnet.hash else {
logManager?.error("DebridManager: Could not find the magnet hash")
return false
}
if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) {
selectedRealDebridItem = realDebridItem
guard let selectedSource = selectedDebridSource else {
return false
}
if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) {
selectedDebridItem = IAItem
if IAItem.files.count == 1 {
selectedDebridFile = IAItem.files[safe: 0]
}
return true
} else {
toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
return false
}
}
public func authenticateRd() async {
do {
realDebridAuthProcessing = true
let verificationResponse = try await realDebrid.getVerificationInfo()
// MARK: - Authentication UI linked functions
realDebridAuthUrl = verificationResponse.directVerificationURL
showWebView.toggle()
try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode)
realDebridEnabled = true
} catch {
toastModel?.updateToastDescription("RealDebrid authentication error: \(error)")
realDebrid.authTask?.cancel()
print("RealDebrid authentication error: \(error)")
}
}
public func logoutRd() async {
do {
try await realDebrid.deleteTokens()
realDebridEnabled = false
realDebridAuthProcessing = false
} catch {
toastModel?.updateToastDescription("RealDebrid logout error: \(error)")
print("RealDebrid logout error: \(error)")
}
}
public func fetchRdDownload(searchResult: SearchResult) async {
// Common function to delegate what debrid service to authenticate with
func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
defer {
currentDebridTask = nil
showLoadingProgress = false
// Don't cancel processing if using OAuth
if !(debridSource is OAuthDebridSource) {
debridSource.authProcessing = false
}
if enabledDebrids.count == 1 {
selectedDebridSource = debridSource
}
}
showLoadingProgress = true
// Set an API key if manually provided
if let apiKey {
debridSource.setApiKey(apiKey)
enabledDebrids.append(debridSource)
guard let magnetLink = searchResult.magnetLink else {
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("RealDebrid error: Invalid magnet link")
return
}
// Processing has started
debridSource.authProcessing = true
if let pollingSource = debridSource as? PollingDebridSource {
do {
let authUrl = try await pollingSource.getAuthUrl()
if validateAuthUrl(authUrl) {
try await pollingSource.authTask?.value
enabledDebrids.append(debridSource)
} else {
throw DebridError.AuthQuery(description: "The authentication URL was invalid")
}
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
pollingSource.authTask?.cancel()
}
} else if let oauthSource = debridSource as? OAuthDebridSource {
do {
let tempAuthUrl = try oauthSource.getAuthUrl()
selectedOAuthDebridSource = oauthSource
validateAuthUrl(tempAuthUrl, useAuthSession: true)
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
}
} else {
// Let the user know that a traditional auth method doesn't exist
showWebLoginAlert.toggle()
logManager?.error(
"DebridManager: Auth: \(debridSource.id) does not have a login portal.",
showToast: false
)
return
}
}
// Get a truncated manual API key if it's being used
func getManualAuthKey(_ debridSource: some DebridSource) async -> String? {
if let debridToken = debridSource.manualToken {
let splitString = debridToken.suffix(4)
if debridToken.count > 4 {
return String(repeating: "*", count: debridToken.count - 4) + splitString
} else {
return String(splitString)
}
} else {
return nil
}
}
// Wrapper function to validate and present an auth URL to the user
@discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
guard let url else {
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
return false
}
authUrl = url
if useAuthSession {
showAuthSession.toggle()
} else {
showWebView.toggle()
}
return true
}
// Currently handles Premiumize callback
func handleAuthCallback(url: URL?, error: Error?) async {
defer {
if enabledDebrids.count == 1 {
selectedDebridSource = selectedOAuthDebridSource
}
selectedOAuthDebridSource?.authProcessing = false
}
do {
guard let oauthDebridSource = selectedOAuthDebridSource else {
throw DebridError.AuthQuery(description: "OAuth source couldn't be found for callback. Aborting.")
}
if let error {
throw DebridError.AuthQuery(description: "OAuth callback Error: \(error)")
}
if let callbackUrl = url {
try oauthDebridSource.handleAuthCallback(url: callbackUrl)
enabledDebrids.append(oauthDebridSource)
} else {
throw DebridError.AuthQuery(description: "The callback URL was invalid")
}
} catch {
await sendDebridError(error, prefix: "Premiumize authentication error (callback)")
}
}
// MARK: - Logout UI functions
func logout(_ debridSource: some DebridSource) async {
await debridSource.logout()
if selectedDebridSource?.id == debridSource.id {
selectedDebridSource = nil
}
enabledDebrids.removeAll { $0.id == debridSource.id }
}
// MARK: - Debrid fetch UI linked functions
// Common function to delegate what debrid service to fetch from
// Cloudinfo is used for any extra information provided by debrid cloud
func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
defer {
logManager?.hideIndeterminateToast()
if !requiresUnrestrict {
clearSelectedDebridItems()
}
currentDebridTask = nil
}
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
self.currentDebridTask?.cancel()
self.currentDebridTask = nil
})
guard let debridSource = selectedDebridSource else {
return
}
do {
// Cleanup beforehand
requiresUnrestrict = false
if let cloudInfo {
downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? ""
return
}
if let magnet {
let (restrictedFile, newIA) = try await debridSource.getRestrictedFile(
magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile
)
// Indicate that a link needs to be selected (batch)
if let newIA {
if newIA.files.isEmpty {
throw DebridError.EmptyData
}
selectedDebridItem = newIA
requiresUnrestrict = true
return
}
guard let restrictedFile else {
throw DebridError.FailedRequest(description: "No files found for your request")
}
// Update the UI
downloadUrl = try await debridSource.unrestrictFile(restrictedFile)
} else {
throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API")
}
// Fetch one more time to add updated data into the RD cloud cache
await fetchDebridCloud(bypassTTL: true)
} catch {
switch error {
case DebridError.IsCaching:
showDeleteAlert.toggle()
default:
await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled")
}
}
}
func unrestrictDownload() async {
defer {
logManager?.hideIndeterminateToast()
requiresUnrestrict = false
clearSelectedDebridItems()
currentDebridTask = nil
}
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
self.currentDebridTask?.cancel()
self.currentDebridTask = nil
})
guard let debridFile = selectedDebridFile, let debridSource = selectedDebridSource else {
logManager?.error("DebridManager: Could not unrestrict the selected debrid file.")
return
}
do {
var fileIds: [Int] = []
let downloadLink = try await debridSource.unrestrictFile(debridFile)
if let iaFile = selectedRealDebridFile {
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
return
}
downloadUrl = downloadLink
} catch {
await sendDebridError(error, prefix: "\(debridSource.id) unrestrict error", cancelString: "Unrestrict cancelled")
}
}
fileIds = iaBatchFromFile.files.map(\.id)
}
// Wrapper to handle cloud fetching
func fetchDebridCloud(bypassTTL: Bool = false) async {
guard let selectedSource = selectedDebridSource else {
return
}
// If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link
let existingTorrents = try await realDebrid.userTorrents().filter { $0.hash == selectedRealDebridItem?.hash }
if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL {
do {
// Populates the inner downloads and magnet arrays
try await selectedSource.getUserDownloads()
try await selectedSource.getUserMagnets()
// If the links match from a user's downloads, no need to re-run a download
if let existingTorrent = existingTorrents[safe: 0],
let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0]
{
let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink }
if let existingLink = existingLinks[safe: 0]?.download {
realDebridDownloadUrl = existingLink
} else {
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
realDebridDownloadUrl = downloadLink
}
} else {
// Add a magnet after all the cache checks fail
selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink)
if let realDebridId = selectedRealDebridID {
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
realDebridDownloadUrl = downloadLink
} else {
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
// Update the TTL to 5 minutes from now
selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300
} catch {
let error = error as NSError
if error.code != -999 {
await sendDebridError(error, prefix: "\(selectedSource.id) cloud fetch error")
}
}
}
}
func deleteCloudDownload(_ download: DebridCloudDownload) async {
guard let selectedSource = selectedDebridSource else {
return
}
do {
try await selectedSource.deleteUserDownload(downloadId: download.id)
await fetchDebridCloud(bypassTTL: true)
} catch {
switch error {
case RealDebridError.EmptyTorrents:
showDeleteAlert.toggle()
case DebridError.NotImplemented:
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
notImplementedMessage = message
showNotImplementedAlert.toggle()
logManager?.error(
"DebridManager: \(message)",
showToast: false
)
default:
let error = error as NSError
switch error.code {
case -999:
toastModel?.updateToastDescription("Download cancelled", newToastType: .info)
default:
toastModel?.updateToastDescription("RealDebrid download error: \(error)")
}
await deleteRdTorrent()
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
}
showLoadingProgress = false
print("RealDebrid download error: \(error)")
}
}
public func deleteRdTorrent() async {
if let realDebridId = selectedRealDebridID {
try? await realDebrid.deleteTorrent(debridID: realDebridId)
func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async {
guard let selectedSource = selectedDebridSource else {
return
}
selectedRealDebridID = nil
do {
try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id)
await fetchDebridCloud(bypassTTL: true)
} catch {
switch error {
case DebridError.NotImplemented:
let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website."
notImplementedMessage = message
showNotImplementedAlert.toggle()
logManager?.error(
"DebridManager: \(message)",
showToast: false
)
default:
await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error")
}
}
}
}

View file

@ -0,0 +1,181 @@
//
// LoggingManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/19/22.
//
import SwiftUI
@MainActor
class LoggingManager: ObservableObject {
let logFormatter = DateFormatter()
struct Log: Hashable {
let level: LogLevel
let message: String
let timeStamp: Date = .init()
var isExpanded: Bool = false
func toMessage() -> String {
"[\(level.rawValue)]: \(message)"
}
}
enum LogLevel: String, Identifiable {
var id: Int {
hashValue
}
case info = "INFO"
case warn = "WARN"
case error = "ERROR"
}
@Published var messageArray: [Log] = []
@Published var showLogExportedAlert = false
// Toast variables
@Published var toastDescription: String? = nil {
didSet {
Task {
try? await Task.sleep(seconds: 0.1)
showToast = true
try? await Task.sleep(seconds: 3)
showToast = false
toastType = .error
}
}
}
@Published var showToast: Bool = false
// Default the toast type to error since the majority of toasts are errors
@Published var toastType: LogLevel = .error
var showErrorToasts: Bool {
UserDefaults.standard.bool(forKey: "Debug.ShowErrorToasts")
}
@Published var indeterminateToastDescription: String? = nil
@Published var indeterminateCancelAction: (() -> Void)? = nil
@Published var showIndeterminateToast: Bool = false
init() {
logFormatter.dateStyle = .short
logFormatter.timeStyle = .long
}
// MARK: - Logging functions
// TODO: Maybe append to a constant logfile?
func info(_ message: String,
description: String? = nil)
{
let log = Log(
level: .info,
message: message
)
if let description {
toastType = .info
toastDescription = description
}
messageArray.append(log)
print("LOG: \(log.toMessage())")
}
func warn(_ message: String,
description: String? = nil)
{
let log = Log(
level: .warn,
message: message
)
if let description {
toastType = .warn
toastDescription = description
}
messageArray.append(log)
print("LOG: \(log.toMessage())")
}
func error(_ message: String,
description: String? = nil,
showToast: Bool = true)
{
let log = Log(
level: .error,
message: message
)
// If a task is run in parallel, don't show a toast on error
// Only gate generic error toasts behind the settings option
if showToast {
if let description {
toastDescription = description
} else if showErrorToasts {
toastDescription = "An error was logged. Please look at logs in Settings."
}
}
messageArray.append(log)
print("LOG: \(log.toMessage())")
}
// MARK: - Indeterminate functions
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
indeterminateToastDescription = description
if let cancelAction {
indeterminateCancelAction = cancelAction
}
if !showIndeterminateToast {
showIndeterminateToast = true
}
}
func hideIndeterminateToast() {
showIndeterminateToast = false
indeterminateToastDescription = ""
indeterminateCancelAction = nil
}
func exportLogs() {
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")
let logPath = logFolderPath.appendingPathComponent(logFileName)
logFormatter.dateStyle = .short
logFormatter.timeStyle = .long
let joinedMessages = messageArray.map { "\(logFormatter.string(from: $0.timeStamp)): \($0.toMessage())" }.joined(separator: "\n")
do {
if FileManager.default.fileExists(atPath: logPath.path) {
try FileManager.default.removeItem(at: logPath)
} else if !FileManager.default.fileExists(atPath: logFolderPath.path) {
try FileManager.default.createDirectory(atPath: logFolderPath.path, withIntermediateDirectories: true, attributes: nil)
}
try joinedMessages.write(to: logPath, atomically: true, encoding: .utf8)
info("Log \(logFileName) was written to path \(logPath.description)")
showLogExportedAlert.toggle()
} catch {
self.error(
"Log export for file \(logFileName): \(error)",
description: "Exporting your log file failed. Please check the logs page."
)
}
}
}

View file

@ -7,16 +7,9 @@
import SwiftUI
enum ViewTab {
case search
case sources
case settings
case library
}
@MainActor
class NavigationViewModel: ObservableObject {
var toastModel: ToastViewModel?
var logManager: LoggingManager?
// Used between SearchResultsView and MagnetChoiceView
enum ChoiceSheetType: Identifiable {
@ -24,21 +17,68 @@ class NavigationViewModel: ObservableObject {
hashValue
}
case magnet
case action
case batch
case activity
}
@Published var isEditingSearch: Bool = false
@Published var isSearching: Bool = false
enum ViewTab {
case search
case plugins
case settings
case library
}
@Published var selectedSearchResult: SearchResult?
enum LibraryPickerSegment {
case bookmarks
case history
case debridCloud
}
enum PluginPickerSegment {
case sources
case actions
}
@Published var selectedMagnet: Magnet?
@Published var selectedHistoryInfo: HistoryEntryJson?
@Published var resultFromCloud: Bool = false
// For giving information in magnet choice sheet
@Published var selectedTitle: String?
@Published var selectedBatchTitle: String?
@Published var selectedTitle: String = ""
@Published var selectedBatchTitle: String = ""
@Published var hideNavigationBar = false
// 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 currentChoiceSheet: ChoiceSheetType?
var activityItems: [Any] = []
@ -47,98 +87,38 @@ class NavigationViewModel: ObservableObject {
@Published var showLocalActivitySheet = false
@Published var selectedTab: ViewTab = .search
@Published var showSearchProgress: Bool = false
// Used between SourceListView and SourceSettingsView
@Published var showSourceSettings: Bool = false
var selectedSource: Source?
// Used between service views and editor views in Settings
@Published var selectedPluginList: PluginList?
@Published var selectedKodiServer: KodiServer?
@Published var showSourceListEditor: Bool = false
var selectedSourceList: SourceList?
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
@Published var pluginPickerSelection: PluginPickerSegment = .sources
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
@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"
]
public func runDebridAction(urlString: String, _ action: DefaultDebridActionType? = nil) {
let selectedAction = action ?? defaultDebridAction
switch selectedAction {
case .none:
currentChoiceSheet = .magnet
case .outplayer:
if let downloadUrl = URL(string: "outplayer://\(urlString)") {
UIApplication.shared.open(downloadUrl)
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 {
toastModel?.updateToastDescription("Could not create an Outplayer URL")
}
case .vlc:
if let downloadUrl = URL(string: "vlc://\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.updateToastDescription("Could not create a VLC URL")
}
case .infuse:
if let downloadUrl = URL(string: "infuse://x-callback-url/play?url=\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.updateToastDescription("Could not create a Infuse URL")
}
case .shareDownload:
if let downloadUrl = URL(string: urlString), currentChoiceSheet == nil {
activityItems = [downloadUrl]
currentChoiceSheet = .activity
} else {
toastModel?.updateToastDescription("Could not create object for sharing")
lastSearchPromptIndex = num
searchPrompt = searchBarTextArray[safe: num] ?? "Search"
}
} else {
lastSearchPromptIndex = -1
searchPrompt = "Search"
}
}
public func runMagnetAction(magnetString: String?, _ action: DefaultMagnetActionType? = nil) {
let selectedAction = action ?? defaultMagnetAction
guard let magnetLink = magnetString else {
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("Magnet action error: The magnet link is invalid.")
return
}
switch selectedAction {
case .none:
currentChoiceSheet = .magnet
case .webtor:
if let url = URL(string: "https://webtor.io/#/show?magnet=\(magnetLink)") {
UIApplication.shared.open(url)
} else {
toastModel?.updateToastDescription("Could not create a WebTor URL")
}
case .shareMagnet:
if let magnetUrl = URL(string: magnetLink),
currentChoiceSheet == nil
{
activityItems = [magnetUrl]
currentChoiceSheet = .activity
} else {
toastModel?.updateToastDescription("Could not create object for sharing")
}
}
}
public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) {
let backgroundContext = PersistenceController.shared.backgroundContext
// The timeStamp and date are nil because the create function will make them automatically
PersistenceController.shared.createHistory(
entryJson: HistoryEntryJson(
name: name ?? "",
subName: subName,
url: url ?? "",
timeStamp: nil,
source: source
),
date: nil
)
PersistenceController.shared.save(backgroundContext)
}
}

View file

@ -0,0 +1,852 @@
//
// PluginManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/25/22.
//
import Foundation
import SwiftUI
import Yams
class PluginManager: ObservableObject {
var logManager: LoggingManager?
let kodi: Kodi = .init()
@Published var availableSources: [SourceJson] = []
@Published var availableActions: [ActionJson] = []
@Published var filteredInstalledSources: Set<Source> = []
@Published var showActionErrorAlert = false
@Published var actionErrorAlertMessage: String = ""
@Published var showActionSuccessAlert = false
@Published var actionSuccessAlertMessage: String = ""
@MainActor
private func cleanAvailablePlugins() {
availableSources = []
availableActions = []
}
@MainActor
private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
availableSources += newPlugins.availableSources
availableActions += newPlugins.availableActions
}
func fetchPluginsFromUrl() async {
let pluginListRequest = PluginList.fetchRequest()
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
await logManager?.error("PluginManager: No plugin lists found")
return
}
// Clean availablePlugin arrays for repopulation
await cleanAvailablePlugins()
await logManager?.info("Starting fetch of plugin lists")
await withTaskGroup(of: (AvailablePlugins?, String).self) { group in
for pluginList in pluginLists {
guard let url = URL(string: pluginList.urlString) else {
return
}
group.addTask {
var availablePlugins: AvailablePlugins?
do {
availablePlugins = try await self.fetchPluginList(pluginList: pluginList, url: url)
} catch {
let error = error as NSError
switch error.code {
case -999:
await self.logManager?.info("PluginManager: \(pluginList.name): List fetch cancelled")
case -1009:
await self.logManager?.info("PluginManager: \(pluginList.name): The connection is offline")
default:
await self.logManager?.error("Plugin fetch: \(pluginList.name): \(error)", showToast: false)
}
}
return (availablePlugins, pluginList.name)
}
}
var failedLists: [String] = []
for await (availablePlugins, pluginListName) in group {
if let availablePlugins {
await updateAvailablePlugins(availablePlugins)
} else {
failedLists.append(pluginListName)
}
}
if !failedLists.isEmpty {
let joinedLists = failedLists.joined(separator: ", ")
await logManager?.info(
"Plugins: Errors in plugin lists \(joinedLists). See above.",
description: "There were errors in plugin lists \(joinedLists). Check the logs for more details."
)
}
}
await logManager?.info("Plugin list fetch finished")
}
private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
var tempSources: [SourceJson] = []
var tempActions: [ActionJson] = []
// Always get the up-to-date source list
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let (data, _) = try await URLSession.shared.data(for: request)
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 {
// Faster and more performant to map instead of a for loop
tempSources += sources.compactMap { inputJson in
if checkAppVersion(minVersion: inputJson.minVersion) {
return SourceJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
about: inputJson.about,
website: inputJson.website,
dynamicWebsite: inputJson.dynamicWebsite,
fallbackUrls: inputJson.fallbackUrls,
trackers: inputJson.trackers,
api: inputJson.api,
jsonParser: inputJson.jsonParser,
rssParser: inputJson.rssParser,
htmlParser: inputJson.htmlParser,
author: pluginList.author,
listId: pluginList.id,
listName: pluginList.name,
tags: inputJson.tags
)
} else {
return nil
}
}
}
if let actions = pluginResponse.actions {
tempActions += actions.compactMap { inputJson in
if
let deeplink = inputJson.deeplink,
checkAppVersion(minVersion: inputJson.minVersion),
let filteredDeeplinks = getFilteredDeeplinks(deeplink)
{
return ActionJson(
name: inputJson.name,
version: inputJson.version,
minVersion: inputJson.minVersion,
about: inputJson.about,
website: inputJson.website,
requires: inputJson.requires,
deeplink: filteredDeeplinks,
author: pluginList.author,
listId: pluginList.id,
listName: pluginList.name,
tags: inputJson.tags
)
} else {
return nil
}
}
}
return AvailablePlugins(availableSources: tempSources, availableActions: tempActions)
}
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
let osArray = deeplinks.filter { deeplink in
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
}
if osArray.count == 1 {
return osArray
} else {
let universalArray = deeplinks.filter { deeplink in
deeplink.os.isEmpty
}
if universalArray.count == 1 {
return universalArray
} else {
return nil
}
}
}
// forType required to guide generic inferences
func fetchFilteredPlugins<PJ: PluginJson>(forType: PJ.Type,
installedPlugins: FetchedResults<some Plugin>,
searchText: String) -> [PJ]
{
let availablePlugins: [PJ] = fetchCastedPlugins(forType)
return availablePlugins
.filter { availablePlugin in
let pluginExists = installedPlugins.contains(where: {
availablePlugin.name == $0.name &&
availablePlugin.listId == $0.listId &&
availablePlugin.author == $0.author
})
if searchText.isEmpty {
return !pluginExists
} else {
return !pluginExists && availablePlugin.name.lowercased().contains(searchText.lowercased())
}
}
}
func fetchUpdatedPlugins<PJ: PluginJson>(forType: PJ.Type,
installedPlugins: FetchedResults<some Plugin>,
searchText: String) -> [PJ]
{
var updatedPlugins: [PJ] = []
let availablePlugins: [PJ] = fetchCastedPlugins(forType)
for plugin in installedPlugins {
if let availablePlugin = availablePlugins.first(where: {
plugin.listId == $0.listId &&
plugin.name == $0.name &&
plugin.author == $0.author
}),
availablePlugin.version > plugin.version
{
updatedPlugins.append(availablePlugin)
}
}
return updatedPlugins
.filter {
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
}
}
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
switch String(describing: PJ.self) {
case "SourceJson":
return availableSources as? [PJ] ?? []
case "ActionJson":
return availableActions as? [PJ] ?? []
default:
return []
}
}
// Checks if the current app version is supported by the source
private func checkAppVersion(minVersion: String?) -> Bool {
// If there's no min version, assume that every version is supported
guard let minVersion else {
return true
}
return Application.shared.appVersion >= minVersion
}
// Fetches sources using the background context
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
return Array(filteredInstalledSources)
} else if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
return sources.compactMap { $0 }
} else {
return []
}
}
@MainActor
func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
let context = PersistenceController.shared.backgroundContext
guard let urlString else {
logManager?.error("Default action: Could not run because the URL is invalid")
return
}
let defaultsKey: String
// Assume this is a magnet link
if urlString.starts(with: "magnet") {
defaultsKey = "Actions.DefaultMagnet"
} else {
defaultsKey = "Actions.DefaultDebrid"
}
if
let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let defaultAction = CodableWrapper<DefaultAction>(rawValue: rawValue)?.value
{
switch defaultAction {
case .none:
navModel.currentChoiceSheet = .action
case .share:
navModel.activityItems = [urlString]
navModel.currentChoiceSheet = .activity
case .kodi:
navModel.kodiExpanded = true
navModel.currentChoiceSheet = .action
case let .custom(name, listId):
let actionFetchRequest = Action.fetchRequest()
actionFetchRequest.fetchLimit = 1
actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", name, listId)
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
runDeeplinkAction(fetchedAction, urlString: urlString)
} else {
navModel.currentChoiceSheet = .action
UserDefaults.standard.set(CodableWrapper<DefaultAction>(value: .none).rawValue, forKey: "Actions.DefaultDebrid")
actionErrorAlertMessage =
"The default action could not be run. The action choice sheet has been opened. \n\n" +
"Please check your default actions in Settings"
showActionErrorAlert.toggle()
}
}
} else {
navModel.currentChoiceSheet = .action
}
}
// The iOS version of Ferrite only runs deeplink actions
@MainActor
func runDeeplinkAction(_ action: Action, urlString: String?) {
guard let deeplink = action.deeplink, let urlString else {
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
showActionErrorAlert.toggle()
logManager?.error("Could not run action: \(action.name) since there is no deeplink to execute.")
return
}
let playbackUrl = URL(string: deeplink.replacingOccurrences(of: "{link}", with: urlString))
if let playbackUrl {
UIApplication.shared.open(playbackUrl)
} else {
actionErrorAlertMessage = "Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!"
showActionErrorAlert.toggle()
logManager?.error("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
}
}
@MainActor
func sendToKodi(urlString: String?, server: KodiServer) async {
guard let urlString else {
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
showActionErrorAlert.toggle()
logManager?.error("Kodi action: Could not send URL to Kodi since there is no playback URL to send")
return
}
do {
try await kodi.sendVideoUrl(urlString: urlString, server: server)
actionSuccessAlertMessage = "Your URL should be playing on Kodi"
showActionSuccessAlert.toggle()
logManager?.info("URL \(urlString) is playing on Kodi")
} catch {
actionErrorAlertMessage = "Kodi Error: \(error)"
showActionErrorAlert.toggle()
logManager?.error("Kodi action: \(error)")
}
}
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
guard let actionJson else {
await logManager?.error("Action addition: No action present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
if actionJson.requires.count < 1 {
await logManager?.error("Action addition: actions must require an input. Please contact the action dev!")
return
}
guard let deeplinks = actionJson.deeplink else {
await logManager?.error("Action addition: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
return
}
let existingActionRequest = Action.fetchRequest()
existingActionRequest.predicate = NSPredicate(format: "name == %@", actionJson.name)
existingActionRequest.fetchLimit = 1
if let existingAction = try? backgroundContext.fetch(existingActionRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingAction, context: backgroundContext)
} else {
await logManager?.error("Action addition: Could not install action with name \(actionJson.name) because it is already installed")
return
}
}
let newAction = Action(context: backgroundContext)
newAction.id = UUID()
newAction.name = actionJson.name
newAction.version = actionJson.version
newAction.website = actionJson.website
newAction.about = actionJson.about
newAction.author = actionJson.author ?? "Unknown"
newAction.listId = actionJson.listId
newAction.requires = actionJson.requires.map(\.rawValue)
newAction.enabled = true
if let jsonTags = actionJson.tags {
for tag in jsonTags {
let newTag = PluginTag(context: backgroundContext)
newTag.name = tag.name
newTag.colorHex = tag.colorHex
newTag.parentAction = newAction
}
}
// Only one deeplink is left in this action JSON because of the previous filtering logic
guard let deeplinkJson = deeplinks.first else {
await logManager?.error("Action addition: No deeplink was present in action with name \(actionJson.name). Contact the action dev!")
return
}
newAction.deeplink = deeplinkJson.scheme
do {
try backgroundContext.save()
} catch {
await logManager?.error("Action addition: \(error)")
}
}
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
guard let sourceJson else {
await logManager?.error("Source addition: No source present. Contact the app dev!")
return
}
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
let dynamicWebsite = sourceJson.dynamicWebsite ?? false
if !dynamicWebsite, sourceJson.website == nil {
await logManager?.error("Not adding this source because website parameters are malformed. Please contact the source dev.")
return
}
// If a source exists, don't add the new one unless upserting
let existingSourceRequest = Source.fetchRequest()
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
existingSourceRequest.fetchLimit = 1
if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
await logManager?.error("Source addition: Could not install source with name \(sourceJson.name) because it is already installed.")
return
}
}
let newSource = Source(context: backgroundContext)
newSource.id = UUID()
newSource.name = sourceJson.name
newSource.version = sourceJson.version
newSource.about = sourceJson.about
newSource.website = sourceJson.website
newSource.dynamicWebsite = dynamicWebsite
newSource.fallbackUrls = dynamicWebsite ? nil : sourceJson.fallbackUrls
newSource.author = sourceJson.author ?? "Unknown"
newSource.listId = sourceJson.listId
newSource.trackers = sourceJson.trackers
if let jsonTags = sourceJson.tags {
for tag in jsonTags {
let newTag = PluginTag(context: backgroundContext)
newTag.name = tag.name
newTag.colorHex = tag.colorHex
newTag.parentSource = newSource
}
}
if let sourceApiJson = sourceJson.api {
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
}
if let jsonParserJson = sourceJson.jsonParser {
addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson)
}
// Adds an RSS parser if present
if let rssParserJson = sourceJson.rssParser {
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
}
// Adds an HTML parser if present
if let htmlParserJson = sourceJson.htmlParser {
addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
}
// Add an API condition as well
if newSource.jsonParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue)
} else if newSource.rssParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
} else {
newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
}
newSource.enabled = true
do {
try backgroundContext.save()
} catch {
await logManager?.error("Source addition error: \(error)")
}
}
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext)
newSourceApi.apiUrl = apiJson.apiUrl
if let clientIdJson = apiJson.clientId {
let newClientId = SourceApiClientId(context: backgroundContext)
newClientId.query = clientIdJson.query
newClientId.urlString = clientIdJson.url
newClientId.dynamic = clientIdJson.dynamic ?? false
newClientId.value = clientIdJson.value
newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientId.expiryLength = clientIdJson.expiryLength ?? 0
newClientId.timeStamp = Date()
newSourceApi.clientId = newClientId
}
if let clientSecretJson = apiJson.clientSecret {
let newClientSecret = SourceApiClientSecret(context: backgroundContext)
newClientSecret.query = clientSecretJson.query
newClientSecret.urlString = clientSecretJson.url
newClientSecret.dynamic = clientSecretJson.dynamic ?? false
newClientSecret.value = clientSecretJson.value
newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0
newClientSecret.timeStamp = Date()
newSourceApi.clientSecret = newClientSecret
}
newSource.api = newSourceApi
}
// TODO: Migrate parser addition to a common protocol
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
newSourceJsonParser.searchUrl = jsonParserJson.searchUrl
newSourceJsonParser.results = jsonParserJson.results
newSourceJsonParser.subResults = jsonParserJson.subResults
if let requestJson = newSourceJsonParser.request {
let newParserRequest = SourceRequest(context: backgroundContext)
newParserRequest.method = requestJson.method
newParserRequest.headers = requestJson.headers
newParserRequest.body = requestJson.body
}
// Tune these complex queries to the final JSON parser format
if let magnetLinkJson = jsonParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceJsonParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = jsonParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceJsonParser.magnetHash = newSourceMagnetHash
}
if let subNameJson = jsonParserJson.subName {
let newSourceSubName = SourceSubName(context: backgroundContext)
newSourceSubName.query = subNameJson.query
newSourceSubName.attribute = subNameJson.query
newSourceSubName.discriminator = subNameJson.discriminator
newSourceJsonParser.subName = newSourceSubName
}
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = jsonParserJson.title.query
newSourceTitle.attribute = jsonParserJson.title.attribute ?? "text"
newSourceTitle.discriminator = jsonParserJson.title.discriminator
newSourceJsonParser.title = newSourceTitle
if let sizeJson = jsonParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceJsonParser.size = newSourceSize
}
if let seedLeechJson = jsonParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceJsonParser.seedLeech = newSourceSeedLeech
}
newSource.jsonParser = newSourceJsonParser
}
private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext)
newSourceRssParser.rssUrl = rssParserJson.rssUrl
newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items
if let requestJson = newSourceRssParser.request {
let newParserRequest = SourceRequest(context: backgroundContext)
newParserRequest.method = requestJson.method
newParserRequest.headers = requestJson.headers
newParserRequest.body = requestJson.body
}
if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceMagnetLink.regex = magnetLinkJson.regex
newSourceRssParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = rssParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceMagnetHash.regex = magnetHashJson.regex
newSourceRssParser.magnetHash = newSourceMagnetHash
}
if let subNameJson = rssParserJson.subName {
let newSourceSubName = SourceSubName(context: backgroundContext)
newSourceSubName.query = subNameJson.query
newSourceSubName.attribute = subNameJson.attribute ?? "text"
newSourceSubName.discriminator = subNameJson.discriminator
newSourceSubName.regex = subNameJson.regex
newSourceRssParser.subName = newSourceSubName
}
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = rssParserJson.title.query
newSourceTitle.attribute = rssParserJson.title.attribute ?? "text"
newSourceTitle.discriminator = rssParserJson.title.discriminator
newSourceTitle.regex = rssParserJson.title.regex
newSourceRssParser.title = newSourceTitle
if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceSize.regex = sizeJson.regex
newSourceRssParser.size = newSourceSize
}
if let seedLeechJson = rssParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceRssParser.seedLeech = newSourceSeedLeech
}
newSource.rssParser = newSourceRssParser
}
private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
newSourceHtmlParser.rows = htmlParserJson.rows
if let subNameJson = htmlParserJson.subName {
let newSourceSubName = SourceSubName(context: backgroundContext)
newSourceSubName.query = subNameJson.query
newSourceSubName.attribute = subNameJson.attribute ?? "text"
newSourceSubName.regex = subNameJson.regex
newSourceHtmlParser.subName = newSourceSubName
}
if let requestJson = htmlParserJson.request {
print(requestJson)
let newParserRequest = SourceRequest(context: backgroundContext)
newParserRequest.method = requestJson.method
newParserRequest.headers = requestJson.headers
newParserRequest.body = requestJson.body
newSourceHtmlParser.request = newParserRequest
}
// Adds a title complex query
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = htmlParserJson.title.query
newSourceTitle.attribute = htmlParserJson.title.attribute ?? "text"
newSourceTitle.regex = htmlParserJson.title.regex
newSourceHtmlParser.title = newSourceTitle
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.regex = sizeJson.regex
newSourceHtmlParser.size = newSourceSize
}
if let seedLeechJson = htmlParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceHtmlParser.seedLeech = newSourceSeedLeech
}
// Adds a magnet complex query and its unique properties
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
newSourceMagnet.query = htmlParserJson.magnet.query
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
newSourceMagnet.regex = htmlParserJson.magnet.regex
newSourceHtmlParser.magnetLink = newSourceMagnet
newSource.htmlParser = newSourceHtmlParser
}
// Adds a plugin list
// Can move this to PersistenceController if needed
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
let backgroundContext = PersistenceController.shared.backgroundContext
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
}
guard let url = URL(string: urlString) else {
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 {
existingPluginList.urlString = urlString
existingPluginList.name = rawResponse.name
existingPluginList.author = rawResponse.author
try PersistenceController.shared.container.viewContext.save()
} else {
let pluginListRequest = PluginList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
pluginListRequest.fetchLimit = 1
if let existingPluginList = try? backgroundContext.fetch(pluginListRequest).first, !isSheet {
PersistenceController.shared.delete(existingPluginList, context: backgroundContext)
} else if isSheet {
throw PluginManagerError.ListAddition(description: "An existing plugin list with this information was found. Please try editing an existing plugin list instead.")
}
let newPluginList = PluginList(context: backgroundContext)
newPluginList.id = UUID()
newPluginList.urlString = urlString
newPluginList.name = rawResponse.name
newPluginList.author = rawResponse.author
try backgroundContext.save()
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,426 +0,0 @@
//
// SourceManager.swift
// Ferrite
//
// Created by Brian Dashore on 7/25/22.
//
import CoreData
import Foundation
import SwiftUI
public class SourceManager: ObservableObject {
var toastModel: ToastViewModel?
@Published var availableSources: [SourceJson] = []
var urlErrorAlertText = ""
@Published var showUrlErrorAlert = false
@MainActor
public func fetchSourcesFromUrl() async {
let sourceListRequest = SourceList.fetchRequest()
do {
let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest)
var tempAvailableSources: [SourceJson] = []
for sourceList in sourceLists {
guard let url = URL(string: sourceList.urlString) else {
return
}
// Always get the up-to-date source list
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let (data, _) = try await URLSession.shared.data(for: request)
let sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
for var source in sourceResponse.sources {
// If there is a minVersion, check and see if the source is valid
if checkAppVersion(minVersion: source.minVersion) {
source.author = sourceList.author
source.listId = sourceList.id
tempAvailableSources.append(source)
}
}
}
availableSources = tempAvailableSources
} catch {
print(error)
}
}
func fetchUpdatedSources(installedSources: FetchedResults<Source>) -> [SourceJson] {
var updatedSources: [SourceJson] = []
for source in installedSources {
if let availableSource = availableSources.first(where: {
source.listId == $0.listId && source.name == $0.name && source.author == $0.author
}),
availableSource.version > source.version
{
updatedSources.append(availableSource)
}
}
return updatedSources
}
// Checks if the current app version is supported by the source
func checkAppVersion(minVersion: String?) -> Bool {
// If there's no min version, assume that every version is supported
guard let minVersion else {
return true
}
return Application.shared.appVersion >= minVersion
}
// Fetches sources using the background context
public func fetchInstalledSources() -> [Source] {
let backgroundContext = PersistenceController.shared.backgroundContext
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
return sources.compactMap { $0 }
} else {
return []
}
}
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async {
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
print("Not adding this source because base URL parameters are malformed")
return
}
// If a source exists, don't add the new one unless upserting
let existingSourceRequest = Source.fetchRequest()
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
existingSourceRequest.fetchLimit = 1
if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first {
if doUpsert {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
return
}
}
let newSource = Source(context: backgroundContext)
newSource.id = UUID()
newSource.name = sourceJson.name
newSource.version = sourceJson.version
newSource.dynamicBaseUrl = dynamicBaseUrl
newSource.baseUrl = sourceJson.baseUrl
newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls
newSource.author = sourceJson.author ?? "Unknown"
newSource.listId = sourceJson.listId
newSource.trackers = sourceJson.trackers
if let sourceApiJson = sourceJson.api {
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
}
if let jsonParserJson = sourceJson.jsonParser {
addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson)
}
// Adds an RSS parser if present
if let rssParserJson = sourceJson.rssParser {
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
}
// Adds an HTML parser if present
if let htmlParserJson = sourceJson.htmlParser {
addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
}
// Add an API condition as well
if newSource.jsonParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue)
} else if newSource.rssParser != nil {
newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
} else {
newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
}
newSource.enabled = true
do {
try backgroundContext.save()
} catch {
await toastModel?.updateToastDescription(error.localizedDescription)
}
}
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceApi = SourceApi(context: backgroundContext)
newSourceApi.apiUrl = apiJson.apiUrl
if let clientIdJson = apiJson.clientId {
let newClientId = SourceApiClientId(context: backgroundContext)
newClientId.query = clientIdJson.query
newClientId.urlString = clientIdJson.url
newClientId.dynamic = clientIdJson.dynamic ?? false
newClientId.value = clientIdJson.value
newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientId.expiryLength = clientIdJson.expiryLength ?? 0
newClientId.timeStamp = Date()
newSourceApi.clientId = newClientId
}
if let clientSecretJson = apiJson.clientSecret {
let newClientSecret = SourceApiClientSecret(context: backgroundContext)
newClientSecret.query = clientSecretJson.query
newClientSecret.urlString = clientSecretJson.url
newClientSecret.dynamic = clientSecretJson.dynamic ?? false
newClientSecret.value = clientSecretJson.value
newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0
newClientSecret.timeStamp = Date()
newSourceApi.clientSecret = newClientSecret
}
newSource.api = newSourceApi
}
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
newSourceJsonParser.searchUrl = jsonParserJson.searchUrl
newSourceJsonParser.results = jsonParserJson.results
newSourceJsonParser.subResults = jsonParserJson.subResults
// Tune these complex queries to the final JSON parser format
if let magnetLinkJson = jsonParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceJsonParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = jsonParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceJsonParser.magnetHash = newSourceMagnetHash
}
if let titleJson = jsonParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.discriminator = titleJson.discriminator
newSourceJsonParser.title = newSourceTitle
}
if let sizeJson = jsonParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceJsonParser.size = newSourceSize
}
if let seedLeechJson = jsonParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceJsonParser.seedLeech = newSourceSeedLeech
}
newSource.jsonParser = newSourceJsonParser
}
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceRssParser = SourceRssParser(context: backgroundContext)
newSourceRssParser.rssUrl = rssParserJson.rssUrl
newSourceRssParser.searchUrl = rssParserJson.searchUrl
newSourceRssParser.items = rssParserJson.items
if let magnetLinkJson = rssParserJson.magnetLink {
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
newSourceMagnetLink.query = magnetLinkJson.query
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
newSourceRssParser.magnetLink = newSourceMagnetLink
}
if let magnetHashJson = rssParserJson.magnetHash {
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
newSourceMagnetHash.query = magnetHashJson.query
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
newSourceRssParser.magnetHash = newSourceMagnetHash
}
if let titleJson = rssParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.discriminator = titleJson.discriminator
newSourceRssParser.title = newSourceTitle
}
if let sizeJson = rssParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.discriminator = sizeJson.discriminator
newSourceRssParser.size = newSourceSize
}
if let seedLeechJson = rssParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceRssParser.seedLeech = newSourceSeedLeech
}
newSource.rssParser = newSourceRssParser
}
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
let backgroundContext = PersistenceController.shared.backgroundContext
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
newSourceHtmlParser.rows = htmlParserJson.rows
// Adds a title complex query if present
if let titleJson = htmlParserJson.title {
let newSourceTitle = SourceTitle(context: backgroundContext)
newSourceTitle.query = titleJson.query
newSourceTitle.attribute = titleJson.attribute ?? "text"
newSourceTitle.regex = titleJson.regex
newSourceHtmlParser.title = newSourceTitle
}
// Adds a size complex query if present
if let sizeJson = htmlParserJson.size {
let newSourceSize = SourceSize(context: backgroundContext)
newSourceSize.query = sizeJson.query
newSourceSize.attribute = sizeJson.attribute ?? "text"
newSourceSize.regex = sizeJson.regex
newSourceHtmlParser.size = newSourceSize
}
if let seedLeechJson = htmlParserJson.sl {
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
newSourceSeedLeech.seeders = seedLeechJson.seeders
newSourceSeedLeech.leechers = seedLeechJson.leechers
newSourceSeedLeech.combined = seedLeechJson.combined
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
newSourceHtmlParser.seedLeech = newSourceSeedLeech
}
// Adds a magnet complex query and its unique properties
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
newSourceMagnet.query = htmlParserJson.magnet.query
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
newSourceMagnet.regex = htmlParserJson.magnet.regex
newSourceHtmlParser.magnetLink = newSourceMagnet
newSource.htmlParser = newSourceHtmlParser
}
@MainActor
public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool {
let backgroundContext = PersistenceController.shared.backgroundContext
if sourceUrl.isEmpty || URL(string: sourceUrl) == nil {
urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly."
showUrlErrorAlert.toggle()
return false
}
do {
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!))
let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
if let existingSourceList {
existingSourceList.urlString = sourceUrl
existingSourceList.name = rawResponse.name
existingSourceList.author = rawResponse.author
try PersistenceController.shared.container.viewContext.save()
} else {
let sourceListRequest = SourceList.fetchRequest()
let urlPredicate = NSPredicate(format: "urlString == %@", sourceUrl)
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
sourceListRequest.fetchLimit = 1
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
urlErrorAlertText = "An existing source with this information was found. Please try editing the source list instead."
showUrlErrorAlert.toggle()
return false
}
let newSourceUrl = SourceList(context: backgroundContext)
newSourceUrl.id = UUID()
newSourceUrl.urlString = sourceUrl
newSourceUrl.name = rawResponse.name
newSourceUrl.author = rawResponse.author
try backgroundContext.save()
}
return true
} catch {
print(error)
urlErrorAlertText = error.localizedDescription
showUrlErrorAlert.toggle()
return false
}
}
}

View file

@ -1,48 +0,0 @@
//
// ToastViewModel.swift
// Ferrite
//
// Created by Brian Dashore on 7/19/22.
//
import SwiftUI
@MainActor
class ToastViewModel: ObservableObject {
enum ToastType: Identifiable {
var id: Int {
hashValue
}
case info
case error
}
// Toast variables
@Published var toastDescription: String? = nil {
didSet {
Task {
try? await Task.sleep(seconds: 0.1)
showToast = true
try? await Task.sleep(seconds: 5)
showToast = false
toastType = .error
}
}
}
@Published var showToast: Bool = false
public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) {
if let newToastType {
toastType = newToastType
}
toastDescription = description
}
// Default the toast type to error since the majority of toasts are errors
@Published var toastType: ToastType = .error
}

View file

@ -14,6 +14,11 @@ struct AboutView: View {
ListRowTextView(leftText: "Version", rightText: Application.shared.appVersion)
ListRowTextView(leftText: "Build number", rightText: Application.shared.appBuild)
ListRowTextView(leftText: "Build type", rightText: Application.shared.buildType)
if let commitHash = Bundle.main.commitHash {
ListRowTextView(leftText: "Commit", rightText: commitHash)
}
ListRowLinkView(text: "Discord server", link: "https://discord.gg/sYQxnuD7Fj")
ListRowLinkView(text: "GitHub repository", link: "https://github.com/bdashore3/Ferrite")
} header: {
@ -26,7 +31,7 @@ struct AboutView: View {
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
.textCase(.none)
.foregroundColor(.label)
.foregroundColor(.init(uiColor: .label))
.font(.body)
.padding(.top, 8)
.padding(.bottom, 20)

View file

@ -1,69 +0,0 @@
//
// BatchChoiceView.swift
// Ferrite
//
// Created by Brian Dashore on 7/24/22.
//
import SwiftUI
struct BatchChoiceView: View {
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var navModel: NavigationViewModel
let backgroundContext = PersistenceController.shared.backgroundContext
var body: some View {
NavView {
List {
ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in
Button(file.name) {
debridManager.selectedRealDebridFile = file
if let searchResult = navModel.selectedSearchResult {
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: searchResult)
if !debridManager.realDebridDownloadUrl.isEmpty {
// The download may complete before this sheet dismisses
try? await Task.sleep(seconds: 1)
navModel.selectedBatchTitle = file.name
navModel.addToHistory(name: searchResult.title, source: searchResult.source, url: debridManager.realDebridDownloadUrl, subName: file.name)
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl)
}
debridManager.selectedRealDebridFile = nil
debridManager.selectedRealDebridItem = nil
}
}
navModel.currentChoiceSheet = nil
}
.backport.tint(.primary)
}
}
.listStyle(.insetGrouped)
.navigationTitle("Select a file")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
navModel.currentChoiceSheet = nil
Task {
try? await Task.sleep(seconds: 1)
debridManager.selectedRealDebridItem = nil
}
}
}
}
}
}
}
struct BatchChoiceView_Previews: PreviewProvider {
static var previews: some View {
BatchChoiceView()
}
}

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 = "Cancel", role: Role? = nil) {
id = UUID()
self.label = label
action = {}
self.role = role
}
func toActionButton() -> Alert.Button {
if let role {
switch role {
case .cancel:
return .cancel(Text(label))
case .destructive:
return .destructive(Text(label), action: action)
}
} else {
return .default(Text(label), action: action)
}
}
@available(iOS 15.0, *)
@ViewBuilder
func toButtonView() -> some View {
Button(label, role: toButtonRole(role), action: action)
}
@available(iOS 15.0, *)
func toButtonRole(_ role: Role?) -> ButtonRole? {
if let role {
switch role {
case .destructive:
return .destructive
case .cancel:
return .cancel
}
} else {
return nil
}
}
}

View file

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

View file

@ -1,29 +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?,
@ViewBuilder content: @escaping (FetchedResults<T>) -> Content)
{
_fetchRequest = FetchRequest<T>(sortDescriptors: [], predicate: predicate)
self.content = content
}
}

View file

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

View file

@ -0,0 +1,62 @@
//
// HybridSecureField.swift
// Ferrite
//
// Created by Brian Dashore on 3/4/23.
//
import SwiftUI
struct HybridSecureField: View {
enum Field: Hashable {
case plain
case secure
}
@Binding var text: String
var onCommit: () -> Void = {}
@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 {
HStack {
Group {
if showPassword {
TextField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .plain)
} else {
SecureField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .secure)
}
}
.autocorrectionDisabled(true)
.autocapitalization(.none)
.disabledAppearance(isFieldDisabled)
Button {
showPassword.toggle()
focusedField = showPassword ? .plain : .secure
} label: {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.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)
.frame(width: reader.size.width * 0.26, height: 6)
.clipShape(Capsule())
.offset(x: -reader.size.width * 0.6, y: 0)
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: self.offset)
.offset(x: reader.size.width * 1.2 * offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: offset)
.onAppear {
withAnimation {
self.offset = 1
offset = 1
}
}
)

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

@ -0,0 +1,15 @@
//
// LibraryHeaderView.swift
// Ferrite
//
// Created by Brian Dashore on 2/12/23.
//
import SwiftUI
struct LibraryHeaderView: View {
@EnvironmentObject var debridManager: DebridManager
@Binding var selectedSegment: LibraryPickerSegment
var body: some View {}
}

View file

@ -23,6 +23,7 @@ struct ListRowLinkView: View {
Image(systemName: "arrow.up.forward.app.fill")
.foregroundColor(.gray)
}
.padding(.trailing, -5)
}
}
@ -50,6 +51,7 @@ struct ListRowButtonView: View {
.foregroundColor(.gray)
}
}
.padding(.trailing, -5)
}
}
@ -66,10 +68,10 @@ struct ListRowTextView: View {
if let rightText {
Text(rightText)
} else {
Image(systemName: rightSymbol!)
.foregroundColor(.gray)
} else if let rightSymbol {
Image(systemName: rightSymbol)
}
}
.padding(.trailing, -5)
}
}

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 ConditionalContextMenu<InternalContent: View, ID: Hashable>: ViewModifier {
let internalContent: () -> InternalContent
let id: ID
init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) {
self.internalContent = internalContent
self.id = id
}
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
.contextMenu {
internalContent()
}
} else {
content
.background {
Color.clear
.contextMenu {
internalContent()
}
.id(id)
}
}
}
}

View file

@ -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 ConditionalId<ID: Hashable>: ViewModifier {
let id: ID
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
} else {
content
.id(id)
}
}
}

View file

@ -9,7 +9,7 @@
import SwiftUI
struct DisableInteraction: ViewModifier {
struct DisableInteractionModifier: ViewModifier {
let disabled: Bool
func body(content: Content) -> some View {

View file

@ -9,7 +9,7 @@
import SwiftUI
struct DisabledAppearance: ViewModifier {
struct DisabledAppearanceModifier: ViewModifier {
let disabled: Bool
let dimmedOpacity: Double?
let animation: Animation?

View file

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

View file

@ -1,31 +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 {
let content: () -> Content
init(@ViewBuilder _ content: @escaping () -> Content) {
self.content = content
}
var body: some View {
if #available(iOS 16, *) {
NavigationStack {
content()
}
} else {
NavigationView {
content()
}
.navigationViewStyle(.stack)
}
}
}

View file

@ -0,0 +1,37 @@
//
// SearchableContent.swift
// Ferrite
//
// Created by Brian Dashore on 2/11/23.
//
// View to link animations together with searchbar
// Passes through geometry proxy and last height vars for any comparison
//
import SwiftUI
struct SearchableContent<Content: View>: View {
@Binding var searching: Bool
@State private var lastHeight: CGFloat = 0.0
@ViewBuilder var content: (Bool) -> Content
var body: some View {
GeometryReader { geom in
// Return if the height has changed as a closure variable for child transactions
content(geom.size.height != lastHeight)
.backport.onAppear {
lastHeight = geom.size.height
}
.onChange(of: geom.size.height) { newHeight in
lastHeight = newHeight
}
.transaction {
if geom.size.height != lastHeight, searching {
$0.animation = .default.speed(2)
}
}
}
}
}

View file

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

View file

@ -0,0 +1,28 @@
//
// Tag.swift
// Ferrite
//
// Created by Brian Dashore on 2/7/23.
//
import SwiftUI
struct Tag: View {
let name: String
let color: Color?
var horizontalPadding: CGFloat = 7
var verticalPadding: CGFloat = 4
var body: some View {
Text(name.capitalizingFirstLetter())
.font(.caption)
.opacity(0.8)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(
RoundedRectangle(cornerRadius: 5)
.foregroundColor(color.map { $0 } ?? .init(uiColor: .tertiaryLabel))
.opacity(0.3)
)
}
}

View file

@ -0,0 +1,75 @@
//
// TestHostingView.swift
// Ferrite
//
// Created by Brian Dashore on 2/13/23.
//
import SwiftUI
struct TestHostingView: View {
@State private var textName = "First"
@State private var secondTextName = "First"
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
Menu {
Picker("", selection: $textName) {
Text("First").tag("First")
Text("Second").tag("Second")
Text("Third").tag("Third")
}
} label: {
HStack(spacing: 2) {
Text(textName)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
.font(.caption, weight: .bold)
.background(Capsule().foregroundColor(.secondarySystemFill))
}
.id(textName)
.transaction {
$0.animation = .none
}
Menu {
Picker("", selection: $secondTextName) {
Text("First").tag("First")
Text("Second").tag("Second")
Text("Third").tag("Third")
}
} label: {
HStack(spacing: 2) {
Text(secondTextName)
.opacity(0.6)
.foregroundColor(.primary)
Image(systemName: "chevron.down")
.foregroundColor(.tertiaryLabel)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
.font(.caption, weight: .bold)
.background(Capsule().foregroundColor(.secondarySystemFill))
}
.id(secondTextName)
.transaction {
$0.animation = .none
}
}
.padding(.horizontal, 18)
}
}
}
struct TestHostingView_Previews: PreviewProvider {
static var previews: some View {
TestHostingView()
}
}

View file

@ -0,0 +1,41 @@
//
// DebridLabelView.swift
// Ferrite
//
// Created by Brian Dashore on 11/27/22.
//
import SwiftUI
struct DebridLabelView: View {
@Store var debridSource: DebridSource
@State var cloudLinks: [String] = []
@State var tagColor: Color = .red
var magnet: Magnet?
var body: some View {
Tag(
name: debridSource.abbreviation,
color: getTagColor(),
horizontalPadding: 5,
verticalPadding: 3
)
}
func getTagColor() -> Color {
if let magnet, cloudLinks.isEmpty {
guard let match = debridSource.IAValues.first(where: { magnet.hash == $0.magnet.hash }) else {
return .red
}
return match.files.count > 1 ? .orange : .green
} else if cloudLinks.count == 1 {
return .green
} else if cloudLinks.count > 1 {
return .orange
} else {
return .red
}
}
}

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