Compare commits

...

59 commits

Author SHA1 Message Date
kingbri
f598137baf Ferrite: Bump version
v0.7.1

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
217bb5caa4 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-19 16:40:26 -05:00
kingbri
64378ccc23 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-19 16:40:26 -05:00
kingbri
b232f9c0f3 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-19 16:40:26 -05:00
kingbri
3627b955e4 Treewide: Cleanup and rename
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
d4baf513c6 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-19 16:40:26 -05:00
kingbri
aaf12e28e2 Update README
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
935f3d1ea4 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-19 16:40:26 -05:00
kingbri
e0510ca924 Update README
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
8f84508e03 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-19 16:40:26 -05:00
kingbri
8e20d6c76d Update README and add media
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
d47d535b16 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-19 16:40:26 -05:00
kingbri
02fca1502d 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-19 16:40:26 -05:00
kingbri
aa0712b967 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-19 16:40:26 -05:00
kingbri
e14c684b5f Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
c4edd5f687 Tree: Remove OffCloud references
Was an experiment for later commits.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
4cee7a15ec 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-19 16:40:26 -05:00
kingbri
0f69b92540 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-19 16:40:26 -05:00
kingbri
e46e115244 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-19 16:40:26 -05:00
kingbri
15651eb695 Debrid: Reorder protocol
Helps when auto-filling stubs for new classes.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
8c9bb4a699 Revert "Extension: Remove Set warning"
This reverts commit cf090cfaa61acef5ff43f9f261764b0a125411f8.
2024-06-19 16:40:26 -05:00
kingbri
4ecc9d9ee7 Ferrite: Update project settings
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
905943330f 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-19 16:40:26 -05:00
kingbri
1289c24fa5 Extension: Remove Set warning
This will be removed in the future anyway.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
d0f1f70d60 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-19 16:40:26 -05:00
kingbri
ff57c90a62 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-19 16:40:26 -05:00
kingbri
17ac5f8534 Actions: Update to latest
Bump actions and macos build versions.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
8755009ad4 Debrid: Fix UI updates for IA
Hook to the published variable to push updates.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
0ad2ba5cf2 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-19 16:40:26 -05:00
kingbri
1d928b9a36 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-19 16:40:26 -05:00
kingbri
457d938be8 Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
5b4cef7ef0 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-19 16:40:26 -05:00
kingbri
eeb9cbdf65 Debrid: Unify cloud views
Cloud torrents and downloads are unified with the new protocol.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
694a2bfdf1 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-19 16:40:26 -05:00
kingbri
7e83a47050 Debrid: Migrate preferred service setter
PreferredService is now the debrid ID.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
b6a1179d1b Debrid: Remove separated download functions
No longer needed due to the common type.

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

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
f00ffb1b8c 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-19 16:40:26 -05:00
kingbri
fa8e5c19c3 Debrid: Swap to common DebridError
Removes the redundant error types.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
aa739133be Debrid: Refactor IA and download functions
Use the common protocol to handle these.

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

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
5120b3e576 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-19 16:40:26 -05:00
kingbri
2db5273ec4 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-19 16:40:26 -05:00
kingbri
6a3733ca9a 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-19 16:40:26 -05:00
kingbri
1c21f563e2 Debrid: Fix RealDebrid download handling
The torrent ID is no longer stored in the DebridManager.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
2d220045c6 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-19 16:40:26 -05:00
kingbri
551083f521 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-19 16:40:26 -05:00
kingbri
11dd2cabb6 Tree: Format
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
3435e929d8 Debrid: Add source to all models
Gives an ID of where the struct came from.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
5eb1158456 Debrid: Add Premiumize to InstantAvailability
Also add the requirement to the protocol.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
fd9eb080be Debrid: Order API implementations
Reorder everything and mark off where different functions are located.

Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
cf58626384 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-19 16:40:26 -05:00
kingbri
69a9d30475 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-19 16:40:26 -05:00
kingbri
b8a225e141 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-19 16:40:26 -05:00
kingbri
b1227db143 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>
2024-06-19 16:40:26 -05:00
kingbri
20f500fb64 Premiumize: Fix DDL fetching and debrid IA handling
Signed-off-by: kingbri <bdashore3@proton.me>
2024-06-19 16:40:26 -05:00
kingbri
eb48b99ed8 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>
2024-06-19 16:40:26 -05:00
kingbri
f0d917002e 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>
2024-06-19 16:40:26 -05:00
kingbri
375de6f46e 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>
2024-06-19 16:40:26 -05:00
77 changed files with 2877 additions and 1550 deletions

View file

@ -6,13 +6,13 @@ on:
jobs:
build:
runs-on: macos-12
runs-on: macos-14
steps:
- uses: actions/checkout@v3
- 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
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build
@ -25,7 +25,7 @@ jobs:
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
- name: Upload artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa

View file

@ -7,13 +7,13 @@ on:
jobs:
build:
runs-on: macos-12
runs-on: macos-14
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest
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:
@ -30,7 +30,7 @@ jobs:
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@v1
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip

View file

@ -12,6 +12,9 @@
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; };
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; };
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; };
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
@ -20,7 +23,6 @@
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; };
0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; };
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; };
0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */; };
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; };
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; };
@ -54,7 +56,6 @@
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; };
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; };
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; };
@ -96,6 +97,9 @@
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; };
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; };
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */; };
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */; };
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; };
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
@ -129,12 +133,13 @@
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.swift */; };
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */; };
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */; };
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; };
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */; };
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.swift */; };
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; };
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
@ -142,6 +147,7 @@
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */; };
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; };
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; };
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */; };
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; };
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */; };
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
@ -153,6 +159,10 @@
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; };
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; };
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */; };
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */; };
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; };
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; };
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
/* End PBXBuildFile section */
@ -162,6 +172,9 @@
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = "<group>"; };
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = "<group>"; };
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = "<group>"; };
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = "<group>"; };
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
@ -170,7 +183,6 @@
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = "<group>"; };
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; };
0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortFilterView.swift; sourceTree = "<group>"; };
0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; };
@ -203,7 +215,6 @@
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = "<group>"; };
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; };
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; };
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; };
@ -241,6 +252,9 @@
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = "<group>"; };
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAmountLabelView.swift; sourceTree = "<group>"; };
0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = "<group>"; };
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxWrapper.swift; sourceTree = "<group>"; };
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxModels.swift; sourceTree = "<group>"; };
0C8AE2472C0FFB6600701675 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoView.swift; sourceTree = "<group>"; };
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsBaseUrlView.swift; sourceTree = "<group>"; };
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsApiView.swift; sourceTree = "<group>"; };
@ -274,12 +288,13 @@
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CAF9318296399190050812A /* PremiumizeCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeCloudView.swift; sourceTree = "<group>"; };
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = "<group>"; };
0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = "<group>"; };
0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = "<group>"; };
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = "<group>"; };
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDownloadView.swift; sourceTree = "<group>"; };
0CB725332C123E760047FC0B /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.swift; sourceTree = "<group>"; };
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = "<group>"; };
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
@ -288,6 +303,7 @@
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; };
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerriteKeychain.swift; sourceTree = "<group>"; };
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoMetaView.swift; sourceTree = "<group>"; };
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoAboutView.swift; sourceTree = "<group>"; };
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
@ -298,6 +314,10 @@
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = "<group>"; };
0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = "<group>"; };
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataClass.swift"; sourceTree = "<group>"; };
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debrid.swift; sourceTree = "<group>"; };
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridModels.swift; sourceTree = "<group>"; };
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -377,6 +397,8 @@
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */,
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */,
);
path = Classes;
sourceTree = "<group>";
@ -388,6 +410,7 @@
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */,
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */,
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
@ -397,6 +420,8 @@
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
);
path = Models;
sourceTree = "<group>";
@ -404,9 +429,8 @@
0C2886D52960C4F800D6FC16 /* Cloud */ = {
isa = PBXGroup;
children = (
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */,
0CAF9318296399190050812A /* PremiumizeCloudView.swift */,
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */,
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
0CB725332C123E760047FC0B /* CloudMagnetView.swift */,
);
path = Cloud;
sourceTree = "<group>";
@ -449,6 +473,9 @@
children = (
0C44E2A728D4DDDC007711AE /* Application.swift */,
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */,
0C8AE2472C0FFB6600701675 /* Store.swift */,
0C07C6032C1A859B00808A46 /* FormDataBody.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -489,6 +516,7 @@
isa = PBXGroup;
children = (
0CE1C4172981E8D700418F20 /* Plugin.swift */,
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */,
);
path = Protocols;
sourceTree = "<group>";
@ -646,6 +674,8 @@
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
);
path = API;
sourceTree = "<group>";
@ -738,7 +768,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1400;
LastUpgradeCheck = 1400;
LastUpgradeCheck = 1600;
TargetAttributes = {
0CAF1C67286F5C0E00296F86 = {
CreatedOnToolsVersion = 14.0;
@ -814,6 +844,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */,
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
@ -844,14 +875,15 @@
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */,
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */,
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */,
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
@ -861,10 +893,10 @@
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */,
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */,
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */,
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
@ -891,7 +923,6 @@
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */,
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */,
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */,
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */,
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
@ -912,16 +943,22 @@
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */,
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */,
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */,
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */,
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */,
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */,
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */,
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */,
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */,
0C7075E429D374C50093DB2D /* Color.swift in Sources */,
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
@ -938,11 +975,14 @@
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */,
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */,
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */,
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
@ -960,6 +1000,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@ -992,6 +1033,7 @@
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@ -1013,6 +1055,7 @@
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = minimal;
};
name = Debug;
};
@ -1020,6 +1063,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
@ -1052,6 +1096,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@ -1066,6 +1111,7 @@
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = minimal;
VALIDATE_PRODUCT = YES;
};
name = Release;
@ -1076,10 +1122,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
@ -1095,7 +1142,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.0;
MARKETING_VERSION = 0.7.1;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -1111,10 +1158,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
DEVELOPMENT_TEAM = 8A74DBQ6S3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Ferrite/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
@ -1130,7 +1178,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.7.0;
MARKETING_VERSION = 0.7.1;
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;

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

@ -6,36 +6,75 @@
//
import Foundation
import KeychainSwift
// TODO: Fix errors
public class AllDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
let baseApiUrl = "https://api.alldebrid.com/v4"
let appName = "Ferrite"
class AllDebrid: PollingDebridSource, ObservableObject {
let id = "AllDebrid"
let abbreviation = "AD"
let website = "https://alldebrid.com"
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
public func getPinInfo() async throws -> PinResponse {
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)
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
return rawResponse
// Validate the URL before doing anything else
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
guard let userUrl = URL(string: rawResponse.userURL) else {
throw DebridError.AuthQuery(description: "The login URL is invalid")
}
// Spawn the polling task separately
authTask = Task {
try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin)
}
return userUrl
} catch {
print("Couldn't get pin information!")
throw ADError.AuthQuery(description: error.localizedDescription)
throw DebridError.AuthQuery(description: error.localizedDescription)
}
}
// Fetches API keys
public func getApiKey(checkID: String, pin: String) async throws {
func getApiKey(checkID: String, pin: String) async throws {
let queryItems = [
URLQueryItem(name: "agent", value: appName),
URLQueryItem(name: "check", value: checkID),
@ -50,7 +89,7 @@ public class AllDebrid {
while count < 12 {
if Task.isCancelled {
throw ADError.AuthQuery(description: "Token request cancelled.")
throw DebridError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
@ -60,7 +99,7 @@ public class AllDebrid {
// If there's an API key from the response, end the task successfully
if let apiKeyResponse = rawResponse {
keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
return
} else {
@ -69,7 +108,7 @@ public class AllDebrid {
}
}
throw ADError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
if case let .failure(error) = await authTask?.result {
@ -77,15 +116,28 @@ public class AllDebrid {
}
}
// Clears tokens. No endpoint to deregister a device
public func deleteTokens() {
keychain.delete("AllDebrid.ApiKey")
// 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 = keychain.get("AllDebrid.ApiKey") else {
throw ADError.InvalidToken
guard let token = getToken() else {
throw DebridError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -93,23 +145,22 @@ public class AllDebrid {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw ADError.FailedRequest(description: "No HTTP response given")
throw DebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
deleteTokens()
throw ADError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
} else {
throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// Builds a URL for further requests
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: urlString) else {
throw ADError.InvalidUrl
throw DebridError.InvalidUrl
}
components.queryItems = [
@ -119,14 +170,80 @@ public class AllDebrid {
if let url = components.url {
return url
} else {
throw ADError.InvalidUrl
throw DebridError.InvalidUrl
}
}
// MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
let sendMagnets = magnets.filter { magnet in
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if sendMagnets.isEmpty {
return
}
let queryItems = sendMagnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) }
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<InstantAvailabilityResponse>.self, from: data).data
let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil }
let availableHashes = filteredMagnets.map { magnetResp in
// Force unwrap is OK here since the filter caught any nil values
let files = magnetResp.files!.enumerated().map { index, magnetFile in
DebridIAFile(id: index, name: magnetFile.name)
}
return DebridIA(
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
}
IAValues += 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?) {
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 lockedLink = try await fetchMagnetStatus(
magnetId: selectedMagnetId,
selectedIndex: iaFile?.id ?? 0
)
return (lockedLink, nil)
}
// Adds a magnet link to the user's AD account
public func addMagnet(magnet: Magnet) async throws -> Int {
func addMagnet(magnet: Magnet) async throws -> Int {
guard let magnetLink = magnet.link else {
throw ADError.FailedRequest(description: "The magnet link is invalid")
throw DebridError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
@ -146,13 +263,13 @@ public class AllDebrid {
if let magnet = rawResponse.magnets[safe: 0] {
return magnet.id
} else {
throw ADError.InvalidResponse
throw DebridError.InvalidResponse
}
}
public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String {
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> DebridIAFile {
let queryItems = [
URLQueryItem(name: "id", value: String(magnetId))
URLQueryItem(name: "id", value: magnetId)
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
@ -160,68 +277,93 @@ public class AllDebrid {
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
// Better to fetch no link at all than the wrong link
if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] {
return linkWrapper.link
if let cloudMagnetFile = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] {
return DebridIAFile(id: 0, name: cloudMagnetFile.filename, streamUrlString: cloudMagnetFile.link)
} else {
throw ADError.EmptyTorrents
throw DebridError.EmptyUserMagnets
}
}
public func userMagnets() async throws -> [MagnetStatusData] {
// Known as unlockLink in AD's API
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
let queryItems = [
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
]
var request = URLRequest(url: try 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 = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
// MARK: - Cloud methods
func getUserMagnets() async throws {
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
if rawResponse.magnets.isEmpty {
throw ADError.EmptyData
} else {
return rawResponse.magnets
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)
)
}
}
public func deleteMagnet(magnetId: Int) async throws {
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: String(magnetId))
URLQueryItem(name: "id", value: cloudMagnetId)
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
public func unlockLink(lockedLink: String) async throws -> String {
let queryItems = [
URLQueryItem(name: "link", value: lockedLink)
]
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
func getUserDownloads() async throws {
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
return rawResponse.link
}
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] {
let queryItems = magnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) }
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems))
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(ADResponse<InstantAvailabilityResponse>.self, from: data).data
let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil }
let availableHashes = filteredMagnets.map { magnetResp in
// Force unwrap is OK here since the filter caught any nil values
let files = magnetResp.files!.enumerated().map { index, magnetFile in
IAFile(id: index, fileName: magnetFile.name)
}
return IA(
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
// The link is also the ID
cloudDownloads = rawResponse.links.map { link in
DebridCloudDownload(
id: link.link, fileName: link.filename, link: link.link
)
}
}
return availableHashes
// 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 = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
try await performRequest(request: &request, requestName: #function)
}
}

View file

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

View file

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

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 = URLRequest(url: try 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 = URLRequest(url: try 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 = URLRequest(url: try 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 = URLRequest(url: try 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 = URLRequest(url: try buildRequestURL(urlString: "\(website)/cloud/remove/\(cloudMagnetId)"))
try await performRequest(request: &request, requestName: "cloudRemove")
}
}

View file

@ -6,17 +6,48 @@
//
import Foundation
import KeychainSwift
public class Premiumize {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
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."
let baseAuthUrl = "https://www.premiumize.me/authorize"
let baseApiUrl = "https://www.premiumize.me/api"
let clientId = "791565696"
@Published var authProcessing: Bool = false
var isLoggedIn: Bool {
getToken() != nil
}
public func buildAuthUrl() throws -> URL {
var manualToken: String? {
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
return getToken()
} else {
return nil
}
}
@Published var IAValues: [DebridIA] = []
@Published var cloudDownloads: [DebridCloudDownload] = []
@Published var cloudMagnets: [DebridCloudMagnet] = []
var cloudTTL: Double = 0.0
private let baseAuthUrl = "https://www.premiumize.me/authorize"
private let baseApiUrl = "https://www.premiumize.me/api"
private let clientId = "791565696"
private let jsonDecoder = JSONDecoder()
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
}
}
// MARK: - Auth
func getAuthUrl() throws -> URL {
var urlComponents = URLComponents(string: baseAuthUrl)!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
@ -27,59 +58,185 @@ public class Premiumize {
if let url = urlComponents.url {
return url
} else {
throw PMError.InvalidUrl
throw DebridError.InvalidUrl
}
}
public func handleAuthCallback(url: URL) throws {
func handleAuthCallback(url: URL) throws {
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let callbackFragment = callbackComponents?.fragment else {
throw PMError.InvalidResponse
throw DebridError.InvalidResponse
}
var fragmentComponents = URLComponents()
fragmentComponents.query = callbackFragment
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
throw PMError.InvalidToken
throw DebridError.InvalidToken
}
keychain.set(accessToken, forKey: "Premiumize.AccessToken")
FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken")
}
// Adds a manual API key instead of web auth
func setApiKey(_ key: String) {
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
}
func getToken() -> String? {
FerriteKeychain.shared.get("Premiumize.AccessToken")
}
// Clears tokens. No endpoint to deregister a device
public func deleteTokens() {
keychain.delete("Premiumize.AccessToken")
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 = keychain.get("Premiumize.AccessToken") else {
throw PMError.InvalidToken
guard let token = getToken() else {
throw DebridError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
// Use the API query parameter if a manual API key is present
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
guard
let requestUrl = request.url,
var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false)
else {
throw DebridError.InvalidUrl
}
let apiTokenItem = URLQueryItem(name: "apikey", value: token)
if components.queryItems == nil {
components.queryItems = [apiTokenItem]
} else {
components.queryItems?.append(apiTokenItem)
}
request.url = components.url
} else {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw PMError.FailedRequest(description: "No HTTP response given")
throw DebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
deleteTokens()
throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
} else {
throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// MARK: - Instant availability
func instantAvailability(magnets: [Magnet]) async throws {
let now = Date().timeIntervalSince1970
// Remove magnets that don't have an associated link for PM along with existing TTL logic
let sendMagnets = magnets.filter { magnet in
if magnet.link == nil {
return false
}
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
if now > IAValues[IAIndex].expiryTimeStamp {
IAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if sendMagnets.isEmpty {
return
}
let availableMagnets = try await divideCacheRequests(magnets: sendMagnets)
// Split DDL requests into chunks of 10
for chunk in availableMagnets.chunked(into: 10) {
let tempIA = try await divideDDLRequests(magnetChunk: chunk)
IAValues += tempIA
}
}
// Function to divide and execute DDL endpoint requests in parallel
// Calls this for 10 requests at a time to not overwhelm API servers
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
for magnet in magnetChunk {
group.addTask {
try await self.fetchDDL(magnet: magnet)
}
}
var chunkedIA: [DebridIA] = []
for try await ia in group {
chunkedIA.append(ia)
}
return chunkedIA
}
return tempIA
}
// Grabs DDL links
private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
if magnet.hash == nil {
throw DebridError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
let content = rawResponse.content ?? []
if !content.isEmpty {
let files = content.map { file in
DebridIAFile(
id: 0,
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
streamUrlString: file.link
)
}
return DebridIA(
magnet: magnet,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
} else {
throw DebridError.EmptyData
}
}
// Function to divide and execute cache endpoint requests in parallel
// Calls this for 100 hashes at a time due to API limits
public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
for chunk in magnets.chunked(into: 100) {
group.addTask {
@ -99,11 +256,11 @@ public class Premiumize {
}
// Parent function for initial checking of the cache
func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
guard let url = urlComponents.url else {
throw PMError.InvalidUrl
throw DebridError.InvalidUrl
}
var request = URLRequest(url: url)
@ -112,7 +269,7 @@ public class Premiumize {
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
if rawResponse.response.isEmpty {
throw PMError.EmptyData
throw DebridError.EmptyData
} else {
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
if rawResponse.response[safe: index] == true {
@ -126,65 +283,32 @@ public class Premiumize {
}
}
// Function to divide and execute DDL endpoint requests in parallel
// Calls this for 10 requests at a time to not overwhelm API servers
public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [IA] {
let tempIA = try await withThrowingTaskGroup(of: Premiumize.IA.self) { group in
for magnet in magnetChunk {
group.addTask {
try await self.fetchDDL(magnet: magnet)
}
}
// MARK: - Downloading
var chunkedIA: [Premiumize.IA] = []
for try await ia in group {
chunkedIA.append(ia)
}
return chunkedIA
}
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)
return tempIA
}
// Grabs DDL links
func fetchDDL(magnet: Magnet) async throws -> IA {
if magnet.hash == nil {
throw PMError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
request.httpBody = bodyComponents.query?.data(using: .utf8)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
if !rawResponse.content.isEmpty {
let files = rawResponse.content.map { file in
IAFile(
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
streamUrlString: file.link
)
}
return IA(
magnet: magnet,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files
)
if let iaFile {
return (iaFile, nil)
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
return (firstFile, nil)
} else {
throw PMError.EmptyData
throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
}
}
func createTransfer(magnet: Magnet) async throws {
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
guard let streamUrlString = restrictedFile.streamUrlString else {
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
}
return streamUrlString
}
private func createTransfer(magnet: Magnet) async throws {
guard let magnetLink = magnet.link else {
throw PMError.FailedRequest(description: "The magnet link is invalid")
throw DebridError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
@ -199,24 +323,29 @@ public class Premiumize {
try await performRequest(request: &request, requestName: #function)
}
func userItems() async throws -> [UserItem] {
// MARK: - Cloud methods
func getUserDownloads() async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data)
if rawResponse.files.isEmpty {
throw PMError.EmptyData
throw DebridError.EmptyData
}
return rawResponse.files
// The "link" is the ID for Premiumize
cloudDownloads = rawResponse.files.map { file in
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
}
}
func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
guard let url = urlComponents.url else {
throw PMError.InvalidUrl
throw DebridError.InvalidUrl
}
var request = URLRequest(url: url)
@ -227,16 +356,26 @@ public class Premiumize {
return rawResponse
}
func deleteItem(itemID: String) async throws {
func checkUserDownloads(link: String) async throws -> String? {
// Link is the cloud item ID
try await itemDetails(itemID: link).link
}
func deleteUserDownload(downloadId: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var bodyComponents = URLComponents()
bodyComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
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,30 +6,62 @@
//
import Foundation
import KeychainSwift
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 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
func setUserDefaultsValue(_ value: Any, forKey: String) {
private func setUserDefaultsValue(_ value: Any, forKey: String) {
UserDefaults.standard.set(value, forKey: forKey)
}
@MainActor
func removeUserDefaultsValue(forKey: String) {
private func removeUserDefaultsValue(forKey: String) {
UserDefaults.standard.removeObject(forKey: forKey)
}
init() {
// Populate user downloads and magnets
Task {
try? await getUserDownloads()
try? await getUserMagnets()
}
}
// MARK: - Auth
// Fetches the device code from RD
public func getVerificationInfo() async throws -> DeviceCodeResponse {
func getAuthUrl() async throws -> URL {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
@ -37,23 +69,33 @@ public class RealDebrid {
]
guard let url = urlComponents.url else {
throw RDError.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 RDError.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),
@ -61,55 +103,49 @@ public class RealDebrid {
]
guard let url = urlComponents.url else {
throw RDError.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 < 12 {
if Task.isCancelled {
throw RDError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
while count < 12 {
if Task.isCancelled {
throw DebridError.AuthQuery(description: "Token request cancelled.")
}
throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getApiTokens(deviceCode: deviceCode)
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
if case let .failure(error) = await authTask?.result {
throw 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 RDError.EmptyData
throw DebridError.EmptyData
}
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
throw RDError.EmptyData
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
throw DebridError.EmptyData
}
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
@ -130,20 +166,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)
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)
@ -151,29 +187,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")
// 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 private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
guard let token = await fetchToken() else {
throw RDError.InvalidToken
guard let token = await getToken() else {
throw DebridError.InvalidToken
}
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@ -181,28 +231,45 @@ public class RealDebrid {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw RDError.FailedRequest(description: "No HTTP response given")
throw DebridError.FailedRequest(description: "No HTTP response given")
}
if response.statusCode >= 200, response.statusCode <= 299 {
return data
} else if response.statusCode == 401 {
try await deleteTokens()
throw RDError.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 RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
}
}
// MARK: - Instant availability
// Checks if the magnet is streamable on RD
// Currently does not work for batch links
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] {
var availableHashes: [RealDebrid.IA] = []
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.compactMap(\.hash).joined(separator: "/"))")!)
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 = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(sendMagnets.compactMap(\.hash).joined(separator: "/"))")!)
let data = try await performRequest(request: &request, requestName: #function)
// Does not account for torrent packs at the moment
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
for (hash, response) in rawResponseDict {
@ -214,66 +281,82 @@ public class RealDebrid {
continue
}
// Is this a batch
if data.rd.count > 1 || data.rd[0].count > 1 {
// Batch array
let batches = data.rd.map { fileDict in
let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
// Handle files array
let batches = data.rd.map { fileDict in
let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
}.sorted(by: { $0.id < $1.id })
return RealDebrid.IABatch(files: batchFiles)
}
return RealDebrid.IABatch(files: batchFiles)
}
// RD files array
// Possibly sort this in the future, but not sure how at the moment
var files: [RealDebrid.IAFile] = []
var files: [DebridIAFile] = []
for index in batches.indices {
let batchFiles = batches[index].files
for batch in batches {
let batchFileIds = batch.files.map(\.id)
for batchFileIndex in batchFiles.indices {
let batchFile = batchFiles[batchFileIndex]
if !files.contains(where: { $0.name == batchFile.fileName }) {
files.append(
RealDebrid.IAFile(
name: batchFile.fileName,
batchIndex: index,
batchFileIndex: batchFileIndex
)
for batchFile in batch.files {
if !files.contains(where: { $0.id == batchFile.id }) {
files.append(
DebridIAFile(
id: batchFile.id,
name: batchFile.fileName,
batchIds: batchFileIds
)
}
)
}
}
// TTL: 5 minutes
availableHashes.append(
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches
)
)
} else {
availableHashes.append(
RealDebrid.IA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300
)
)
}
}
return availableHashes
// TTL: 5 minutes
IAValues.append(
DebridIA(
magnet: Magnet(hash: hash, link: nil),
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: 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?) {
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 && $0.status == "downloaded" }) {
selectedMagnetId = existingCloudMagnet.id
} else {
selectedMagnetId = try await addMagnet(magnet: magnet)
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
}
// RealDebrid has 1 as the first ID for a file
let restrictedFile = try await torrentInfo(
debridID: selectedMagnetId,
selectedFileId: iaFile?.id ?? 1
)
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(magnet: Magnet) async throws -> String {
func addMagnet(magnet: Magnet) async throws -> String {
guard let magnetLink = magnet.link else {
throw RDError.FailedRequest(description: "The magnet link is invalid")
throw DebridError.FailedRequest(description: "The magnet link is invalid")
}
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
@ -292,7 +375,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")
@ -312,48 +395,36 @@ 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, selectedFileId: Int?) async throws -> DebridIAFile {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
let data = try await performRequest(request: &request, requestName: #function)
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
let filteredFiles = rawResponse.files.filter { $0.selected == 1 }
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
// Let the user know if a torrent is downloading
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
return torrentLink
// Let the user know if a magnet is downloading
if let cloudMagnetLink = rawResponse.links[safe: linkIndex ?? -1], rawResponse.status == "downloaded" {
return DebridIAFile(
id: 0,
name: rawResponse.filename,
streamUrlString: cloudMagnetLink
)
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
throw RDError.EmptyTorrents
throw DebridError.IsCaching
} else {
throw RDError.EmptyData
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)
@ -363,18 +434,66 @@ 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.links
)
}
}
// 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)
return rawResponse
cloudDownloads = rawResponse.map { response in
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
}
}
public func deleteDownload(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!)
// 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

@ -10,4 +10,4 @@ import CoreData
import Foundation
@objc(Bookmark)
public class Bookmark: NSManagedObject {}
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")
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Action" representedClassName="Action" syncable="YES">
<attribute name="about" optional="YES" attributeType="String"/>
<attribute name="author" attributeType="String" defaultValueString=""/>
@ -106,6 +106,7 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentHtmlParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
@ -118,6 +119,7 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentJsonParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
@ -134,6 +136,14 @@
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRequest" representedClassName="SourceRequest" syncable="YES">
<attribute name="body" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData"/>
<attribute name="headers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String: String]"/>
<attribute name="method" optional="YES" attributeType="String"/>
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="request" inverseEntity="SourceHtmlParser"/>
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="request" inverseEntity="SourceJsonParser"/>
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="request" inverseEntity="SourceRssParser"/>
</entity>
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
<attribute name="items" attributeType="String" defaultValueString=""/>
<attribute name="rssUrl" optional="YES" attributeType="String"/>
@ -141,6 +151,7 @@
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentRssParser" inverseEntity="SourceRequest"/>
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>

View file

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

View file

@ -9,6 +9,15 @@ import Introspect
import SwiftUI
extension View {
// Modifies properties of a view. Works the same way as a ViewModifier
// From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
var result = self
body(&result)
return result
}
// MARK: Modifiers
func conditionalContextMenu(id: some Hashable,

View file

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

View file

@ -7,21 +7,7 @@
import Foundation
public extension AllDebrid {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum ADError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
extension AllDebrid {
// MARK: - Generic AllDebrid response
// Uses a generic parametr for whatever underlying response is present
@ -67,7 +53,7 @@ public extension AllDebrid {
// MARK: - AddMagnetData
internal struct AddMagnetData: Codable {
struct AddMagnetData: Codable {
let magnet, hash, name, filenameOriginal: String
let size: Int
let ready: Bool
@ -85,7 +71,7 @@ public extension AllDebrid {
struct MagnetStatusResponse: Codable {
let magnets: [MagnetStatusData]
public init(from decoder: Decoder) throws {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
@ -117,7 +103,7 @@ public extension AllDebrid {
// MARK: - MagnetStatusLink
// Abridged for required parameters
internal struct MagnetStatusLink: Codable {
struct MagnetStatusLink: Codable {
let link: String
let filename: String
let size: Int
@ -130,6 +116,19 @@ public extension AllDebrid {
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 {
@ -138,7 +137,7 @@ public extension AllDebrid {
// MARK: - IAMagnetResponse
internal struct InstantAvailabilityMagnet: Codable {
struct InstantAvailabilityMagnet: Codable {
let magnet, hash: String
let instant: Bool
let files: [InstantAvailabilityFile]?
@ -146,24 +145,11 @@ public extension AllDebrid {
// MARK: - IAFileResponse
internal struct InstantAvailabilityFile: Codable {
struct InstantAvailabilityFile: Codable {
let name: String
enum CodingKeys: String, CodingKey {
case name = "n"
}
}
// MARK: - InstantAvailablity client side structures
struct IA: Codable, Hashable {
let magnet: Magnet
let expiryTimeStamp: Double
var files: [IAFile]
}
struct IAFile: Codable, Hashable {
let id: Int
let fileName: String
}
}

View file

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

View file

@ -10,7 +10,7 @@ import Foundation
// MARK: - Universal IA enum (IA = InstantAvailability)
public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
case full = "Cached"
case partial = "Batch"
case none = "Uncached"
@ -18,7 +18,7 @@ public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
// MARK: - Enum for debrid differentiation. 0 is nil
public enum DebridType: Int, Codable, Hashable, CaseIterable {
enum DebridType: Int, Codable, Hashable, CaseIterable {
case realDebrid = 1
case allDebrid = 2
case premiumize = 3
@ -47,7 +47,7 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable {
}
// Wrapper struct for magnet links to contain both the link and hash for easy access
public struct Magnet: Codable, Hashable, Sendable {
struct Magnet: Codable, Hashable, Sendable {
var hash: String?
var link: String?
@ -56,11 +56,13 @@ public struct Magnet: Codable, Hashable, Sendable {
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 = parseHash(extractHash(link: link))
self.hash = hash
} else {
self.hash = parseHash(hash)
self.link = link
self.link = parseLink(link).link
}
}
@ -107,4 +109,36 @@ public struct Magnet: Codable, Hashable, Sendable {
return String(magnetHash).lowercased()
}
}
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

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

View file

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

View file

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

View file

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

View file

@ -8,21 +8,7 @@
import Foundation
public extension RealDebrid {
// MARK: - Errors
// TODO: Hybridize debrid errors in one structure
enum RDError: Error {
case InvalidUrl
case InvalidPostBody
case InvalidResponse
case InvalidToken
case EmptyData
case EmptyTorrents
case FailedRequest(description: String)
case AuthQuery(description: String)
}
extension RealDebrid {
// MARK: - device code endpoint
struct DeviceCodeResponse: Codable, Sendable {
@ -72,7 +58,7 @@ public extension RealDebrid {
struct InstantAvailabilityResponse: Codable, Sendable {
var data: InstantAvailabilityData?
public init(from decoder: Decoder) throws {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) {
@ -81,23 +67,16 @@ public extension RealDebrid {
}
}
internal struct InstantAvailabilityData: Codable, Sendable {
struct InstantAvailabilityData: Codable, Sendable {
var rd: [[String: InstantAvailabilityInfo]]
}
internal struct InstantAvailabilityInfo: Codable, Sendable {
struct InstantAvailabilityInfo: Codable, Sendable {
var filename: String
var filesize: Int
}
// MARK: - Instant Availability client side structures
struct IA: Codable, Hashable, Sendable {
let magnet: Magnet
let expiryTimeStamp: Double
var files: [IAFile] = []
var batches: [IABatch] = []
}
// MARK: - Instant Availability batch structures (used for client-side conversion)
struct IABatch: Codable, Hashable, Sendable {
let files: [IABatchFile]
@ -108,12 +87,6 @@ public extension RealDebrid {
let fileName: String
}
struct IAFile: Codable, Hashable, Sendable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
// MARK: - addMagnet endpoint
struct AddMagnetResponse: Codable, Sendable {
@ -123,7 +96,7 @@ public extension RealDebrid {
// MARK: - torrentInfo endpoint
internal struct TorrentInfoResponse: Codable, Sendable {
struct TorrentInfoResponse: Codable, Sendable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
@ -144,7 +117,7 @@ public extension RealDebrid {
}
}
internal struct TorrentInfoFile: Codable, Sendable {
struct TorrentInfoFile: Codable, Sendable {
let id: Int
let path: String
let bytes, selected: Int
@ -163,7 +136,7 @@ public extension RealDebrid {
// MARK: - unrestrictLink endpoint
internal struct UnrestrictLinkResponse: Codable, Sendable {
struct UnrestrictLinkResponse: Codable, Sendable {
let id, filename: String
let mimeType: String?
let filesize: Int

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -70,8 +70,8 @@ class LoggingManager: ObservableObject {
// TODO: Maybe append to a constant logfile?
public func info(_ message: String,
description: String? = nil)
func info(_ message: String,
description: String? = nil)
{
let log = Log(
level: .info,
@ -88,8 +88,8 @@ class LoggingManager: ObservableObject {
print("LOG: \(log.toMessage())")
}
public func warn(_ message: String,
description: String? = nil)
func warn(_ message: String,
description: String? = nil)
{
let log = Log(
level: .warn,
@ -106,9 +106,9 @@ class LoggingManager: ObservableObject {
print("LOG: \(log.toMessage())")
}
public func error(_ message: String,
description: String? = nil,
showToast: Bool = true)
func error(_ message: String,
description: String? = nil,
showToast: Bool = true)
{
let log = Log(
level: .error,
@ -121,7 +121,7 @@ class LoggingManager: ObservableObject {
if let description {
toastDescription = description
} else if showErrorToasts {
toastDescription = "An error was logged"
toastDescription = "An error was logged. Please look at logs in Settings."
}
}
@ -132,7 +132,7 @@ class LoggingManager: ObservableObject {
// MARK: - Indeterminate functions
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
indeterminateToastDescription = description
if let cancelAction {
@ -144,13 +144,13 @@ class LoggingManager: ObservableObject {
}
}
public func hideIndeterminateToast() {
func hideIndeterminateToast() {
showIndeterminateToast = false
indeterminateToastDescription = ""
indeterminateCancelAction = nil
}
public func exportLogs() {
func exportLogs() {
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")

View file

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

View file

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

View file

@ -27,18 +27,18 @@ class ScrapingViewModel: ObservableObject {
// Only add results with valid magnet hashes to the search results array
@MainActor
func updateSearchResults(newResults: [SearchResult]) {
private func updateSearchResults(newResults: [SearchResult]) {
searchResults += newResults
}
@MainActor
func clearSearchResults() {
private func clearSearchResults() {
searchResults = []
}
@Published var currentSourceNames: Set<String> = []
@MainActor
func updateCurrentSourceNames(_ newName: String) {
private func updateCurrentSourceNames(_ newName: String) {
currentSourceNames.insert(newName)
logManager?.updateIndeterminateToast(
"Loading \(currentSourceNames.joined(separator: ", "))",
@ -47,7 +47,7 @@ class ScrapingViewModel: ObservableObject {
}
@MainActor
func removeCurrentSourceName(_ removedName: String) {
private func removeCurrentSourceName(_ removedName: String) {
currentSourceNames.remove(removedName)
logManager?.updateIndeterminateToast(
"Loading \(currentSourceNames.joined(separator: ", "))",
@ -56,17 +56,39 @@ class ScrapingViewModel: ObservableObject {
}
@MainActor
func clearCurrentSourceNames() {
private func clearCurrentSourceNames() {
currentSourceNames = []
logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil)
}
// Utility function to print source specific errors
func sendSourceError(_ description: String) async {
private func sendSourceError(_ description: String) async {
await logManager?.error(description, showToast: false)
}
public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
// Substitutes the given string with an arbitrary parameter dictionary
private func substituteParams(_ input: String, with params: [String: String]) -> String {
let replaced = params.reduce(input) { result, param -> String in
result.replacingOccurrences(of: "{\(param.key)}", with: param.value)
}
return replaced
}
// Cleans a SourceRequest's body and headers to be substituted
private func cleanRequest(request: SourceRequest, params: [String: String]) -> SourceRequest {
if let body = request.body {
request.body = substituteParams(body, with: params)
}
if let headers = request.headers {
request.headers = headers.mapValues { substituteParams($0, with: params) }
}
return request
}
func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
await logManager?.info("Started scanning sources for query \"\(searchText)\"")
if sources.isEmpty {
@ -144,7 +166,7 @@ class ScrapingViewModel: ObservableObject {
}
}
func executeParser(source: Source) async -> SearchRequestResult? {
private func executeParser(source: Source) async -> SearchRequestResult? {
guard let website = source.website else {
await logManager?.error("Scraping: The base URL could not be found for source \(source.name)")
@ -160,18 +182,26 @@ class ScrapingViewModel: ObservableObject {
return nil
}
// Initial params dict to reference
// More params are added here as needed
var params: [String: String] = [
"query": encodedQuery,
"queryFirstLetter": encodedQuery.first.map { String($0).lowercased() } ?? ""
]
switch preferredParser {
case .scraping:
if let htmlParser = source.htmlParser {
let replacedSearchUrl = htmlParser.searchUrl.map {
$0.replacingOccurrences(of: "{query}", with: encodedQuery)
substituteParams($0, with: params)
}
let data = await handleUrls(
website: website,
replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls,
sourceName: source.name
sourceName: source.name,
requestParams: htmlParser.request.map { cleanRequest(request: $0, params: params) }
)
if let data,
@ -182,23 +212,25 @@ class ScrapingViewModel: ObservableObject {
}
case .rss:
if let rssParser = source.rssParser {
let replacedSearchUrl = rssParser.searchUrl
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
.replacingOccurrences(of: "{query}", with: encodedQuery)
params.updateValue(source.api?.clientSecret?.value ?? "", forKey: "secret")
let replacedSearchUrl = substituteParams(rssParser.searchUrl, with: params)
// Do not use fallback URLs if the base URL isn't used
let data: Data?
if let rssUrl = rssParser.rssUrl {
data = await fetchWebsiteData(
urlString: rssUrl + replacedSearchUrl,
sourceName: source.name
sourceName: source.name,
requestParams: rssParser.request
)
} else {
data = await handleUrls(
website: website,
replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls,
sourceName: source.name
sourceName: source.name,
requestParams: rssParser.request
)
}
@ -210,8 +242,7 @@ class ScrapingViewModel: ObservableObject {
}
case .siteApi:
if let jsonParser = source.jsonParser {
var replacedSearchUrl = jsonParser.searchUrl
.replacingOccurrences(of: "{query}", with: encodedQuery)
var replacedSearchUrl = substituteParams(jsonParser.searchUrl, with: params)
// Handle anything API related including tokens, client IDs, and appending the API URL
// The source API key is for APIs that require extra credentials or use a different URL
@ -247,7 +278,8 @@ class ScrapingViewModel: ObservableObject {
website: passedUrl,
replacedSearchUrl: replacedSearchUrl,
fallbackUrls: source.fallbackUrls,
sourceName: source.name
sourceName: source.name,
requestParams: jsonParser.request
)
if let data {
@ -262,16 +294,16 @@ class ScrapingViewModel: ObservableObject {
}
// Checks the base URL for any website data then iterates through the fallback URLs
func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String) async -> Data? {
private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? {
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
return data
}
if let fallbackUrls {
for fallbackUrl in fallbackUrls {
let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
return data
}
}
@ -280,12 +312,12 @@ class ScrapingViewModel: ObservableObject {
return nil
}
public func handleApiCredential(_ credential: SourceApiCredential,
replacement: String,
searchUrl: String,
apiUrl: String?,
website: String,
sourceName: String) async -> String?
private func handleApiCredential(_ credential: SourceApiCredential,
replacement: String,
searchUrl: String,
apiUrl: String?,
website: String,
sourceName: String) async -> String?
{
// Is the credential expired
var isExpired = false
@ -297,8 +329,7 @@ class ScrapingViewModel: ObservableObject {
// Fetch a new credential if it's expired or doesn't exist yet
if let value = credential.value, !isExpired {
return searchUrl
.replacingOccurrences(of: replacement, with: value)
return substituteParams(searchUrl, with: [replacement: value])
} else if
credential.value == nil || isExpired,
let credentialUrl = credential.urlString,
@ -322,9 +353,9 @@ class ScrapingViewModel: ObservableObject {
return nil
}
public func fetchApiCredential(urlString: String,
credential: SourceApiCredential,
sourceName: String) async -> String?
private func fetchApiCredential(urlString: String,
credential: SourceApiCredential,
sourceName: String) async -> String?
{
guard let url = URL(string: urlString) else {
await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.")
@ -368,7 +399,7 @@ class ScrapingViewModel: ObservableObject {
}
// Fetches the data for a URL
public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? {
private func fetchWebsiteData(urlString: String, sourceName: String, requestParams: SourceRequest?) async -> Data? {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!")
@ -387,7 +418,12 @@ class ScrapingViewModel: ObservableObject {
}
}
let request = URLRequest(url: url, timeoutInterval: timeout)
var request = URLRequest(url: url, timeoutInterval: timeout)
request.httpMethod = requestParams?.method
request.httpBody = requestParams?.body?.data(using: .utf8)
requestParams?.headers?.forEach { field, value in
request.addValue(value, forHTTPHeaderField: field)
}
do {
let (data, _) = try await URLSession.shared.data(for: request)
@ -410,7 +446,7 @@ class ScrapingViewModel: ObservableObject {
}
}
public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
guard let jsonParser = source.jsonParser else {
return nil
}
@ -485,10 +521,10 @@ class ScrapingViewModel: ObservableObject {
}
// TODO: Add regex parsing for API
public func parseJsonResult(_ result: JSON,
jsonParser: SourceJsonParser,
source: Source,
existingSearchResult: SearchResult? = nil) -> SearchResult?
private func parseJsonResult(_ result: JSON,
jsonParser: SourceJsonParser,
source: Source,
existingSearchResult: SearchResult? = nil) -> SearchResult?
{
// Enforce these parsers
guard let titleParser = jsonParser.title else {
@ -579,7 +615,7 @@ class ScrapingViewModel: ObservableObject {
}
// RSS feed scraper
public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
private func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
guard let rssParser = source.rssParser else {
return nil
}
@ -714,11 +750,11 @@ class ScrapingViewModel: ObservableObject {
}
// Complex query parsing for RSS scraping
func runRssComplexQuery(item: Element,
query: String,
attribute: String,
discriminator: String?,
regexString: String?) throws -> String?
private func runRssComplexQuery(item: Element,
query: String,
attribute: String,
discriminator: String?,
regexString: String?) throws -> String?
{
var parsedValue: String?
@ -747,7 +783,7 @@ class ScrapingViewModel: ObservableObject {
}
// HTML scraper
public func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
guard let htmlParser = source.htmlParser else {
return nil
}
@ -799,7 +835,7 @@ class ScrapingViewModel: ObservableObject {
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
guard
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name),
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request),
let magnetHtml = String(data: data, encoding: .utf8)
else {
continue
@ -884,7 +920,7 @@ class ScrapingViewModel: ObservableObject {
)
}
if let leecherQuery = seederLeecher.seeders {
if let leecherQuery = seederLeecher.leechers {
leechers = try? runHtmlComplexQuery(
row: row,
query: leecherQuery,
@ -919,10 +955,10 @@ class ScrapingViewModel: ObservableObject {
}
// Complex query parsing for HTML scraping
func runHtmlComplexQuery(row: Element,
query: String,
attribute: String,
regexString: String?) throws -> String?
private func runHtmlComplexQuery(row: Element,
query: String,
attribute: String,
regexString: String?) throws -> String?
{
var parsedValue: String?
@ -944,7 +980,7 @@ class ScrapingViewModel: ObservableObject {
}
}
func runRegex(parsedValue: String, regexString: String) -> String? {
private func runRegex(parsedValue: String, regexString: String) -> String? {
// TODO: Maybe dynamically parse flags
let replacedRegexString = regexString
.replacingOccurrences(of: "{query}", with: cleanedSearchText)
@ -967,7 +1003,7 @@ class ScrapingViewModel: ObservableObject {
}
}
func parseSizeString(sizeString: String) -> String? {
private func parseSizeString(sizeString: String) -> String? {
// Test if the string can be a full integer
guard let size = Int(sizeString) else {
return nil
@ -989,7 +1025,7 @@ class ScrapingViewModel: ObservableObject {
}
}
func cleanApiCreds(api: SourceApi, sourceName: String) async {
private func cleanApiCreds(api: SourceApi, sourceName: String) async {
let backgroundContext = PersistenceController.shared.backgroundContext
let hasCredentials = api.clientId != nil || api.clientSecret != nil

View file

@ -14,22 +14,34 @@ struct HybridSecureField: View {
}
@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)
TextField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .plain)
} else {
SecureField("Password", text: $text)
SecureField("Password", text: $text, onCommit: onCommit)
.focused($focusedField, equals: .secure)
}
}
.autocorrectionDisabled(true)
.autocapitalization(.none)
.disabledAppearance(isFieldDisabled)
Button {
showPassword.toggle()
@ -42,3 +54,9 @@ struct HybridSecureField: View {
}
}
}
extension HybridSecureField {
func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
}
}

View file

@ -14,18 +14,16 @@ struct NavView<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
// Uncomment once NavigationStack issues are fixed
/*
if #available(iOS 16, *) {
NavigationStack {
content
}
} else {
*/
NavigationView {
content
// NavigationStack issues are fixed on iOS 17
if #available(iOS 17, *) {
NavigationStack {
content
}
} else {
NavigationView {
content
}
.navigationViewStyle(.stack)
}
.navigationViewStyle(.stack)
// }
}
}

View file

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

View file

@ -15,23 +15,23 @@ struct SelectedDebridFilterView<Content: View>: View {
var body: some View {
Menu {
Button {
debridManager.selectedDebridType = nil
debridManager.selectedDebridSource = nil
} label: {
Text("None")
if debridManager.selectedDebridType == nil {
if debridManager.selectedDebridSource == nil {
Image(systemName: "checkmark")
}
}
ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in
if debridManager.enabledDebrids.contains(debridType) {
ForEach(debridManager.debridSources, id: \.id) { debridSource in
if debridSource.isLoggedIn {
Button {
debridManager.selectedDebridType = debridType
debridManager.selectedDebridSource = debridSource
} label: {
Text(debridType.toString())
Text(debridSource.id)
if debridManager.selectedDebridType == debridType {
if debridManager.selectedDebridSource?.id == debridSource.id {
Image(systemName: "checkmark")
}
}
@ -40,6 +40,5 @@ struct SelectedDebridFilterView<Content: View>: View {
} label: {
label
}
.id(debridManager.selectedDebridType)
}
}

View file

@ -56,7 +56,7 @@ struct BookmarksView: View {
.frame(height: 15)
}
.task {
if debridManager.enabledDebrids.count > 0 {
if !debridManager.enabledDebrids.isEmpty {
let magnets = bookmarks.compactMap {
if let magnetHash = $0.magnetHash {
return Magnet(hash: magnetHash, link: $0.magnetLink)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -53,7 +53,7 @@ struct SearchFilterHeaderView: View {
SelectedDebridFilterView {
FilterLabelView(
name: debridManager.selectedDebridType?.toString(),
name: debridManager.selectedDebridSource?.id,
fallbackName: "Debrid"
)
}

View file

@ -28,21 +28,28 @@ struct SearchResultButtonView: View {
navModel.selectedTitle = result.title ?? ""
navModel.resultFromCloud = false
var historyEntry = HistoryEntryJson(
name: result.title,
source: result.source
)
switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) {
case .full:
if debridManager.selectDebridResult(magnet: result.magnet) {
debridManager.currentDebridTask = Task {
await debridManager.fetchDebridDownload(magnet: result.magnet)
// Bump to batch
if debridManager.requiresUnrestrict {
navModel.selectedHistoryInfo = historyEntry
navModel.currentChoiceSheet = .batch
return
}
if !debridManager.downloadUrl.isEmpty {
PersistenceController.shared.createHistory(
HistoryEntryJson(
name: result.title,
url: debridManager.downloadUrl,
source: result.source
),
performSave: true
)
historyEntry.url = debridManager.downloadUrl
PersistenceController.shared.createHistory(historyEntry, performSave: true)
pluginManager.runDefaultAction(
urlString: debridManager.downloadUrl,
@ -57,21 +64,12 @@ struct SearchResultButtonView: View {
}
case .partial:
if debridManager.selectDebridResult(magnet: result.magnet) {
navModel.selectedHistoryInfo = HistoryEntryJson(
name: result.title,
source: result.source
)
navModel.selectedHistoryInfo = historyEntry
navModel.currentChoiceSheet = .batch
}
case .none:
PersistenceController.shared.createHistory(
HistoryEntryJson(
name: result.title,
url: result.magnet.link,
source: result.source
),
performSave: true
)
historyEntry.url = result.magnet.link
PersistenceController.shared.createHistory(historyEntry, performSave: true)
pluginManager.runDefaultAction(
urlString: result.magnet.link,
@ -127,14 +125,15 @@ struct SearchResultButtonView: View {
.alert("Caching file", isPresented: $debridManager.showDeleteAlert) {
Button("Yes", role: .destructive) {
Task {
await debridManager.deleteRdTorrent()
try? await debridManager.selectedDebridSource?.deleteUserMagnet(cloudMagnetId: nil)
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text(
"RealDebrid is currently caching this file. Would you like to delete it? \n\n" +
"Progress can be checked on the RealDebrid website."
"\(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") is currently caching this file. " +
"Would you like to delete it? \n\n" +
"Progress can be checked on the \(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") website."
)
}
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in

View file

@ -30,7 +30,9 @@ struct SearchResultInfoView: View {
Text(size)
}
DebridLabelView(magnet: result.magnet)
if let debridSource = debridManager.selectedDebridSource {
DebridLabelView(debridSource: debridSource, magnet: result.magnet)
}
}
.font(.caption)
}

View file

@ -10,15 +10,19 @@ import SwiftUI
struct SettingsDebridInfoView: View {
@EnvironmentObject var debridManager: DebridManager
let debridType: DebridType
@Store var debridSource: DebridSource
@State private var apiKeyTempText: String = ""
var body: some View {
List {
Section(header: InlineHeader("Description")) {
VStack(alignment: .leading, spacing: 10) {
Text("\(debridType.toString()) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.")
Text(debridSource.description ??
"\(debridSource.id) is a debrid service that is used for downloads and media playback. You must pay to access the service."
)
Link("Website", destination: URL(string: debridType.website()) ?? URL(string: "https://kingbri.dev/ferrite")!)
Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!)
}
}
@ -28,24 +32,56 @@ struct SettingsDebridInfoView: View {
) {
Button {
Task {
if debridManager.enabledDebrids.contains(debridType) {
await debridManager.logoutDebrid(debridType: debridType)
} else if !debridManager.getAuthProcessingBool(debridType: debridType) {
await debridManager.authenticateDebrid(debridType: debridType)
if debridSource.isLoggedIn {
await debridManager.logout(debridSource)
} else if !debridSource.authProcessing {
await debridManager.authenticateDebrid(debridSource, apiKey: nil)
}
apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? ""
}
} label: {
Text(
debridManager.enabledDebrids.contains(debridType)
debridSource.isLoggedIn
? "Logout"
: (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login")
: (debridSource.authProcessing ? "Processing" : "Login")
)
.foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue)
.foregroundColor(debridSource.isLoggedIn ? .red : .blue)
}
.alert("Invalid web login", isPresented: $debridManager.showWebLoginAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(
"\(debridSource.id) does not have a login portal. Please use an API key to login."
)
}
}
Section(
header: InlineHeader("API key"),
footer: Text("Add a permanent API key here. Only use this if web authentication does not work!")
) {
HybridSecureField(
text: $apiKeyTempText,
onCommit: {
Task {
if !apiKeyTempText.isEmpty {
await debridManager.authenticateDebrid(debridSource, apiKey: apiKeyTempText)
apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? ""
}
}
}
)
.fieldDisabled(debridSource.isLoggedIn)
}
.onAppear {
Task {
apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? ""
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(debridType.toString())
.navigationTitle(debridSource.id)
.navigationBarTitleDisplayMode(.inline)
}
}

View file

@ -38,7 +38,13 @@ struct LibraryView: View {
case .history:
HistoryView(allHistoryEntries: allHistoryEntries, searchText: $searchText)
case .debridCloud:
DebridCloudView(searchText: $searchText)
if let selectedDebridSource = debridManager.selectedDebridSource {
DebridCloudView(debridSource: selectedDebridSource, searchText: $searchText)
} else {
// Placeholder view that takes up the entire parent view
Color.clear
.frame(maxWidth: .infinity)
}
}
}
.overlay {
@ -53,7 +59,7 @@ struct LibraryView: View {
EmptyInstructionView(title: "No History", message: "Start watching to build history")
}
case .debridCloud:
if debridManager.selectedDebridType == nil {
if debridManager.selectedDebridSource == nil {
EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service")
}
}
@ -69,7 +75,7 @@ struct LibraryView: View {
switch navModel.libraryPickerSelection {
case .bookmarks, .debridCloud:
SelectedDebridFilterView {
Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid")
Text(debridManager.selectedDebridSource?.abbreviation ?? "Debrid")
}
.transaction {
$0.animation = .none
@ -90,6 +96,11 @@ struct LibraryView: View {
.esAutocapitalization(autocorrectSearch ? .sentences : .none)
.environment(\.editMode, $editMode)
}
.alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(debridManager.notImplementedMessage)
}
.onChange(of: navModel.libraryPickerSelection) { _ in
editMode = .inactive
}

View file

@ -7,7 +7,7 @@
import SwiftUI
public extension View {
extension View {
// A dismissAction must be added in the parent view struct due to lifecycle issues
func expandedSearchable(text: Binding<String>,
isSearching: Binding<Bool>? = nil,

View file

@ -9,19 +9,23 @@ import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
@AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth: Bool = true
var url: URL
func makeUIView(context: Context) -> WKWebView {
// Make the WebView ephemeral
// Make the WebView ephemeral depending on the ephemeral auth setting
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.nonPersistent()
config.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default()
let webView = WKWebView(frame: .zero, configuration: config)
let _ = webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.configuration.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default()
}
}
struct WebView_Previews: PreviewProvider {

View file

@ -8,6 +8,7 @@
import BetterSafariView
import Introspect
import SwiftUI
import WebKit
struct SettingsView: View {
@EnvironmentObject var debridManager: DebridManager
@ -24,6 +25,7 @@ struct SettingsView: View {
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false
@AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth = true
@AppStorage("Behavior.DisableRequestTimeout") var disableRequestTimeout = false
@AppStorage("Behavior.RequestTimeoutSecs") var requestTimeoutSecs: Double = 15
@ -44,14 +46,14 @@ struct SettingsView: View {
NavView {
Form {
Section(header: InlineHeader("Debrid services")) {
ForEach(DebridType.allCases, id: \.self) { debridType in
ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in
NavigationLink {
SettingsDebridInfoView(debridType: debridType)
SettingsDebridInfoView(debridSource: debridSource)
} label: {
HStack {
Text(debridType.toString())
Text(debridSource.id)
Spacer()
Text(debridManager.enabledDebrids.contains(debridType) ? "Enabled" : "Disabled")
Text(debridSource.isLoggedIn ? "Enabled" : "Disabled")
.foregroundColor(.secondary)
}
}
@ -73,7 +75,10 @@ struct SettingsView: View {
Section(
header: InlineHeader("Behavior"),
footer: Text("Only disable search timeout if results are slow to fetch")
footer: VStack(alignment: .leading, spacing: 8) {
Text("Temporarily disable ephemeral auth if you cannot log into a service")
Text("Only disable search timeout if results are slow to fetch")
}
) {
Toggle(isOn: $autocorrectSearch) {
Text("Autocorrect search")
@ -83,6 +88,21 @@ struct SettingsView: View {
Text("Random searchbar text")
}
Toggle(isOn: $useEphemeralAuth) {
Text("Ephemeral authentication")
}
.onChange(of: useEphemeralAuth) { changed in
// Does not work with ASWebAuthenticationSession
if changed {
Task {
let dataRecords = await WKWebsiteDataStore.default().dataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes())
await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: dataRecords)
}
}
}
// TODO: Change this to enable search timeout instead
Toggle(isOn: $disableRequestTimeout) {
Text("Disable search timeout")
}
@ -108,7 +128,7 @@ struct SettingsView: View {
}
Section(header: InlineHeader("Default actions")) {
if debridManager.enabledDebrids.count > 0 {
if !debridManager.enabledDebrids.isEmpty {
NavigationLink {
DefaultActionPickerView(
actionRequirement: .debrid,
@ -207,10 +227,10 @@ struct SettingsView: View {
callbackURLScheme: "ferrite"
) { callbackURL, error in
Task {
await debridManager.handleCallback(url: callbackURL, error: error)
await debridManager.handleAuthCallback(url: callbackURL, error: error)
}
}
.prefersEphemeralWebBrowserSession(true)
.prefersEphemeralWebBrowserSession(useEphemeralAuth)
}
.navigationTitle("Settings")
.toolbar {

View file

@ -23,39 +23,14 @@ struct BatchChoiceView: View {
var body: some View {
NavView {
List {
switch debridManager.selectedDebridType {
case .realDebrid:
ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
Button(file.name) {
debridManager.selectedRealDebridFile = file
ForEach(debridManager.selectedDebridItem?.files ?? [], id: \.self) { file in
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
Button(file.name) {
debridManager.selectedDebridFile = file
queueCommonDownload(fileName: file.name)
}
queueCommonDownload(fileName: file.name)
}
}
case .allDebrid:
ForEach(debridManager.selectedAllDebridItem?.files ?? [], id: \.self) { file in
if file.fileName.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
Button(file.fileName) {
debridManager.selectedAllDebridFile = file
queueCommonDownload(fileName: file.fileName)
}
}
}
case .premiumize:
ForEach(debridManager.selectedPremiumizeItem?.files ?? [], id: \.self) { file in
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
Button(file.name) {
debridManager.selectedPremiumizeFile = file
queueCommonDownload(fileName: file.name)
}
}
}
case .none:
EmptyView()
}
}
.tint(.primary)
@ -75,6 +50,7 @@ struct BatchChoiceView: View {
try? await Task.sleep(seconds: 1)
debridManager.clearSelectedDebridItems()
debridManager.requiresUnrestrict = false
}
}
}
@ -85,7 +61,11 @@ struct BatchChoiceView: View {
// Common function to communicate betwen VMs and queue/display a download
func queueCommonDownload(fileName: String) {
debridManager.currentDebridTask = Task {
await debridManager.fetchDebridDownload(magnet: navModel.resultFromCloud ? nil : navModel.selectedMagnet)
if debridManager.requiresUnrestrict {
await debridManager.unrestrictDownload()
} else {
await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet)
}
if !debridManager.downloadUrl.isEmpty {
try? await Task.sleep(seconds: 1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

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

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

101
README.md
View file

@ -1,7 +1,61 @@
# Ferrite
<p align="left">
<img src="https://img.shields.io/badge/Swift-5.10-orange.svg" alt="Swift 5.2"/>
<img src="https://img.shields.io/badge/platform-iOS%20%7C%20iPadOS-lightgrey" alt="Platform: iOS | iPadOS"/>
<a href="/LICENSE">
<img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3"/>
</a>
<a href="https://github.com/Ferrite-iOS/Ferrite/releases">
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/Ferrite-iOS/Ferrite/total?label=Downloads">
</a>
</p>
<p align="left">
<a href="https://github.com/Ferrite-iOS/Ferrite/actions/workflows/nightly.yml">
<img src="https://github.com/Ferrite-iOS/Ferrite/actions/workflows/nightly.yml/badge.svg?branch=next" alt="Nightly Build Status"/>
</a>
<a href="https://discord.gg/sYQxnuD7Fj">
<img src="https://img.shields.io/discord/545740643247456267.svg?logo=discord&color=blue" alt="Discord Server"/>
</a>
</p>
<p align="left">
<a href="http://real-debrid.com/?id=8109785">
<img src="https://img.shields.io/badge/Refer_on_RealDebrid-98ceeb?style=for-the-badge" alt="Refer on RealDebrid">
</a>
<a href="Misc/Referrals/TorBox.md">
<img src="https://img.shields.io/badge/Refer_on_TorBox-52a153?style=for-the-badge" alt="Refer on TorBox">
</a>
<a href="https://ko-fi.com/I2I3BDTSW">
<img src="https://img.shields.io/badge/Support_on_Ko--fi-FF5E5B?logo=ko-fi&style=for-the-badge&logoColor=white" alt="Support on Ko-Fi">
</a>
</p>
A media search engine for iOS with a plugin API to extend its functionality.
## Screenshots
### Dark Mode
| Search | Bookmarks | History |
| ------------- | -------- | -------- |
| ![1](Misc/Media/Demo/Dark/Search.png) | ![2](Misc/Media/Demo/Dark/Bookmarks.png) | ![3](Misc/Media/Demo/Dark/History.png) |
| Debrid Cloud | Plugins |
| ----------- | -------------------- |
| ![4](Misc/Media/Demo/Dark/Cloud.png) | ![5](Misc/Media/Demo/Dark/Plugins.png) |
### Light Mode
| Search | Bookmarks | History |
| ------------- | -------- | -------- |
| ![1](Misc/Media/Demo/Light/Search.png) | ![2](Misc/Media/Demo/Light/Bookmarks.png) | ![3](Misc/Media/Demo/Light/History.png) |
| Debrid Cloud | Plugins |
| ----------- | -------------------- |
| ![4](Misc/Media/Demo/Light/Cloud.png) | ![5](Misc/Media/Demo/Light/Plugins.png) |
## Disclaimer
This project is developed with a hobbyist/educational purpose and I am not responsible for what happens when you install Ferrite.
@ -16,31 +70,62 @@ However, the main problem is that these websites tend to suck in terms of UI or
I also wanted to support the use of debrid services since there aren't any (free) options on iOS that have support for this service.
## Features
- [x] Ad-free
- [x] Clean UI with native performance
- [x] Powerful search with an intuitive filter system
- [x] Modular plugin system
- [x] Integrates with many debrid providers
- [x] Flexible parser system written in native Swift
- [x] Local library with bookmarks and history
- [x] Manage your debrid cloud
- [x] Does not pollute your debrid cloud
- [x] Kodi integration
If there's a feature that's not listed here, open an issue or ask in the support Discord.
## What iOS versions are supported?
To decide what minimum version of iOS is supported, Ferrite follows an "n - 2" patten. For example, if iOS 18 is the latest version, the minimum supported iOS version is 16 (18-2 = 16).
To make this easier, the minimum required iOS version and Ferrite versions are listed below:
- v0.8 and up: iOS 16 and up
- v0.7 and up: iOS 15 and up
- v0.6.x and lower: iOS 14 and up
## Planned features
## Supported debrid services
More of these can be found in [issues](https://github.com/bdashore3/Ferrite/issues), but here is a small snippet:
Ferrite primarily uses Debrid services for instant streaming. A list of supported services are provided below:
- More involved search filtering
- RealDebrid
- AllDebrid
- Premiumize
- TorBox
- OffCloud
- Companion apps for playback on other devices
Want another debrid service? Make a request in issues or the support Discord.
## Downloads
Ferrite will only exist as an ipa. There will never be any plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts.
At this time, Ferrite will only exist as an ipa. There are no plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts.
## Plugins/Sources
Sources are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease.
Plugins are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease.
There are two types of plugins:
- Source: A plugin that looks something up in an indexer
- Action: A plugin that "does" something, such as opening a result in a separate application
To start off, a plugin list is located here (you can copy and paste this in the app) -> [https://raw.githubusercontent.com/Ferrite-iOS/example-sources/default/public-domain-plugins.yml](https://raw.githubusercontent.com/Ferrite-iOS/example-sources/default/public-domain-plugins.yml)
## Building from source
Xcode 14 must be used.
Use the latest stable version of Xcode.
There are currently two branches in the repository:
@ -67,7 +152,7 @@ If you have issues with the app:
- Describe the issue in detail
- If you have a feature request, please indicate it as so. Planned features are in a different section of the README, so be sure to read those before submitting.
- Please join [the discord](https://discord.gg/sYQxnuD7Fj) for more info
- Please join [the discord](https://discord.gg/sYQxnuD7Fj) for more info and support
## Developers and Permissions