Compare commits
239 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d7bc9b314 | ||
|
|
25bff02875 | ||
|
|
20dd00fa85 | ||
|
|
f9d2f38329 | ||
|
|
a7e20f30e6 | ||
|
|
ecf92239d2 | ||
|
|
dd54ec027b | ||
|
|
84357ea2c5 | ||
|
|
4fb5f77718 | ||
|
|
e5a872e09f | ||
|
|
1d6ac13e84 | ||
|
|
896efed663 | ||
|
|
a3463948ea | ||
|
|
215cd0feec | ||
|
|
9213b8627b | ||
|
|
6b40bb3ea2 | ||
|
|
dbf12c0a79 | ||
|
|
70b628b608 | ||
|
|
78f2aff25b | ||
|
|
489da8e82e | ||
|
|
078e48d316 | ||
|
|
646c22c9be | ||
|
|
d512d8b88d | ||
|
|
d0728e1a9b | ||
|
|
89367b72da | ||
|
|
c5a08cc725 | ||
|
|
0d39fd481a | ||
|
|
5223c60acd | ||
|
|
80e966512a | ||
|
|
8f7fe94d21 | ||
|
|
3ef041f889 | ||
|
|
e49e37af36 | ||
|
|
d6d731102c | ||
|
|
4beb953596 | ||
|
|
e1eca593f3 | ||
|
|
9b4f31daac | ||
|
|
24e39f9fba | ||
|
|
904b5a74b5 | ||
|
|
ecdd0199f6 | ||
|
|
3b771e5deb | ||
|
|
d8107cb5b6 | ||
|
|
42e202b207 | ||
|
|
afceea7bfb | ||
|
|
4ae1966934 | ||
|
|
796cc65016 | ||
|
|
90f44348b8 | ||
|
|
6192ef1ede | ||
|
|
973fbb4099 | ||
|
|
243a16e3c4 | ||
|
|
44a90b77eb | ||
|
|
59ac719d9a | ||
|
|
02636e0bda | ||
|
|
40b323bd56 | ||
|
|
91f124130c | ||
|
|
ec8455c08d | ||
|
|
0c3648120d | ||
|
|
9650e6deec | ||
|
|
07731e7b00 | ||
|
|
b80f8900b7 | ||
|
|
cf0c5a30f7 | ||
|
|
96a6722e65 | ||
|
|
0caf8a8120 | ||
|
|
273403b711 | ||
|
|
f9ecc746a1 | ||
|
|
c641fdf300 | ||
|
|
f902142fee | ||
|
|
37ef64224e | ||
|
|
9e306eff1e | ||
|
|
37450ef979 | ||
|
|
0fe1cbc888 | ||
|
|
2e746320cf | ||
|
|
13a40a237a | ||
|
|
46e0687bd7 | ||
|
|
b8978fd29c | ||
|
|
cc550dd208 | ||
|
|
d8db3e0cc8 | ||
|
|
3606dbb6ff | ||
|
|
9e362c14b7 | ||
|
|
76a0262a14 | ||
|
|
468d89b983 | ||
|
|
45900d6456 | ||
|
|
5232ddfc97 | ||
|
|
f7d2f1ce60 | ||
|
|
dc3014095c | ||
|
|
df534327e5 | ||
|
|
1d89b9519d | ||
|
|
8123fd8d0c | ||
|
|
75be076e0b | ||
|
|
4f303e1c1e | ||
|
|
9427ca271b | ||
|
|
eacccf36ff | ||
|
|
fbd99752e4 | ||
|
|
d5ba67503b | ||
|
|
29614bc0f8 | ||
|
|
b2616bdeb7 | ||
|
|
d918039810 | ||
|
|
51366f3215 | ||
|
|
22bec5da52 | ||
|
|
c8c7732575 | ||
|
|
2cf6e46422 | ||
|
|
2982c971a8 | ||
|
|
e650bbd2bb | ||
|
|
4ba58dc67f | ||
|
|
3828ffa539 | ||
|
|
8f9f522846 | ||
|
|
2435952a86 | ||
|
|
1ed0710446 | ||
|
|
5a73efa9dc | ||
|
|
371281118f | ||
|
|
39a705717e | ||
|
|
9f83ebfce0 | ||
|
|
87d94e4c35 | ||
|
|
ff13884b2b | ||
|
|
c719d2cd87 | ||
|
|
254058928f | ||
|
|
e0784b3cec | ||
|
|
6e95c6072c | ||
|
|
edfba1c62e | ||
|
|
69d2f6babe | ||
|
|
20c55316b0 | ||
|
|
d2d7d7364f | ||
|
|
7202a95bb2 | ||
|
|
a0632b0c16 | ||
|
|
618041714d | ||
|
|
d504369e98 | ||
|
|
b8799be896 | ||
|
|
438e48be66 | ||
|
|
8c8e9d0215 | ||
|
|
282783c460 | ||
|
|
f622b7af05 | ||
|
|
0661ed66f3 | ||
|
|
bcdacdae06 | ||
|
|
f8b6ea6ba7 | ||
|
|
cbe3d17be1 | ||
|
|
4a87d86e76 | ||
|
|
988e607027 | ||
|
|
291aa0fe46 | ||
|
|
cb4d935008 | ||
|
|
0f081d0716 | ||
|
|
88a2dc9742 | ||
|
|
41572362c7 | ||
|
|
9ff7f5a7d5 | ||
|
|
4512318e8f | ||
|
|
6b0f90178b | ||
|
|
e31f9a07fe | ||
|
|
6456b34210 | ||
|
|
f960efc1ed | ||
|
|
39c4a10a72 | ||
|
|
e8f62e3cdc | ||
|
|
2258036f7b | ||
|
|
90ed4f8353 | ||
|
|
025d3797dc | ||
|
|
5a4e98f10d | ||
|
|
9f54397b77 | ||
|
|
9b7bc55a25 | ||
|
|
b0850d43d7 | ||
|
|
15ad8c5581 | ||
|
|
47ef72bf13 | ||
|
|
04e4503c86 | ||
|
|
55226e5628 | ||
|
|
32e5e21d3c | ||
|
|
17867db40c | ||
|
|
2322d3af67 | ||
|
|
06d4f8e84e | ||
|
|
a1cd62d3b9 | ||
|
|
9d7f85cc62 | ||
|
|
3f588e249d | ||
|
|
f27578be7a | ||
|
|
102b59ab0a | ||
|
|
e063b91f3f | ||
|
|
a774564212 | ||
|
|
e3e8924547 | ||
|
|
a89e832d1c | ||
|
|
b85752c92c | ||
|
|
4d3a16f77e | ||
|
|
2f870b9410 | ||
|
|
5d97c7511f | ||
|
|
8306ca1f9b | ||
|
|
a9d2604fb3 | ||
|
|
52409099d7 | ||
|
|
9a3573a222 | ||
|
|
d4f2dba279 | ||
|
|
d1eb67cc16 | ||
|
|
aa344b8ea8 | ||
|
|
aad24d4b1d | ||
|
|
ff1fef85b0 | ||
|
|
4e149814a3 | ||
|
|
f7e1b87c73 | ||
|
|
3540e0bcd3 | ||
|
|
40c55e689a | ||
|
|
400242690b | ||
|
|
664c57b751 | ||
|
|
1bf64a8934 | ||
|
|
4deb853e01 | ||
|
|
1761f8dfb4 | ||
|
|
49010a270e | ||
|
|
62f1717c83 | ||
|
|
f9b6587e31 | ||
|
|
79d88ffab6 | ||
|
|
e0182a700f | ||
|
|
8c0e495f93 | ||
|
|
665c7510a2 | ||
|
|
0401d220dc | ||
|
|
3c6ce0bb94 | ||
|
|
a030cc8bf0 | ||
|
|
7c14d73fa8 | ||
|
|
530d107218 | ||
|
|
0501817333 | ||
|
|
eb5cc83ce2 | ||
|
|
3195d74eea | ||
|
|
d99a065d3b | ||
|
|
2792b61e9b | ||
|
|
59ee4f7a2a | ||
|
|
490a123bf7 | ||
|
|
cab6eb3bb6 | ||
|
|
a141ca5819 | ||
|
|
8723580074 | ||
|
|
ba993336a5 | ||
|
|
210faeff9e | ||
|
|
f45f05a3f5 | ||
|
|
9d0cc90783 | ||
|
|
c82cb1819d | ||
|
|
6a90dab386 | ||
|
|
24b86335d5 | ||
|
|
9c109a047f | ||
|
|
02f3d63884 | ||
|
|
bb481f471e | ||
|
|
ff23a854ef | ||
|
|
1eb4bbb59a | ||
|
|
3907f23fec | ||
|
|
bf70b436db | ||
|
|
282d0eacca | ||
|
|
064a68fbb2 | ||
|
|
b4a3d8d8c6 | ||
|
|
4bdda39663 | ||
|
|
4fda0d63db | ||
|
|
940f8337a5 | ||
|
|
64798a172a | ||
|
|
d1b34fe964 |
193 changed files with 14053 additions and 1476 deletions
21
.github/workflows/nightly.yml
vendored
21
.github/workflows/nightly.yml
vendored
|
|
@ -2,30 +2,31 @@ name: Build and upload nightly ipa
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [none]
|
branches: [next]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-latest
|
runs-on: macos-14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Xcode
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
with:
|
with:
|
||||||
xcode-version: latest
|
xcode-version: latest-stable
|
||||||
- name: Get commit SHA
|
- name: Get commit SHA
|
||||||
id: commitinfo
|
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
||||||
|
env:
|
||||||
|
IS_NIGHTLY: YES
|
||||||
- name: Package ipa
|
- name: Package ipa
|
||||||
run: |
|
run: |
|
||||||
mkdir Payload
|
mkdir Payload
|
||||||
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
|
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
|
||||||
zip -r Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa Payload
|
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
|
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
||||||
path: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
|
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
|
||||||
36
.github/workflows/release.yml
vendored
Normal file
36
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
name: Build and upload release ipa
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- created
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: latest-stable
|
||||||
|
- name: Build
|
||||||
|
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
||||||
|
env:
|
||||||
|
IS_NIGHTLY: NO
|
||||||
|
- name: Get app version
|
||||||
|
run: |
|
||||||
|
echo "app_version=$(/usr/libexec/plistbuddy -c Print:CFBundleShortVersionString: build/Ferrite.xcarchive/Products/Applications/Ferrite.app/Info.plist)" >> $GITHUB_ENV
|
||||||
|
- name: Package ipa
|
||||||
|
run: |
|
||||||
|
mkdir Payload
|
||||||
|
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
|
||||||
|
zip -r Ferrite-iOS_v${{ env.app_version }}.ipa Payload
|
||||||
|
- name: Create ipa zip
|
||||||
|
run: |
|
||||||
|
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
|
||||||
|
- name: Upload release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip
|
||||||
|
|
@ -1 +1 @@
|
||||||
5.7
|
5.8
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1400"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
||||||
396
Ferrite/API/AllDebridWrapper.swift
Normal file
396
Ferrite/API/AllDebridWrapper.swift
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
//
|
||||||
|
// AllDebridWrapper.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/25/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AllDebrid: PollingDebridSource, ObservableObject {
|
||||||
|
let id = "AllDebrid"
|
||||||
|
let abbreviation = "AD"
|
||||||
|
let website = "https://alldebrid.com"
|
||||||
|
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " +
|
||||||
|
"You must pay to access this service. \n\n" +
|
||||||
|
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||||
|
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||||
|
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||||
|
"This service does not inform if a magnet link is a batch before downloading."
|
||||||
|
|
||||||
|
let cachedStatus: [String] = ["Ready"]
|
||||||
|
var authTask: Task<Void, Error>?
|
||||||
|
|
||||||
|
@Published var authProcessing: Bool = false
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
getToken() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") {
|
||||||
|
return getToken()
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseApiUrl = "https://api.alldebrid.com/v4"
|
||||||
|
private let appName = "Ferrite"
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserDownloads()
|
||||||
|
try? await getUserMagnets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
// Fetches information for PIN auth
|
||||||
|
func getAuthUrl() async throws -> URL {
|
||||||
|
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
|
||||||
|
let request = URLRequest(url: url)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// Validate the URL before doing anything else
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
|
||||||
|
guard let userUrl = URL(string: rawResponse.userURL) else {
|
||||||
|
throw DebridError.AuthQuery(description: "The login URL is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the polling task separately
|
||||||
|
authTask = Task {
|
||||||
|
try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userUrl
|
||||||
|
} catch {
|
||||||
|
print("Couldn't get pin information!")
|
||||||
|
throw DebridError.AuthQuery(description: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches API keys
|
||||||
|
func getApiKey(checkID: String, pin: String) async throws {
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "agent", value: appName),
|
||||||
|
URLQueryItem(name: "check", value: checkID),
|
||||||
|
URLQueryItem(name: "pin", value: pin)
|
||||||
|
]
|
||||||
|
|
||||||
|
let request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
|
||||||
|
|
||||||
|
// Timer to poll AD API for key
|
||||||
|
authTask = Task {
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
while count < 12 {
|
||||||
|
if Task.isCancelled {
|
||||||
|
throw DebridError.AuthQuery(description: "Token request cancelled.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// We don't care if this fails
|
||||||
|
let rawResponse = try? self.jsonDecoder.decode(ADResponse<ApiKeyResponse>.self, from: data).data
|
||||||
|
|
||||||
|
// If there's an API key from the response, end the task successfully
|
||||||
|
if let apiKeyResponse = rawResponse {
|
||||||
|
FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
|
||||||
|
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
try await Task.sleep(seconds: 5)
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if case let .failure(error) = await authTask?.result {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a manual API key instead of web auth
|
||||||
|
func setApiKey(_ key: String) {
|
||||||
|
FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
|
||||||
|
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getToken() -> String? {
|
||||||
|
FerriteKeychain.shared.get("AllDebrid.ApiKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears tokens. No endpoint to deregister a device
|
||||||
|
func logout() {
|
||||||
|
FerriteKeychain.shared.delete("AllDebrid.ApiKey")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Common request
|
||||||
|
|
||||||
|
// Wrapper request function which matches the responses and returns data
|
||||||
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
|
guard let token = getToken() else {
|
||||||
|
throw DebridError.InvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
|
return data
|
||||||
|
} else if response.statusCode == 401 {
|
||||||
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
|
||||||
|
} else {
|
||||||
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a URL for further requests
|
||||||
|
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||||
|
guard var components = URLComponents(string: urlString) else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "agent", value: appName)
|
||||||
|
] + queryItems
|
||||||
|
|
||||||
|
if let url = components.url {
|
||||||
|
return url
|
||||||
|
} else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instant availability
|
||||||
|
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
let sendMagnets = magnets.filter { magnet in
|
||||||
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
|
IAValues.remove(at: IAIndex)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the user magnets to the latest version
|
||||||
|
try await getUserMagnets()
|
||||||
|
|
||||||
|
for cloudMagnet in cloudMagnets {
|
||||||
|
if cachedStatus.contains(cloudMagnet.status),
|
||||||
|
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||||
|
{
|
||||||
|
IAValues.append(
|
||||||
|
DebridIA(
|
||||||
|
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downloading
|
||||||
|
|
||||||
|
// Wrapper function to fetch a download link from the API
|
||||||
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
|
let selectedMagnetId: String
|
||||||
|
|
||||||
|
if let existingMagnet = cloudMagnets.first(where: {
|
||||||
|
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||||
|
}) {
|
||||||
|
selectedMagnetId = existingMagnet.id
|
||||||
|
} else {
|
||||||
|
let magnetId = try await addMagnet(magnet: magnet)
|
||||||
|
selectedMagnetId = String(magnetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawResponse = try await fetchMagnetStatus(
|
||||||
|
magnetId: selectedMagnetId,
|
||||||
|
selectedIndex: iaFile?.id ?? 0
|
||||||
|
)
|
||||||
|
guard let magnets = rawResponse.magnets[safe: 0] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batches require an unrestrict from the user
|
||||||
|
if magnets.links.count > 1, iaFile == nil {
|
||||||
|
var copiedIA = ia
|
||||||
|
|
||||||
|
copiedIA?.files = magnets.links.enumerated().compactMap { index, file in
|
||||||
|
DebridIAFile(
|
||||||
|
id: index,
|
||||||
|
name: file.filename,
|
||||||
|
streamUrlString: file.link
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (nil, copiedIA)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cloudMagnetFile = magnets.links[safe: iaFile?.id ?? 0] {
|
||||||
|
let restrictedFile = DebridIAFile(
|
||||||
|
id: 0,
|
||||||
|
name: cloudMagnetFile.filename,
|
||||||
|
streamUrlString: cloudMagnetFile.link
|
||||||
|
)
|
||||||
|
|
||||||
|
return (restrictedFile, nil)
|
||||||
|
} else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a magnet link to the user's AD account
|
||||||
|
func addMagnet(magnet: Magnet) async throws -> Int {
|
||||||
|
guard let magnetLink = magnet.link else {
|
||||||
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var bodyComponents = URLComponents()
|
||||||
|
bodyComponents.queryItems = [
|
||||||
|
URLQueryItem(name: "magnets[]", value: magnetLink)
|
||||||
|
]
|
||||||
|
|
||||||
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
|
||||||
|
|
||||||
|
if let magnet = rawResponse.magnets[safe: 0] {
|
||||||
|
if !magnet.ready {
|
||||||
|
throw DebridError.IsCaching
|
||||||
|
}
|
||||||
|
|
||||||
|
return magnet.id
|
||||||
|
} else {
|
||||||
|
throw DebridError.InvalidResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "id", value: magnetId)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
||||||
|
|
||||||
|
return rawResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known as unlockLink in AD's API
|
||||||
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: "unlockLink")
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
|
||||||
|
|
||||||
|
return rawResponse.link
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveLink(link: String) async throws {
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "links[]", value: link)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cloud methods
|
||||||
|
|
||||||
|
func getUserMagnets() async throws {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
||||||
|
|
||||||
|
cloudMagnets = rawResponse.magnets.map { magnetResponse in
|
||||||
|
DebridCloudMagnet(
|
||||||
|
id: String(magnetResponse.id),
|
||||||
|
fileName: magnetResponse.filename,
|
||||||
|
status: magnetResponse.status,
|
||||||
|
hash: magnetResponse.hash,
|
||||||
|
links: magnetResponse.links.map(\.link)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||||
|
guard let cloudMagnetId else {
|
||||||
|
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "id", value: cloudMagnetId)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserDownloads() async throws {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
|
||||||
|
|
||||||
|
// The link is also the ID
|
||||||
|
cloudDownloads = rawResponse.links.map { link in
|
||||||
|
DebridCloudDownload(
|
||||||
|
id: link.link, fileName: link.filename, link: link.link
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not used
|
||||||
|
func checkUserDownloads(link: String) -> String? {
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
// The downloadId is actually the download link
|
||||||
|
func deleteUserDownload(downloadId: String) async throws {
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "link", value: downloadId)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Ferrite/API/GithubWrapper.swift
Normal file
28
Ferrite/API/GithubWrapper.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
// GithubWrapper.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/28/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Github {
|
||||||
|
func fetchLatestRelease() async throws -> Release? {
|
||||||
|
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")!
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
|
||||||
|
let rawResponse = try JSONDecoder().decode(Release.self, from: data)
|
||||||
|
return rawResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchReleases() async throws -> [Release]? {
|
||||||
|
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")!
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
|
||||||
|
let rawResponse = try JSONDecoder().decode([Release].self, from: data)
|
||||||
|
return rawResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Ferrite/API/KodiWrapper.swift
Normal file
129
Ferrite/API/KodiWrapper.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
//
|
||||||
|
// KodiWrapper.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Kodi {
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
// Used to add server to CoreData. Not part of API
|
||||||
|
func addServer(urlString: String,
|
||||||
|
friendlyName: String?,
|
||||||
|
username: String?,
|
||||||
|
password: String?,
|
||||||
|
existingServer: KodiServer? = nil) throws
|
||||||
|
{
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
if !urlString.starts(with: "http://"), !urlString.starts(with: "https://") {
|
||||||
|
throw KodiError.ServerAddition(description: "Could not add Kodi server because the URL is invalid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = ""
|
||||||
|
if let friendlyName {
|
||||||
|
name = friendlyName
|
||||||
|
} else {
|
||||||
|
var components = URLComponents(string: urlString)
|
||||||
|
components?.scheme = nil
|
||||||
|
components?.path = ""
|
||||||
|
|
||||||
|
guard let cleanedName = components?.url?.description.dropFirst(2) else {
|
||||||
|
throw KodiError.ServerAddition(description: "An invalid friendly name for this Kodi server was generated.")
|
||||||
|
}
|
||||||
|
|
||||||
|
name = String(cleanedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingServer == nil {
|
||||||
|
let existingServerRequest = KodiServer.fetchRequest()
|
||||||
|
existingServerRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
// If a server with the same name or URL exists, error out
|
||||||
|
let namePredicate = NSPredicate(format: "name == %@", name)
|
||||||
|
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
|
||||||
|
existingServerRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, urlPredicate])
|
||||||
|
|
||||||
|
if (try? backgroundContext.fetch(existingServerRequest).first) != nil {
|
||||||
|
throw KodiError.ServerAddition(description: "An existing kodi server with the same name or URL was found. Please try editing an existing server instead.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newServerObject = existingServer ?? KodiServer(context: backgroundContext)
|
||||||
|
|
||||||
|
newServerObject.urlString = urlString
|
||||||
|
newServerObject.name = name
|
||||||
|
|
||||||
|
if let username, let password {
|
||||||
|
newServerObject.username = username
|
||||||
|
newServerObject.password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
try backgroundContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ping(server: KodiServer) async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let requestBody = RPCPayload(
|
||||||
|
method: "JSONRPC.Ping",
|
||||||
|
params: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if let username = server.username, let password = server.password {
|
||||||
|
request.setValue("Basic \(Data("\(username):\(password)".utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
request.httpBody = try encoder.encode(requestBody)
|
||||||
|
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
throw KodiError.FailedRequest(description: "No HTTP response given")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.statusCode == 401 {
|
||||||
|
throw KodiError.FailedRequest(description: "Your Kodi account details for server \(server.name) are invalid. Please check your credentials in Settings > Kodi.")
|
||||||
|
} else if response.statusCode <= 200, response.statusCode >= 299 {
|
||||||
|
throw KodiError.FailedRequest(description: "The Kodi request failed with status code \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVideoUrl(urlString: String, server: KodiServer) async throws {
|
||||||
|
if URL(string: urlString) == nil {
|
||||||
|
throw KodiError.InvalidPlaybackUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBody = RPCPayload(
|
||||||
|
method: "Player.Open",
|
||||||
|
params: Params(item: Item(file: urlString))
|
||||||
|
)
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
if let username = server.username, let password = server.password {
|
||||||
|
request.setValue("Basic \(Data("\(username):\(password)".utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
request.httpBody = try encoder.encode(requestBody)
|
||||||
|
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
throw KodiError.FailedRequest(description: "No HTTP response given")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.statusCode == 401 {
|
||||||
|
throw KodiError.FailedRequest(description: "Your Kodi account details are invalid. Please check your credentials in Settings > Kodi.")
|
||||||
|
} else if response.statusCode <= 200, response.statusCode >= 299 {
|
||||||
|
throw KodiError.FailedRequest(description: "The Kodi request failed with status code \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
Ferrite/API/OffCloudWrapper.swift
Normal file
277
Ferrite/API/OffCloudWrapper.swift
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
//
|
||||||
|
// OffCloudWrapper.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 6/12/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class OffCloud: DebridSource, ObservableObject {
|
||||||
|
let id = "OffCloud"
|
||||||
|
let abbreviation = "OC"
|
||||||
|
let website = "https://offcloud.com"
|
||||||
|
let description: String? = "OffCloud is a debrid service that is used for downloads and media playback. " +
|
||||||
|
"You must pay to access this service. \n\n" +
|
||||||
|
"This service does not inform if a magnet link is a batch before downloading."
|
||||||
|
let cachedStatus: [String] = ["downloaded"]
|
||||||
|
|
||||||
|
@Published var authProcessing: Bool = false
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
getToken() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "OffCloud.UseManualKey") {
|
||||||
|
return getToken()
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseApiUrl = "https://offcloud.com/api"
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
private let jsonEncoder = JSONEncoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserMagnets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setApiKey(_ key: String) {
|
||||||
|
FerriteKeychain.shared.set(key, forKey: "OffCloud.ApiKey")
|
||||||
|
UserDefaults.standard.set(true, forKey: "OffCloud.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async {
|
||||||
|
FerriteKeychain.shared.delete("OffCloud.ApiKey")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "OffCloud.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getToken() -> String? {
|
||||||
|
FerriteKeychain.shared.get("OffCloud.ApiKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper request function which matches the responses and returns data
|
||||||
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
|
return data
|
||||||
|
} else if response.statusCode == 401 {
|
||||||
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to TorBox in Settings.")
|
||||||
|
} else {
|
||||||
|
print(response)
|
||||||
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a URL for further requests
|
||||||
|
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||||
|
guard var components = URLComponents(string: urlString) else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = getToken() else {
|
||||||
|
throw DebridError.InvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "key", value: token)
|
||||||
|
] + queryItems
|
||||||
|
|
||||||
|
if let url = components.url {
|
||||||
|
return url
|
||||||
|
} else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
let sendMagnets = magnets.filter { magnet in
|
||||||
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
|
IAValues.remove(at: IAIndex)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendMagnets.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cache"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = InstantAvailabilityRequest(hashes: sendMagnets.compactMap(\.hash))
|
||||||
|
request.httpBody = try jsonEncoder.encode(body)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(InstantAvailabilityResponse.self, from: data)
|
||||||
|
|
||||||
|
let availableHashes = rawResponse.cachedItems.map {
|
||||||
|
DebridIA(
|
||||||
|
magnet: Magnet(hash: $0, link: nil),
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IAValues += availableHashes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud in OffCloud's API
|
||||||
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
|
let selectedCloudMagnet: DebridCloudMagnet
|
||||||
|
|
||||||
|
// Don't queue a new job if the magnet already exists in the user's account
|
||||||
|
if let existingCloudMagnet = cloudMagnets.first(where: { $0.hash == magnet.hash && cachedStatus.contains($0.status) }) {
|
||||||
|
selectedCloudMagnet = existingCloudMagnet
|
||||||
|
} else {
|
||||||
|
let cloudDownloadResponse = try await offcloudDownload(magnet: magnet)
|
||||||
|
|
||||||
|
guard cachedStatus.contains(cloudDownloadResponse.status) else {
|
||||||
|
throw DebridError.IsCaching
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCloudMagnet = DebridCloudMagnet(
|
||||||
|
id: cloudDownloadResponse.requestId,
|
||||||
|
fileName: cloudDownloadResponse.fileName,
|
||||||
|
status: cloudDownloadResponse.status,
|
||||||
|
hash: "",
|
||||||
|
links: [cloudDownloadResponse.url]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cloudExploreResponse = try await cloudExplore(requestId: selectedCloudMagnet.id)
|
||||||
|
|
||||||
|
// Request will error if the file isn't a batch
|
||||||
|
if case let .links(cloudExploreLinks) = cloudExploreResponse {
|
||||||
|
var copiedIA = ia
|
||||||
|
|
||||||
|
copiedIA?.files = cloudExploreLinks.enumerated().compactMap { index, exploreLink in
|
||||||
|
guard let exploreURL = URL(string: exploreLink) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return DebridIAFile(
|
||||||
|
id: index,
|
||||||
|
name: exploreURL.lastPathComponent,
|
||||||
|
streamUrlString: exploreLink.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (nil, copiedIA)
|
||||||
|
} else if case let .error(cloudExploreError) = cloudExploreResponse,
|
||||||
|
cloudExploreError.error.lowercased() == "bad archive"
|
||||||
|
{
|
||||||
|
guard let selectedCloudLink = selectedCloudMagnet.links[safe: 0] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
let restrictedFile = DebridIAFile(
|
||||||
|
id: 0,
|
||||||
|
name: selectedCloudMagnet.fileName,
|
||||||
|
streamUrlString: "\(selectedCloudLink)/\(selectedCloudMagnet.fileName)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (restrictedFile, nil)
|
||||||
|
} else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called as "cloud" in offcloud's API
|
||||||
|
private func offcloudDownload(magnet: Magnet) async throws -> CloudDownloadResponse {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
guard let magnetLink = magnet.link else {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = CloudDownloadRequest(url: magnetLink)
|
||||||
|
request.httpBody = try jsonEncoder.encode(body)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: "cloud")
|
||||||
|
let rawResponse = try jsonDecoder.decode(CloudDownloadResponse.self, from: data)
|
||||||
|
|
||||||
|
return rawResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cloudExplore(requestId: String) async throws -> CloudExploreResponse {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud/explore/\(requestId)"))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: "cloudExplore")
|
||||||
|
let rawResponse = try jsonDecoder.decode(CloudExploreResponse.self, from: data)
|
||||||
|
|
||||||
|
return rawResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
|
guard let streamUrlString = restrictedFile.streamUrlString else {
|
||||||
|
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the OffCloud API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrlString
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserDownloads() {}
|
||||||
|
|
||||||
|
func checkUserDownloads(link: String) -> String? {
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserDownload(downloadId: String) {}
|
||||||
|
|
||||||
|
func getUserMagnets() async throws {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/cloud/history"))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: "cloudHistory")
|
||||||
|
let rawResponse = try jsonDecoder.decode([CloudHistoryResponse].self, from: data)
|
||||||
|
|
||||||
|
cloudMagnets = rawResponse.compactMap { cloudHistory in
|
||||||
|
guard let magnetHash = Magnet(hash: nil, link: cloudHistory.originalLink).hash else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return DebridCloudMagnet(
|
||||||
|
id: cloudHistory.requestId,
|
||||||
|
fileName: cloudHistory.fileName,
|
||||||
|
status: cloudHistory.status,
|
||||||
|
hash: magnetHash,
|
||||||
|
links: [cloudHistory.originalLink]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses the base website because this isn't present in the API path but still works like the API?
|
||||||
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||||
|
guard let cloudMagnetId else {
|
||||||
|
throw DebridError.InvalidPostBody
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(website)/cloud/remove/\(cloudMagnetId)"))
|
||||||
|
try await performRequest(request: &request, requestName: "cloudRemove")
|
||||||
|
}
|
||||||
|
}
|
||||||
381
Ferrite/API/PremiumizeWrapper.swift
Normal file
381
Ferrite/API/PremiumizeWrapper.swift
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
//
|
||||||
|
// PremiumizeWrapper.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/28/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Premiumize: OAuthDebridSource, ObservableObject {
|
||||||
|
let id = "Premiumize"
|
||||||
|
let abbreviation = "PM"
|
||||||
|
let website = "https://premiumize.me"
|
||||||
|
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
|
||||||
|
"You must pay to access the service."
|
||||||
|
|
||||||
|
@Published var authProcessing: Bool = false
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
getToken() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
|
||||||
|
return getToken()
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseAuthUrl = "https://www.premiumize.me/authorize"
|
||||||
|
private let baseApiUrl = "https://www.premiumize.me/api"
|
||||||
|
private let clientId = "791565696"
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
func getAuthUrl() throws -> URL {
|
||||||
|
var urlComponents = URLComponents(string: baseAuthUrl)!
|
||||||
|
urlComponents.queryItems = [
|
||||||
|
URLQueryItem(name: "client_id", value: clientId),
|
||||||
|
URLQueryItem(name: "response_type", value: "token"),
|
||||||
|
URLQueryItem(name: "state", value: UUID().uuidString)
|
||||||
|
]
|
||||||
|
|
||||||
|
if let url = urlComponents.url {
|
||||||
|
return url
|
||||||
|
} else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAuthCallback(url: URL) throws {
|
||||||
|
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
|
||||||
|
guard let callbackFragment = callbackComponents?.fragment else {
|
||||||
|
throw DebridError.InvalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragmentComponents = URLComponents()
|
||||||
|
fragmentComponents.query = callbackFragment
|
||||||
|
|
||||||
|
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
|
||||||
|
throw DebridError.InvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a manual API key instead of web auth
|
||||||
|
func setApiKey(_ key: String) {
|
||||||
|
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
|
||||||
|
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getToken() -> String? {
|
||||||
|
FerriteKeychain.shared.get("Premiumize.AccessToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears tokens. No endpoint to deregister a device
|
||||||
|
func logout() {
|
||||||
|
FerriteKeychain.shared.delete("Premiumize.AccessToken")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Common request
|
||||||
|
|
||||||
|
// Wrapper request function which matches the responses and returns data
|
||||||
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
|
guard let token = getToken() else {
|
||||||
|
throw DebridError.InvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the API query parameter if a manual API key is present
|
||||||
|
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
|
||||||
|
guard
|
||||||
|
let requestUrl = request.url,
|
||||||
|
var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false)
|
||||||
|
else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiTokenItem = URLQueryItem(name: "apikey", value: token)
|
||||||
|
|
||||||
|
if components.queryItems == nil {
|
||||||
|
components.queryItems = [apiTokenItem]
|
||||||
|
} else {
|
||||||
|
components.queryItems?.append(apiTokenItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.url = components.url
|
||||||
|
} else {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
|
return data
|
||||||
|
} else if response.statusCode == 401 {
|
||||||
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
|
||||||
|
} else {
|
||||||
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instant availability
|
||||||
|
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
// Remove magnets that don't have an associated link for PM along with existing TTL logic
|
||||||
|
let sendMagnets = magnets.filter { magnet in
|
||||||
|
if magnet.link == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
|
IAValues.remove(at: IAIndex)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendMagnets.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableMagnets = try await divideCacheRequests(magnets: sendMagnets)
|
||||||
|
|
||||||
|
// Split DDL requests into chunks of 10
|
||||||
|
for chunk in availableMagnets.chunked(into: 10) {
|
||||||
|
let tempIA = try await divideDDLRequests(magnetChunk: chunk)
|
||||||
|
IAValues += tempIA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to divide and execute DDL endpoint requests in parallel
|
||||||
|
// Calls this for 10 requests at a time to not overwhelm API servers
|
||||||
|
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
|
||||||
|
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
|
||||||
|
for magnet in magnetChunk {
|
||||||
|
group.addTask {
|
||||||
|
try await self.fetchDDL(magnet: magnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkedIA: [DebridIA] = []
|
||||||
|
for try await ia in group {
|
||||||
|
chunkedIA.append(ia)
|
||||||
|
}
|
||||||
|
return chunkedIA
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempIA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grabs DDL links
|
||||||
|
private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
|
||||||
|
if magnet.hash == nil {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var bodyComponents = URLComponents()
|
||||||
|
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
|
||||||
|
|
||||||
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
|
||||||
|
let content = rawResponse.content ?? []
|
||||||
|
|
||||||
|
if !content.isEmpty {
|
||||||
|
let files = content.map { file in
|
||||||
|
DebridIAFile(
|
||||||
|
id: 0,
|
||||||
|
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
|
||||||
|
streamUrlString: file.link
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DebridIA(
|
||||||
|
magnet: magnet,
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: files
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to divide and execute cache endpoint requests in parallel
|
||||||
|
// Calls this for 100 hashes at a time due to API limits
|
||||||
|
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
|
||||||
|
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
|
||||||
|
for chunk in magnets.chunked(into: 100) {
|
||||||
|
group.addTask {
|
||||||
|
try await self.checkCache(magnets: chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkedMagnets: [Magnet] = []
|
||||||
|
for try await magnetArray in group {
|
||||||
|
chunkedMagnets += magnetArray
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunkedMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent function for initial checking of the cache
|
||||||
|
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
|
||||||
|
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
|
||||||
|
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
|
||||||
|
guard let url = urlComponents.url else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
|
||||||
|
|
||||||
|
if rawResponse.response.isEmpty {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
} else {
|
||||||
|
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
|
||||||
|
if rawResponse.response[safe: index] == true {
|
||||||
|
return magnet
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableMagnets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downloading
|
||||||
|
|
||||||
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
|
// Store the item in PM cloud for later use
|
||||||
|
try await createTransfer(magnet: magnet)
|
||||||
|
|
||||||
|
if let iaFile {
|
||||||
|
return (iaFile, nil)
|
||||||
|
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
|
||||||
|
return (firstFile, nil)
|
||||||
|
} else {
|
||||||
|
throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
|
guard let streamUrlString = restrictedFile.streamUrlString else {
|
||||||
|
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrlString
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTransfer(magnet: Magnet) async throws {
|
||||||
|
guard let magnetLink = magnet.link else {
|
||||||
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var bodyComponents = URLComponents()
|
||||||
|
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnetLink)]
|
||||||
|
|
||||||
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cloud methods
|
||||||
|
|
||||||
|
func getUserDownloads() async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data)
|
||||||
|
|
||||||
|
if rawResponse.files.isEmpty {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "link" is the ID for Premiumize
|
||||||
|
cloudDownloads = rawResponse.files.map { file in
|
||||||
|
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
|
||||||
|
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
|
||||||
|
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
|
||||||
|
guard let url = urlComponents.url else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(ItemDetailsResponse.self, from: data)
|
||||||
|
|
||||||
|
return rawResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUserDownloads(link: String) async throws -> String? {
|
||||||
|
// Link is the cloud item ID
|
||||||
|
try await itemDetails(itemID: link).link
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserDownload(downloadId: String) async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var bodyComponents = URLComponents()
|
||||||
|
bodyComponents.queryItems = [URLQueryItem(name: "id", value: downloadId)]
|
||||||
|
|
||||||
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No user magnets for Premiumize
|
||||||
|
func getUserMagnets() {}
|
||||||
|
|
||||||
|
func deleteUserMagnet(cloudMagnetId: String?) {}
|
||||||
|
}
|
||||||
|
|
@ -6,32 +6,69 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import KeychainSwift
|
|
||||||
|
|
||||||
public enum RealDebridError: Error {
|
class RealDebrid: PollingDebridSource, ObservableObject {
|
||||||
case InvalidUrl
|
let id = "RealDebrid"
|
||||||
case InvalidPostBody
|
let abbreviation = "RD"
|
||||||
case InvalidResponse
|
let website = "https://real-debrid.com"
|
||||||
case InvalidToken
|
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
|
||||||
case EmptyData
|
"You must pay to access this service. \n\n" +
|
||||||
case FailedRequest(description: String)
|
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||||
case AuthQuery(description: String)
|
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||||
}
|
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||||
|
"This service does not inform if a magnet link is a batch before downloading."
|
||||||
public class RealDebrid: ObservableObject {
|
|
||||||
var parentManager: DebridManager?
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
let cachedStatus: [String] = ["downloaded"]
|
||||||
var authTask: Task<Void, Error>?
|
var authTask: Task<Void, Error>?
|
||||||
|
|
||||||
|
@Published var authProcessing: Bool = false
|
||||||
|
|
||||||
|
// Check the manual token since getTokens() is async
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
|
||||||
|
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
||||||
|
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
||||||
|
private let openSourceClientId = "X245A4XAIBGVM"
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setUserDefaultsValue(_ value: Any, forKey: String) {
|
||||||
|
UserDefaults.standard.set(value, forKey: forKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func removeUserDefaultsValue(forKey: String) {
|
||||||
|
UserDefaults.standard.removeObject(forKey: forKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserDownloads()
|
||||||
|
try? await getUserMagnets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
// Fetches the device code from RD
|
// Fetches the device code from RD
|
||||||
public func getVerificationInfo() async throws -> String {
|
func getAuthUrl() async throws -> URL {
|
||||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
|
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
|
||||||
urlComponents.queryItems = [
|
urlComponents.queryItems = [
|
||||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||||
|
|
@ -39,38 +76,33 @@ public class RealDebrid: ObservableObject {
|
||||||
]
|
]
|
||||||
|
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
throw RealDebridError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// Validate the URL before doing anything else
|
||||||
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
|
||||||
|
guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else {
|
||||||
// Spawn a separate process to get the device code
|
throw DebridError.AuthQuery(description: "The verification URL is invalid")
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
|
|
||||||
} catch {
|
|
||||||
print("Authentication error in \(#function): \(error)")
|
|
||||||
authTask?.cancel()
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
parentManager?.toastModel?.toastDescription = "Authentication error in \(#function): \(error)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rawResponse.directVerificationURL
|
// Spawn the polling task separately
|
||||||
|
authTask = Task {
|
||||||
|
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return directVerificationUrl
|
||||||
} catch {
|
} catch {
|
||||||
print("Couldn't get the new client creds!")
|
print("Couldn't get the new client creds!")
|
||||||
throw RealDebridError.AuthQuery(description: error.localizedDescription)
|
throw DebridError.AuthQuery(description: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches the user's client ID and secret
|
// Fetches the user's client ID and secret
|
||||||
public func getDeviceCredentials(deviceCode: String) async throws {
|
func getDeviceCredentials(deviceCode: String) async throws {
|
||||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
|
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
|
||||||
urlComponents.queryItems = [
|
urlComponents.queryItems = [
|
||||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||||
|
|
@ -78,51 +110,49 @@ public class RealDebrid: ObservableObject {
|
||||||
]
|
]
|
||||||
|
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
throw RealDebridError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
try await getDeviceCredentialsInternal(urlRequest: request, deviceCode: deviceCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer to poll RD api for credentials
|
// Timer to poll RD API for credentials
|
||||||
func getDeviceCredentialsInternal(urlRequest: URLRequest, deviceCode: String) async throws {
|
var count = 0
|
||||||
authTask = Task {
|
|
||||||
var count = 0
|
|
||||||
|
|
||||||
while count < 20 {
|
while count < 12 {
|
||||||
let (data, _) = try await URLSession.shared.data(for: urlRequest)
|
if Task.isCancelled {
|
||||||
|
throw DebridError.AuthQuery(description: "Token request cancelled.")
|
||||||
|
}
|
||||||
|
|
||||||
// We don't care if this fails
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
|
||||||
|
|
||||||
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
// We don't care if this fails
|
||||||
UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId")
|
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
||||||
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
|
||||||
|
|
||||||
try await getTokens(deviceCode: deviceCode)
|
// 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")
|
||||||
|
|
||||||
break
|
try await getApiTokens(deviceCode: deviceCode)
|
||||||
} else {
|
|
||||||
try await Task.sleep(seconds: 5)
|
return
|
||||||
count += 1
|
} else {
|
||||||
}
|
try await Task.sleep(seconds: 5)
|
||||||
|
count += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if case let .failure(error) = await authTask?.result {
|
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all tokens for the user and store in keychain
|
// Fetch all tokens for the user and store in FerriteKeychain.shared
|
||||||
public func getTokens(deviceCode: String) async throws {
|
func getApiTokens(deviceCode: String) async throws {
|
||||||
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
||||||
throw RealDebridError.EmptyData
|
throw DebridError.EmptyData
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
|
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
|
||||||
throw RealDebridError.EmptyData
|
throw DebridError.EmptyData
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
|
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
|
||||||
|
|
@ -143,25 +173,20 @@ public class RealDebrid: ObservableObject {
|
||||||
|
|
||||||
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
|
||||||
|
|
||||||
keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
||||||
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
||||||
|
|
||||||
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
|
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
|
||||||
UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
||||||
|
|
||||||
// Set AppStorage variable
|
|
||||||
Task { @MainActor in
|
|
||||||
parentManager?.realDebridEnabled = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchToken() async -> String? {
|
func getToken() async -> String? {
|
||||||
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
||||||
|
|
||||||
if Date().timeIntervalSince1970 > accessTokenStamp {
|
if Date().timeIntervalSince1970 > accessTokenStamp {
|
||||||
do {
|
do {
|
||||||
if let refreshToken = keychain.get("RealDebrid.RefreshToken") {
|
if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
|
||||||
try await getTokens(deviceCode: refreshToken)
|
try await getApiTokens(deviceCode: refreshToken)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
print(error)
|
||||||
|
|
@ -169,33 +194,43 @@ public class RealDebrid: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return keychain.get("RealDebrid.AccessToken")
|
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deleteTokens() async throws {
|
// Adds a manual API key instead of web auth
|
||||||
keychain.delete("RealDebrid.RefreshToken")
|
// Clear out existing refresh tokens and timestamps
|
||||||
keychain.delete("RealDebrid.ClientSecret")
|
func setApiKey(_ key: String) {
|
||||||
UserDefaults.standard.removeObject(forKey: "RealDebrid.ClientId")
|
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
|
||||||
UserDefaults.standard.removeObject(forKey: "RealDebrid.AccessTokenStamp")
|
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
|
// Run the request, doesn't matter if it fails
|
||||||
if let token = keychain.get("RealDebrid.AccessToken") {
|
if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
_ = try? await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
keychain.delete("RealDebrid.AccessToken")
|
FerriteKeychain.shared.delete("RealDebrid.AccessToken")
|
||||||
}
|
await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey")
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
parentManager?.realDebridEnabled = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Common request
|
||||||
|
|
||||||
// Wrapper request function which matches the responses and returns data
|
// Wrapper request function which matches the responses and returns data
|
||||||
@discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
guard let token = await fetchToken() else {
|
guard let token = await getToken() else {
|
||||||
throw RealDebridError.InvalidToken
|
throw DebridError.InvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
@ -203,84 +238,118 @@ public class RealDebrid: ObservableObject {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let response = response as? HTTPURLResponse else {
|
guard let response = response as? HTTPURLResponse else {
|
||||||
throw RealDebridError.FailedRequest(description: "No HTTP response given")
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
return data
|
return data
|
||||||
} else if response.statusCode == 401 {
|
} else if response.statusCode == 401 {
|
||||||
try await deleteTokens()
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
|
||||||
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
|
|
||||||
} else {
|
} else {
|
||||||
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the magnet is streamable on RD
|
// MARK: - Instant availability
|
||||||
// Currently does not work for batch links
|
|
||||||
public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] {
|
|
||||||
var availableHashes: [RealDebridIA] = []
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!)
|
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
// Post-API changes
|
||||||
|
// Use user magnets to check for IA instead
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
// Does not account for torrent packs at the moment
|
let sendMagnets = magnets.filter { magnet in
|
||||||
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
for (hash, response) in rawResponseDict {
|
IAValues.remove(at: IAIndex)
|
||||||
guard let data = response.data else {
|
return true
|
||||||
continue
|
} else {
|
||||||
}
|
return false
|
||||||
|
|
||||||
if data.rd.isEmpty {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this a batch
|
|
||||||
if data.rd.count > 1 || data.rd[0].count > 1 {
|
|
||||||
// Batch array
|
|
||||||
let batches = data.rd.map { fileDict in
|
|
||||||
let batchFiles: [RealDebridIABatchFile] = fileDict.map { key, value in
|
|
||||||
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
|
|
||||||
RealDebridIABatchFile(id: Int(key)!, fileName: value.filename)
|
|
||||||
}.sorted(by: { $0.id < $1.id })
|
|
||||||
|
|
||||||
return RealDebridIABatch(files: batchFiles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RD files array
|
|
||||||
// Possibly sort this in the future, but not sure how at the moment
|
|
||||||
var files: [RealDebridIAFile] = []
|
|
||||||
|
|
||||||
for index in batches.indices {
|
|
||||||
let batchFiles = batches[index].files
|
|
||||||
|
|
||||||
for batchFileIndex in batchFiles.indices {
|
|
||||||
let batchFile = batchFiles[batchFileIndex]
|
|
||||||
|
|
||||||
if !files.contains(where: { $0.name == batchFile.fileName }) {
|
|
||||||
files.append(
|
|
||||||
RealDebridIAFile(
|
|
||||||
name: batchFile.fileName,
|
|
||||||
batchIndex: index,
|
|
||||||
batchFileIndex: batchFileIndex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
availableHashes.append(RealDebridIA(hash: hash, files: files, batches: batches))
|
|
||||||
} else {
|
} else {
|
||||||
availableHashes.append(RealDebridIA(hash: hash))
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return availableHashes
|
// Fetch the user magnets to the latest version
|
||||||
|
try await getUserMagnets()
|
||||||
|
|
||||||
|
for cloudMagnet in cloudMagnets {
|
||||||
|
if cachedStatus.contains(cloudMagnet.status),
|
||||||
|
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||||
|
{
|
||||||
|
IAValues.append(
|
||||||
|
DebridIA(
|
||||||
|
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downloading
|
||||||
|
|
||||||
|
// Wrapper function to fetch a download link from the API
|
||||||
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
|
var selectedMagnetId = ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Don't queue a new job if the magnet already exists in the user's library
|
||||||
|
if let existingCloudMagnet = cloudMagnets.first(where: {
|
||||||
|
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||||
|
}) {
|
||||||
|
selectedMagnetId = existingCloudMagnet.id
|
||||||
|
} else {
|
||||||
|
selectedMagnetId = try await addMagnet(magnet: magnet)
|
||||||
|
|
||||||
|
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try await torrentInfo(debridID: selectedMagnetId)
|
||||||
|
let filteredFiles = response.files.filter { $0.selected == 1 }
|
||||||
|
|
||||||
|
// Need to return this to the user
|
||||||
|
if filteredFiles.count > 1, iaFile == nil {
|
||||||
|
var copiedIA = ia
|
||||||
|
|
||||||
|
copiedIA?.files = response.files.enumerated().compactMap { index, file in
|
||||||
|
DebridIAFile(
|
||||||
|
id: index,
|
||||||
|
name: file.path,
|
||||||
|
streamUrlString: response.links[safe: index]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (nil, copiedIA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealDebrid has 1 as the first ID for a file
|
||||||
|
let selectedFileId = iaFile?.id ?? 1
|
||||||
|
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
|
||||||
|
|
||||||
|
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
|
||||||
|
return (restrictedFile, nil)
|
||||||
|
} catch {
|
||||||
|
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
|
||||||
|
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-raise the error to the calling function
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a magnet link to the user's RD account
|
// Adds a magnet link to the user's RD account
|
||||||
public func addMagnet(magnetLink: String) async throws -> String {
|
func addMagnet(magnet: Magnet) async throws -> String {
|
||||||
|
guard let magnetLink = magnet.link else {
|
||||||
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
@ -297,7 +366,7 @@ public class RealDebrid: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queues the magnet link for downloading
|
// Queues the magnet link for downloading
|
||||||
public func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
@ -316,29 +385,32 @@ public class RealDebrid: ObservableObject {
|
||||||
try await performRequest(request: &request, requestName: #function)
|
try await performRequest(request: &request, requestName: #function)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches the info of a torrent
|
// Gets the info of a torrent from a given ID
|
||||||
public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String {
|
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
|
||||||
|
|
||||||
// Error out if no index is provided
|
// Let the user know if a magnet is downloading
|
||||||
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1] {
|
switch rawResponse.status {
|
||||||
return torrentLink
|
case "downloaded":
|
||||||
} else {
|
return rawResponse
|
||||||
throw RealDebridError.EmptyData
|
case "downloading", "queued":
|
||||||
|
throw DebridError.IsCaching
|
||||||
|
default:
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Downloads link from selectFiles for playback
|
// Downloads link from selectFiles for playback
|
||||||
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
var bodyComponents = URLComponents()
|
var bodyComponents = URLComponents()
|
||||||
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
|
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)]
|
||||||
|
|
||||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
|
@ -347,4 +419,69 @@ public class RealDebrid: ObservableObject {
|
||||||
|
|
||||||
return rawResponse.download
|
return rawResponse.download
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cloud methods
|
||||||
|
|
||||||
|
// Gets the user's cloud magnet library
|
||||||
|
func getUserMagnets() async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
|
||||||
|
cloudMagnets = rawResponse.map { response in
|
||||||
|
DebridCloudMagnet(
|
||||||
|
id: response.id,
|
||||||
|
fileName: response.filename,
|
||||||
|
status: response.status,
|
||||||
|
hash: response.hash,
|
||||||
|
links: [response.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes a magnet download from RD
|
||||||
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||||
|
let deleteId: String
|
||||||
|
|
||||||
|
if let cloudMagnetId {
|
||||||
|
deleteId = cloudMagnetId
|
||||||
|
} else {
|
||||||
|
// Refresh the user magnet list
|
||||||
|
// The first file is the currently caching one
|
||||||
|
let _ = try await getUserMagnets()
|
||||||
|
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteId = firstCloudMagnet.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
|
||||||
|
request.httpMethod = "DELETE"
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets the user's downloads
|
||||||
|
func getUserDownloads() async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
|
||||||
|
cloudDownloads = rawResponse.map { response in
|
||||||
|
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
270
Ferrite/API/TorBoxWrapper.swift
Normal file
270
Ferrite/API/TorBoxWrapper.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "Ferrite-lg.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
|
||||||
BIN
Ferrite/Assets.xcassets/AppIcon.appiconset/Ferrite-lg.png
Normal file
BIN
Ferrite/Assets.xcassets/AppIcon.appiconset/Ferrite-lg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
21
Ferrite/Assets.xcassets/AppImages/AppImage.imageset/Contents.json
vendored
Normal file
21
Ferrite/Assets.xcassets/AppImages/AppImage.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Ferrite-180.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Ferrite/Assets.xcassets/AppImages/AppImage.imageset/Ferrite-180.png
vendored
Normal file
BIN
Ferrite/Assets.xcassets/AppImages/AppImage.imageset/Ferrite-180.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
21
Ferrite/Assets.xcassets/AppImages/AppImageRounded.imageset/Contents.json
vendored
Normal file
21
Ferrite/Assets.xcassets/AppImages/AppImageRounded.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Ferrite-180-rounded.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Ferrite/Assets.xcassets/AppImages/AppImageRounded.imageset/Ferrite-180-rounded.png
vendored
Normal file
BIN
Ferrite/Assets.xcassets/AppImages/AppImageRounded.imageset/Ferrite-180-rounded.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
6
Ferrite/Assets.xcassets/AppImages/Contents.json
Normal file
6
Ferrite/Assets.xcassets/AppImages/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Ferrite/DataManagement/Classes/Action+CoreDataClass.swift
Normal file
13
Ferrite/DataManagement/Classes/Action+CoreDataClass.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// Action+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 1/12/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(Action)
|
||||||
|
public class Action: NSManagedObject, Plugin {}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
//
|
||||||
|
// Action+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 2/6/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Action {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<Action> {
|
||||||
|
NSFetchRequest<Action>(entityName: "Action")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var id: UUID
|
||||||
|
@NSManaged var listId: UUID?
|
||||||
|
@NSManaged var name: String
|
||||||
|
@NSManaged var deeplink: String?
|
||||||
|
@NSManaged var version: Int16
|
||||||
|
@NSManaged var about: String?
|
||||||
|
@NSManaged var website: String?
|
||||||
|
@NSManaged var requires: [String]
|
||||||
|
@NSManaged var author: String
|
||||||
|
@NSManaged var enabled: Bool
|
||||||
|
@NSManaged var tags: NSOrderedSet?
|
||||||
|
|
||||||
|
func getTags() -> [PluginTagJson] {
|
||||||
|
requires.map { PluginTagJson(name: $0, colorHex: nil) } + tagArray.map { $0.toJson() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Generated accessors for tags
|
||||||
|
|
||||||
|
public extension Action {
|
||||||
|
@objc(insertObject:inTagsAtIndex:)
|
||||||
|
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
|
||||||
|
|
||||||
|
@objc(removeObjectFromTagsAtIndex:)
|
||||||
|
@NSManaged func removeFromTags(at idx: Int)
|
||||||
|
|
||||||
|
@objc(insertTags:atIndexes:)
|
||||||
|
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
|
||||||
|
|
||||||
|
@objc(removeTagsAtIndexes:)
|
||||||
|
@NSManaged func removeFromTags(at indexes: NSIndexSet)
|
||||||
|
|
||||||
|
@objc(replaceObjectInTagsAtIndex:withObject:)
|
||||||
|
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
|
||||||
|
|
||||||
|
@objc(replaceTagsAtIndexes:withTags:)
|
||||||
|
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
|
||||||
|
|
||||||
|
@objc(addTagsObject:)
|
||||||
|
@NSManaged func addToTags(_ value: PluginTag)
|
||||||
|
|
||||||
|
@objc(removeTagsObject:)
|
||||||
|
@NSManaged func removeFromTags(_ value: PluginTag)
|
||||||
|
|
||||||
|
@objc(addTags:)
|
||||||
|
@NSManaged func addToTags(_ values: NSOrderedSet)
|
||||||
|
|
||||||
|
@objc(removeTags:)
|
||||||
|
@NSManaged func removeFromTags(_ values: NSOrderedSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Action: Identifiable {}
|
||||||
13
Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift
Normal file
13
Ferrite/DataManagement/Classes/Bookmark+CoreDataClass.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// Bookmark+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/2/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(Bookmark)
|
||||||
|
class Bookmark: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// Bookmark+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/3/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Bookmark {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
|
||||||
|
NSFetchRequest<Bookmark>(entityName: "Bookmark")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var leechers: String?
|
||||||
|
@NSManaged var magnetHash: String?
|
||||||
|
@NSManaged var magnetLink: String?
|
||||||
|
@NSManaged var seeders: String?
|
||||||
|
@NSManaged var size: String?
|
||||||
|
@NSManaged var source: String
|
||||||
|
@NSManaged var title: String?
|
||||||
|
@NSManaged var orderNum: Int16
|
||||||
|
|
||||||
|
func toSearchResult() -> SearchResult {
|
||||||
|
SearchResult(
|
||||||
|
title: title,
|
||||||
|
source: source,
|
||||||
|
size: size,
|
||||||
|
magnet: Magnet(hash: magnetHash, link: magnetLink),
|
||||||
|
seeders: seeders,
|
||||||
|
leechers: leechers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Bookmark: Identifiable {}
|
||||||
13
Ferrite/DataManagement/Classes/History+CoreDataClass.swift
Normal file
13
Ferrite/DataManagement/Classes/History+CoreDataClass.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// History+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/4/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(History)
|
||||||
|
public class History: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
//
|
||||||
|
// History+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/4/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension History {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<History> {
|
||||||
|
NSFetchRequest<History>(entityName: "History")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var date: Date?
|
||||||
|
@NSManaged var dateString: String?
|
||||||
|
@NSManaged var entries: NSSet?
|
||||||
|
|
||||||
|
internal var entryArray: [HistoryEntry] {
|
||||||
|
let entrySet = entries as? Set<HistoryEntry> ?? []
|
||||||
|
|
||||||
|
return entrySet.sorted {
|
||||||
|
$0.timeStamp > $1.timeStamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Generated accessors for entries
|
||||||
|
|
||||||
|
public extension History {
|
||||||
|
@objc(addEntriesObject:)
|
||||||
|
@NSManaged func addToEntries(_ value: HistoryEntry)
|
||||||
|
|
||||||
|
@objc(removeEntriesObject:)
|
||||||
|
@NSManaged func removeFromEntries(_ value: HistoryEntry)
|
||||||
|
|
||||||
|
@objc(addEntries:)
|
||||||
|
@NSManaged func addToEntries(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removeEntries:)
|
||||||
|
@NSManaged func removeFromEntries(_ values: NSSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension History: Identifiable {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// KodiServer+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/6/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(KodiServer)
|
||||||
|
public class KodiServer: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// KodiServer+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/6/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension KodiServer {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<KodiServer> {
|
||||||
|
NSFetchRequest<KodiServer>(entityName: "KodiServer")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var urlString: String
|
||||||
|
@NSManaged var name: String
|
||||||
|
@NSManaged var username: String?
|
||||||
|
@NSManaged var password: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension KodiServer: Identifiable {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// PluginList+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 1/11/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(PluginList)
|
||||||
|
public class PluginList: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// PluginList+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 1/11/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension PluginList {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<PluginList> {
|
||||||
|
NSFetchRequest<PluginList>(entityName: "PluginList")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var author: String
|
||||||
|
@NSManaged var id: UUID
|
||||||
|
@NSManaged var name: String
|
||||||
|
@NSManaged var urlString: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PluginList: Identifiable {}
|
||||||
13
Ferrite/DataManagement/Classes/PluginTag+CoreDataClass.swift
Normal file
13
Ferrite/DataManagement/Classes/PluginTag+CoreDataClass.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// PluginTag+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 2/7/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(PluginTag)
|
||||||
|
public class PluginTag: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
// PluginTag+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 2/7/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension PluginTag {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<PluginTag> {
|
||||||
|
NSFetchRequest<PluginTag>(entityName: "PluginTag")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var colorHex: String?
|
||||||
|
@NSManaged var name: String
|
||||||
|
@NSManaged var parentAction: Action?
|
||||||
|
@NSManaged var parentSource: Source?
|
||||||
|
|
||||||
|
internal func toJson() -> PluginTagJson {
|
||||||
|
PluginTagJson(name: name, colorHex: colorHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PluginTag: Identifiable {}
|
||||||
13
Ferrite/DataManagement/Classes/Source+CoreDataClass.swift
Normal file
13
Ferrite/DataManagement/Classes/Source+CoreDataClass.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// Source+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 7/30/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(Source)
|
||||||
|
public class Source: NSManagedObject, Plugin {}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
//
|
||||||
|
// Source+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 2/6/23.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Source {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<Source> {
|
||||||
|
NSFetchRequest<Source>(entityName: "Source")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var id: UUID
|
||||||
|
@NSManaged var about: String?
|
||||||
|
@NSManaged var website: String?
|
||||||
|
@NSManaged var dynamicWebsite: Bool
|
||||||
|
@NSManaged var fallbackUrls: [String]?
|
||||||
|
@NSManaged var enabled: Bool
|
||||||
|
@NSManaged var name: String
|
||||||
|
@NSManaged var author: String
|
||||||
|
@NSManaged var listId: UUID?
|
||||||
|
@NSManaged var preferredParser: Int16
|
||||||
|
@NSManaged var version: Int16
|
||||||
|
@NSManaged var htmlParser: SourceHtmlParser?
|
||||||
|
@NSManaged var rssParser: SourceRssParser?
|
||||||
|
@NSManaged var jsonParser: SourceJsonParser?
|
||||||
|
@NSManaged var api: SourceApi?
|
||||||
|
@NSManaged var trackers: [String]?
|
||||||
|
@NSManaged var tags: NSOrderedSet?
|
||||||
|
|
||||||
|
func getTags() -> [PluginTagJson] {
|
||||||
|
tagArray.map { $0.toJson() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Generated accessors for tags
|
||||||
|
|
||||||
|
public extension Source {
|
||||||
|
@objc(insertObject:inTagsAtIndex:)
|
||||||
|
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
|
||||||
|
|
||||||
|
@objc(removeObjectFromTagsAtIndex:)
|
||||||
|
@NSManaged func removeFromTags(at idx: Int)
|
||||||
|
|
||||||
|
@objc(insertTags:atIndexes:)
|
||||||
|
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
|
||||||
|
|
||||||
|
@objc(removeTagsAtIndexes:)
|
||||||
|
@NSManaged func removeFromTags(at indexes: NSIndexSet)
|
||||||
|
|
||||||
|
@objc(replaceObjectInTagsAtIndex:withObject:)
|
||||||
|
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
|
||||||
|
|
||||||
|
@objc(replaceTagsAtIndexes:withTags:)
|
||||||
|
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
|
||||||
|
|
||||||
|
@objc(addTagsObject:)
|
||||||
|
@NSManaged func addToTags(_ value: PluginTag)
|
||||||
|
|
||||||
|
@objc(removeTagsObject:)
|
||||||
|
@NSManaged func removeFromTags(_ value: PluginTag)
|
||||||
|
|
||||||
|
@objc(addTags:)
|
||||||
|
@NSManaged func addToTags(_ values: NSOrderedSet)
|
||||||
|
|
||||||
|
@objc(removeTags:)
|
||||||
|
@NSManaged func removeFromTags(_ values: NSOrderedSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Source: Identifiable {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// SourceComplexQuery+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 7/31/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(SourceComplexQuery)
|
||||||
|
public class SourceComplexQuery: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// SourceComplexQuery+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/22/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension SourceComplexQuery {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceComplexQuery> {
|
||||||
|
NSFetchRequest<SourceComplexQuery>(entityName: "SourceComplexQuery")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var attribute: String
|
||||||
|
@NSManaged var discriminator: String?
|
||||||
|
@NSManaged var query: String
|
||||||
|
@NSManaged var regex: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SourceComplexQuery: Identifiable {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// SourceHtmlParser+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 7/30/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(SourceHtmlParser)
|
||||||
|
public class SourceHtmlParser: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// SourceHtmlParser+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/20/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension SourceHtmlParser {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceHtmlParser> {
|
||||||
|
NSFetchRequest<SourceHtmlParser>(entityName: "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?
|
||||||
|
@NSManaged var seedLeech: SourceSeedLeech?
|
||||||
|
@NSManaged var size: SourceSize?
|
||||||
|
@NSManaged var title: SourceTitle?
|
||||||
|
@NSManaged var subName: SourceSubName?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SourceHtmlParser: Identifiable {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// SourceJsonParser+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/20/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(SourceJsonParser)
|
||||||
|
public class SourceJsonParser: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
//
|
||||||
|
// SourceJsonParser+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/21/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension SourceJsonParser {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceJsonParser> {
|
||||||
|
NSFetchRequest<SourceJsonParser>(entityName: "SourceJsonParser")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var results: String?
|
||||||
|
@NSManaged var subResults: String?
|
||||||
|
@NSManaged var searchUrl: String
|
||||||
|
@NSManaged var request: SourceRequest?
|
||||||
|
@NSManaged var magnetHash: SourceMagnetHash?
|
||||||
|
@NSManaged var magnetLink: SourceMagnetLink?
|
||||||
|
@NSManaged var parentSource: Source?
|
||||||
|
@NSManaged var seedLeech: SourceSeedLeech?
|
||||||
|
@NSManaged var size: SourceSize?
|
||||||
|
@NSManaged var title: SourceTitle?
|
||||||
|
@NSManaged var subName: SourceSubName?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SourceJsonParser: Identifiable {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// SourceRssParser+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/3/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(SourceRssParser)
|
||||||
|
public class SourceRssParser: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
//
|
||||||
|
// SourceRssParser+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/20/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension SourceRssParser {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceRssParser> {
|
||||||
|
NSFetchRequest<SourceRssParser>(entityName: "SourceRssParser")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var items: String
|
||||||
|
@NSManaged var rssUrl: String?
|
||||||
|
@NSManaged var searchUrl: String
|
||||||
|
@NSManaged var request: SourceRequest?
|
||||||
|
@NSManaged var magnetHash: SourceMagnetHash?
|
||||||
|
@NSManaged var magnetLink: SourceMagnetLink?
|
||||||
|
@NSManaged var parentSource: Source?
|
||||||
|
@NSManaged var seedLeech: SourceSeedLeech?
|
||||||
|
@NSManaged var size: SourceSize?
|
||||||
|
@NSManaged var title: SourceTitle?
|
||||||
|
@NSManaged var subName: SourceSubName?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SourceRssParser: Identifiable {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// SourceSeedLeech+CoreDataClass.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/2/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@objc(SourceSeedLeech)
|
||||||
|
public class SourceSeedLeech: NSManagedObject {}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
// SourceSeedLeech+CoreDataProperties.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/2/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension SourceSeedLeech {
|
||||||
|
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceSeedLeech> {
|
||||||
|
NSFetchRequest<SourceSeedLeech>(entityName: "SourceSeedLeech")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged var combined: String?
|
||||||
|
@NSManaged var leecherRegex: String?
|
||||||
|
@NSManaged var leechers: String?
|
||||||
|
@NSManaged var seederRegex: String?
|
||||||
|
@NSManaged var seeders: String?
|
||||||
|
@NSManaged var attribute: String
|
||||||
|
@NSManaged var discriminator: String?
|
||||||
|
@NSManaged var parentParser: SourceHtmlParser?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SourceSeedLeech: Identifiable {}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
//
|
|
||||||
// TorrentSource+CoreDataClass.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/24/22.
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@objc(TorrentSource)
|
|
||||||
public class TorrentSource: NSManagedObject {}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
//
|
|
||||||
// TorrentSource+CoreDataProperties.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/24/22.
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension TorrentSource {
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<TorrentSource> {
|
|
||||||
NSFetchRequest<TorrentSource>(entityName: "TorrentSource")
|
|
||||||
}
|
|
||||||
|
|
||||||
@NSManaged var enabled: Bool
|
|
||||||
@NSManaged var linkQuery: String
|
|
||||||
@NSManaged var name: String?
|
|
||||||
@NSManaged var rowQuery: String
|
|
||||||
@NSManaged var sizeQuery: String?
|
|
||||||
@NSManaged var titleQuery: String?
|
|
||||||
@NSManaged var url: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TorrentSource: Identifiable {}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
//
|
|
||||||
// TorrentSourceUrl+CoreDataClass.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/24/22.
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@objc(TorrentSourceUrl)
|
|
||||||
public class TorrentSourceUrl: NSManagedObject {}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
//
|
|
||||||
// TorrentSourceUrl+CoreDataProperties.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/25/22.
|
|
||||||
//
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public extension TorrentSourceUrl {
|
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<TorrentSourceUrl> {
|
|
||||||
NSFetchRequest<TorrentSourceUrl>(entityName: "TorrentSourceUrl")
|
|
||||||
}
|
|
||||||
|
|
||||||
@NSManaged var urlString: String
|
|
||||||
@NSManaged var repoName: String?
|
|
||||||
@NSManaged var repoAuthor: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TorrentSourceUrl: Identifiable {}
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>_XCCurrentVersionName</key>
|
||||||
|
<string>FerriteDB_v2.xcdatamodel</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -1,17 +1,141 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21256.2" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="21G83" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="TorrentSource" representedClassName="TorrentSource" syncable="YES">
|
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
|
||||||
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="leechers" optional="YES" attributeType="String"/>
|
||||||
<attribute name="linkQuery" attributeType="String"/>
|
<attribute name="magnetHash" optional="YES" attributeType="String"/>
|
||||||
<attribute name="name" optional="YES" attributeType="String"/>
|
<attribute name="magnetLink" optional="YES" attributeType="String"/>
|
||||||
<attribute name="rowQuery" attributeType="String"/>
|
<attribute name="orderNum" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="sizeQuery" optional="YES" attributeType="String"/>
|
<attribute name="seeders" optional="YES" attributeType="String"/>
|
||||||
<attribute name="titleQuery" optional="YES" attributeType="String"/>
|
<attribute name="size" optional="YES" attributeType="String"/>
|
||||||
<attribute name="url" attributeType="String"/>
|
<attribute name="source" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="TorrentSourceUrl" representedClassName="TorrentSourceUrl" syncable="YES">
|
<entity name="History" representedClassName="History" syncable="YES">
|
||||||
<attribute name="repoAuthor" optional="YES" attributeType="String"/>
|
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="repoName" optional="YES" attributeType="String"/>
|
<attribute name="dateString" optional="YES" attributeType="String"/>
|
||||||
<attribute name="urlString" attributeType="String"/>
|
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HistoryEntry" inverseName="parentHistory" inverseEntity="HistoryEntry"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="HistoryEntry" representedClassName="HistoryEntry" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="source" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="subName" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="timeStamp" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Source" representedClassName="Source" syncable="YES">
|
||||||
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="baseUrl" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="dynamicBaseUrl" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
|
||||||
|
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="rssParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApi" representedClassName="SourceApi" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="apiUrl" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="clientId" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientId" inverseName="parentApi" inverseEntity="SourceApiClientId"/>
|
||||||
|
<relationship name="clientSecret" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientSecret" inverseName="parentApi" inverseEntity="SourceApiClientSecret"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="api" inverseEntity="Source"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApiClientId" representedClassName="SourceApiClientId" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientId" inverseEntity="SourceApi"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApiClientSecret" representedClassName="SourceApiClientSecret" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientSecret" inverseEntity="SourceApi"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApiCredential" representedClassName="SourceApiCredential" isAbstract="YES" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="dynamic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="expiryLength" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="query" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="responseType" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="timeStamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="urlString" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="value" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
|
||||||
|
<attribute name="attribute" attributeType="String" defaultValueString="text"/>
|
||||||
|
<attribute name="discriminator" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="query" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="regex" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
|
||||||
|
<attribute name="rows" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
|
||||||
|
<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="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="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentHtmlParser" inverseEntity="SourceTitle"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceJsonParser" representedClassName="SourceJsonParser" syncable="YES">
|
||||||
|
<attribute name="results" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="searchUrl" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="subResults" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
|
||||||
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="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="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentJsonParser" inverseEntity="SourceTitle"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceList" representedClassName="SourceList" syncable="YES">
|
||||||
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="urlString" attributeType="String" defaultValueString=""/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceMagnetHash" representedClassName="SourceMagnetHash" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetHash" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetHash" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetHash" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceMagnetLink" representedClassName="SourceMagnetLink" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="externalLinkQuery" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetLink" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
|
||||||
|
<attribute name="items" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="rssUrl" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
|
||||||
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
|
||||||
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="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="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentRssParser" inverseEntity="SourceTitle"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES">
|
||||||
|
<attribute name="attribute" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="combined" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="discriminator" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="leecherRegex" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="leechers" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="seederRegex" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="seeders" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="seedLeech" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="seedLeech" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="size" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="size" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="title" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="title" inverseEntity="SourceRssParser"/>
|
||||||
</entity>
|
</entity>
|
||||||
</model>
|
</model>
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
|
<entity name="Action" representedClassName="Action" syncable="YES">
|
||||||
|
<attribute name="about" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="deeplink" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="website" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
|
||||||
|
<attribute name="leechers" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="magnetHash" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="magnetLink" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="orderNum" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="seeders" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="size" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="source" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="History" representedClassName="History" syncable="YES">
|
||||||
|
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="dateString" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HistoryEntry" inverseName="parentHistory" inverseEntity="HistoryEntry"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="HistoryEntry" representedClassName="HistoryEntry" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="source" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="subName" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="timeStamp" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="KodiServer" representedClassName="KodiServer" syncable="YES">
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="password" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="urlString" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="username" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PluginList" representedClassName="PluginList" syncable="YES">
|
||||||
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="urlString" attributeType="String" defaultValueString=""/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PluginTag" representedClassName="PluginTag" syncable="YES">
|
||||||
|
<attribute name="colorHex" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<relationship name="parentAction" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Action" inverseName="tags" inverseEntity="Action"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Source" representedClassName="Source" syncable="YES">
|
||||||
|
<attribute name="about" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="dynamicWebsite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="listId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="website" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
|
||||||
|
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="rssParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceRssParser" inverseName="parentSource" inverseEntity="SourceRssParser"/>
|
||||||
|
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentSource" inverseEntity="PluginTag"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApi" representedClassName="SourceApi" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="apiUrl" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="clientId" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientId" inverseName="parentApi" inverseEntity="SourceApiClientId"/>
|
||||||
|
<relationship name="clientSecret" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApiClientSecret" inverseName="parentApi" inverseEntity="SourceApiClientSecret"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="api" inverseEntity="Source"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApiClientId" representedClassName="SourceApiClientId" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientId" inverseEntity="SourceApi"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApiClientSecret" representedClassName="SourceApiClientSecret" parentEntity="SourceApiCredential" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentApi" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceApi" inverseName="clientSecret" inverseEntity="SourceApi"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceApiCredential" representedClassName="SourceApiCredential" isAbstract="YES" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="dynamic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="expiryLength" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="query" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="responseType" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="timeStamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="urlString" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="value" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceComplexQuery" representedClassName="SourceComplexQuery" isAbstract="YES" syncable="YES">
|
||||||
|
<attribute name="attribute" attributeType="String" defaultValueString="text"/>
|
||||||
|
<attribute name="discriminator" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="query" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="regex" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
|
||||||
|
<attribute name="rows" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="searchUrl" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
|
||||||
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentHtmlParser" inverseEntity="SourceRequest"/>
|
||||||
|
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
|
||||||
|
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
|
||||||
|
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
|
||||||
|
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentHtmlParser" inverseEntity="SourceTitle"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceJsonParser" representedClassName="SourceJsonParser" syncable="YES">
|
||||||
|
<attribute name="results" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
|
<attribute name="searchUrl" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="subResults" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
|
||||||
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentJsonParser" inverseEntity="SourceRequest"/>
|
||||||
|
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
|
||||||
|
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
|
||||||
|
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
|
||||||
|
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentJsonParser" inverseEntity="SourceTitle"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceMagnetHash" representedClassName="SourceMagnetHash" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetHash" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetHash" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetHash" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceMagnetLink" representedClassName="SourceMagnetLink" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="externalLinkQuery" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="magnetLink" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceRequest" representedClassName="SourceRequest" syncable="YES">
|
||||||
|
<attribute name="body" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData"/>
|
||||||
|
<attribute name="headers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String: String]"/>
|
||||||
|
<attribute name="method" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="request" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="request" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="request" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
|
||||||
|
<attribute name="items" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="rssUrl" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
|
||||||
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
|
||||||
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
|
||||||
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentRssParser" inverseEntity="SourceRequest"/>
|
||||||
|
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
|
||||||
|
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
|
||||||
|
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>
|
||||||
|
<relationship name="title" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceTitle" inverseName="parentRssParser" inverseEntity="SourceTitle"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceSeedLeech" representedClassName="SourceSeedLeech" syncable="YES">
|
||||||
|
<attribute name="attribute" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="combined" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="discriminator" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="leecherRegex" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="leechers" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="seederRegex" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="seeders" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="seedLeech" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="seedLeech" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="seedLeech" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceSize" representedClassName="SourceSize" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="size" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="size" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="size" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceSubName" representedClassName="SourceSubName" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="subName" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="subName" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="subName" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SourceTitle" representedClassName="SourceTitle" parentEntity="SourceComplexQuery" syncable="YES" codeGenerationType="class">
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="title" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="title" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="title" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
|
</model>
|
||||||
|
|
@ -7,6 +7,18 @@
|
||||||
|
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
|
enum HistoryDeleteRange {
|
||||||
|
case day
|
||||||
|
case week
|
||||||
|
case month
|
||||||
|
case allTime
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HistoryDeleteError: Error {
|
||||||
|
case noDate(String)
|
||||||
|
case unknown(String)
|
||||||
|
}
|
||||||
|
|
||||||
// No iCloud until finalized sources
|
// No iCloud until finalized sources
|
||||||
struct PersistenceController {
|
struct PersistenceController {
|
||||||
static let shared = PersistenceController()
|
static let shared = PersistenceController()
|
||||||
|
|
@ -32,6 +44,12 @@ struct PersistenceController {
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||||
|
|
||||||
|
container.loadPersistentStores { _, error in
|
||||||
|
if let error {
|
||||||
|
fatalError("CoreData init error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||||
try? container.viewContext.setQueryGenerationFrom(.current)
|
try? container.viewContext.setQueryGenerationFrom(.current)
|
||||||
|
|
@ -40,12 +58,6 @@ struct PersistenceController {
|
||||||
backgroundContext.automaticallyMergesChangesFromParent = true
|
backgroundContext.automaticallyMergesChangesFromParent = true
|
||||||
backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||||
try? backgroundContext.setQueryGenerationFrom(.current)
|
try? backgroundContext.setQueryGenerationFrom(.current)
|
||||||
|
|
||||||
container.loadPersistentStores { _, error in
|
|
||||||
if let error = error {
|
|
||||||
fatalError("Error: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(_ context: NSManagedObjectContext? = nil) {
|
func save(_ context: NSManagedObjectContext? = nil) {
|
||||||
|
|
@ -78,4 +90,148 @@ struct PersistenceController {
|
||||||
container.viewContext.delete(object)
|
container.viewContext.delete(object)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createBookmark(_ bookmarkJson: BookmarkJson, performSave: Bool) {
|
||||||
|
let bookmarkRequest = Bookmark.fetchRequest()
|
||||||
|
bookmarkRequest.predicate = NSPredicate(
|
||||||
|
format: "source == %@ AND title == %@ AND magnetLink == %@",
|
||||||
|
bookmarkJson.source,
|
||||||
|
bookmarkJson.title ?? "",
|
||||||
|
bookmarkJson.magnetLink ?? ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if (try? backgroundContext.fetch(bookmarkRequest).first) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let newBookmark = Bookmark(context: backgroundContext)
|
||||||
|
|
||||||
|
newBookmark.title = bookmarkJson.title
|
||||||
|
newBookmark.source = bookmarkJson.source
|
||||||
|
newBookmark.magnetHash = bookmarkJson.magnetHash
|
||||||
|
newBookmark.magnetLink = bookmarkJson.magnetLink
|
||||||
|
newBookmark.seeders = bookmarkJson.seeders
|
||||||
|
newBookmark.leechers = bookmarkJson.leechers
|
||||||
|
|
||||||
|
if performSave {
|
||||||
|
save(backgroundContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createHistory(_ entryJson: HistoryEntryJson, performSave: Bool, isBackup: Bool = false, date: Double? = nil) {
|
||||||
|
let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date()
|
||||||
|
let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate)
|
||||||
|
|
||||||
|
let historyRequest = History.fetchRequest()
|
||||||
|
historyRequest.predicate = NSPredicate(format: "dateString = %@", historyDateString)
|
||||||
|
var existingHistory: History?
|
||||||
|
|
||||||
|
if var histories = try? backgroundContext.fetch(historyRequest) {
|
||||||
|
for (i, history) in histories.enumerated() {
|
||||||
|
let existingEntries = history.entryArray.filter { $0.url == entryJson.url && $0.name == entryJson.name }
|
||||||
|
|
||||||
|
// Maybe add !isBackup here
|
||||||
|
if !existingEntries.isEmpty {
|
||||||
|
if isBackup {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
for entry in existingEntries {
|
||||||
|
PersistenceController.shared.delete(entry, context: backgroundContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if history.entryArray.isEmpty {
|
||||||
|
PersistenceController.shared.delete(history, context: backgroundContext)
|
||||||
|
histories.remove(at: i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existingHistory = histories.first
|
||||||
|
}
|
||||||
|
|
||||||
|
let newHistoryEntry = HistoryEntry(context: backgroundContext)
|
||||||
|
|
||||||
|
newHistoryEntry.source = entryJson.source
|
||||||
|
newHistoryEntry.name = entryJson.name
|
||||||
|
newHistoryEntry.url = entryJson.url
|
||||||
|
newHistoryEntry.subName = entryJson.subName
|
||||||
|
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
newHistoryEntry.parentHistory = existingHistory ?? History(context: backgroundContext)
|
||||||
|
newHistoryEntry.parentHistory?.dateString = historyDateString
|
||||||
|
newHistoryEntry.parentHistory?.date = historyDate
|
||||||
|
|
||||||
|
if performSave {
|
||||||
|
save(backgroundContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {
|
||||||
|
if range == .allTime {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var components = Calendar.current.dateComponents([.day, .month, .year], from: Date())
|
||||||
|
components.hour = 0
|
||||||
|
components.minute = 0
|
||||||
|
components.second = 0
|
||||||
|
|
||||||
|
guard let today = Calendar.current.date(from: components) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var offsetComponents = DateComponents(day: 1)
|
||||||
|
guard let tomorrow = Calendar.current.date(byAdding: offsetComponents, to: today) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch range {
|
||||||
|
case .week:
|
||||||
|
offsetComponents.day = -7
|
||||||
|
case .month:
|
||||||
|
offsetComponents.day = -28
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var offsetDate = Calendar.current.date(byAdding: offsetComponents, to: today) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if TimeZone.current.isDaylightSavingTime(for: offsetDate) {
|
||||||
|
offsetDate = offsetDate.addingTimeInterval(3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = NSPredicate(format: "date >= %@ && date < %@", range == .day ? today as NSDate : offsetDate as NSDate, tomorrow as NSDate)
|
||||||
|
|
||||||
|
return predicate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper to batch delete history objects
|
||||||
|
func batchDeleteHistory(range: HistoryDeleteRange) throws {
|
||||||
|
let predicate = getHistoryPredicate(range: range)
|
||||||
|
|
||||||
|
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "History")
|
||||||
|
|
||||||
|
if let predicate {
|
||||||
|
fetchRequest.predicate = predicate
|
||||||
|
} else if range != .allTime {
|
||||||
|
throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?")
|
||||||
|
}
|
||||||
|
|
||||||
|
try batchDelete("History", predicate: predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use the background context to batch delete
|
||||||
|
// Merge changes into both contexts to update views
|
||||||
|
func batchDelete(_ entity: String, predicate: NSPredicate? = nil) throws {
|
||||||
|
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
|
||||||
|
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||||
|
|
||||||
|
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||||
|
let result = try backgroundContext.execute(batchDeleteRequest) as? NSBatchDeleteResult
|
||||||
|
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
|
||||||
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [container.viewContext, backgroundContext])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
Ferrite/Extensions/Array.swift
Normal file
17
Ferrite/Extensions/Array.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
//
|
||||||
|
// Array.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 12/4/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
// From https://www.hackingwithswift.com/example-code/language/how-to-split-an-array-into-chunks
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0 ..< Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Ferrite/Extensions/Bundle.swift
Normal file
18
Ferrite/Extensions/Bundle.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
//
|
||||||
|
// Bundle.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 12/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Bundle {
|
||||||
|
var commitHash: String? {
|
||||||
|
infoDictionary?["GitCommitHash"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNightly: Bool {
|
||||||
|
infoDictionary?["IsNightly"] as? Bool ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Ferrite/Extensions/Color.swift
Normal file
35
Ferrite/Extensions/Color.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// Color.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/28/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(hex: String) {
|
||||||
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
Scanner(string: hex).scanHexInt64(&int)
|
||||||
|
let a, r, g, b: UInt64
|
||||||
|
switch hex.count {
|
||||||
|
case 3: // RGB (12-bit)
|
||||||
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||||
|
case 6: // RGB (24-bit)
|
||||||
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
case 8: // ARGB (32-bit)
|
||||||
|
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
default:
|
||||||
|
(a, r, g, b) = (1, 1, 1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double(r) / 255,
|
||||||
|
green: Double(g) / 255,
|
||||||
|
blue: Double(b) / 255,
|
||||||
|
opacity: Double(a) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Ferrite/Extensions/DateFormatter.swift
Normal file
18
Ferrite/Extensions/DateFormatter.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
//
|
||||||
|
// DateFormatter.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/4/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// A static DateFormatter is better than initializing new ones
|
||||||
|
extension DateFormatter {
|
||||||
|
static let historyDateFormatter: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateFormat = "ddMMyyyy"
|
||||||
|
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
}
|
||||||
14
Ferrite/Extensions/FileManager.swift
Normal file
14
Ferrite/Extensions/FileManager.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
//
|
||||||
|
// FileManager.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/17/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension FileManager {
|
||||||
|
var appDirectory: URL {
|
||||||
|
urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Ferrite/Extensions/NotificationCenter.swift
Normal file
14
Ferrite/Extensions/NotificationCenter.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
//
|
||||||
|
// NotificationCenter.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/3/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static var didDeleteBookmark: Notification.Name {
|
||||||
|
Notification.Name("Deleted bookmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Ferrite/Extensions/OperatingSystemVersion.swift
Normal file
14
Ferrite/Extensions/OperatingSystemVersion.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
//
|
||||||
|
// OperatingSystemVersion.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/23/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension OperatingSystemVersion {
|
||||||
|
func toString() -> String {
|
||||||
|
"\(majorVersion).\(minorVersion).\(patchVersion)"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Ferrite/Extensions/Set.swift
Normal file
26
Ferrite/Extensions/Set.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
//
|
||||||
|
// Set.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/26/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Set: RawRepresentable where Element: Codable {
|
||||||
|
public init?(rawValue: String) {
|
||||||
|
guard let data = rawValue.data(using: .utf8),
|
||||||
|
let result = try? JSONDecoder().decode(Set<Element>.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
self = result
|
||||||
|
}
|
||||||
|
|
||||||
|
public var rawValue: String {
|
||||||
|
guard let data = try? JSONEncoder().encode(self),
|
||||||
|
let result = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Ferrite/Extensions/String.swift
Normal file
51
Ferrite/Extensions/String.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
//
|
||||||
|
// String.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/31/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
// From https://www.hackingwithswift.com/example-code/strings/how-to-capitalize-the-first-letter-of-a-string
|
||||||
|
func capitalizingFirstLetter() -> String {
|
||||||
|
prefix(1).capitalized + dropFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func capitalizeFirstLetter() {
|
||||||
|
self = capitalizingFirstLetter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://stackoverflow.com/a/59307884
|
||||||
|
private func compare(toVersion targetVersion: String) -> ComparisonResult {
|
||||||
|
let versionDelimiter = "."
|
||||||
|
var result: ComparisonResult = .orderedSame
|
||||||
|
var versionComponents = components(separatedBy: versionDelimiter)
|
||||||
|
var targetComponents = targetVersion.components(separatedBy: versionDelimiter)
|
||||||
|
|
||||||
|
while versionComponents.count < targetComponents.count {
|
||||||
|
versionComponents.append("0")
|
||||||
|
}
|
||||||
|
|
||||||
|
while targetComponents.count < versionComponents.count {
|
||||||
|
targetComponents.append("0")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (version, target) in zip(versionComponents, targetComponents) {
|
||||||
|
result = version.compare(target, options: .numeric)
|
||||||
|
if result != .orderedSame {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: String, rhs: String) -> Bool { lhs.compare(toVersion: rhs) == .orderedSame }
|
||||||
|
static func < (lhs: String, rhs: String) -> Bool { lhs.compare(toVersion: rhs) == .orderedAscending }
|
||||||
|
static func <= (lhs: String, rhs: String) -> Bool { lhs.compare(toVersion: rhs) != .orderedDescending }
|
||||||
|
static func > (lhs: String, rhs: String) -> Bool { lhs.compare(toVersion: rhs) == .orderedDescending }
|
||||||
|
static func >= (lhs: String, rhs: String) -> Bool { lhs.compare(toVersion: rhs) != .orderedAscending }
|
||||||
|
}
|
||||||
15
Ferrite/Extensions/UIApplication.swift
Normal file
15
Ferrite/Extensions/UIApplication.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//
|
||||||
|
// UIApplication.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/27/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIApplication {
|
||||||
|
// From https://stackoverflow.com/questions/69650504/how-to-get-rid-of-message-windows-was-deprecated-in-ios-15-0-use-uiwindowsc
|
||||||
|
var currentUIWindow: UIWindow? {
|
||||||
|
UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Ferrite/Extensions/UIDevice.swift
Normal file
18
Ferrite/Extensions/UIDevice.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
//
|
||||||
|
// UIDevice.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 2/16/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIDevice {
|
||||||
|
var hasNotch: Bool {
|
||||||
|
if #available(iOS 11.0, *) {
|
||||||
|
return UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Ferrite/Extensions/URL.swift
Normal file
29
Ferrite/Extensions/URL.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// URL.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/20/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
// From https://github.com/Aidoku/Aidoku/blob/main/Shared/Extensions/FileManager.swift
|
||||||
|
// Used for FileManager
|
||||||
|
var contentsByDateAdded: [URL] {
|
||||||
|
if let urls = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: self,
|
||||||
|
includingPropertiesForKeys: [.contentModificationDateKey]
|
||||||
|
) {
|
||||||
|
return urls.sorted {
|
||||||
|
((try? $0.resourceValues(forKeys: [.addedToDirectoryDateKey]))?.addedToDirectoryDate ?? Date.distantPast)
|
||||||
|
>
|
||||||
|
((try? $1.resourceValues(forKeys: [.addedToDirectoryDateKey]))?.addedToDirectoryDate ?? Date.distantPast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil)
|
||||||
|
|
||||||
|
return contents ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Ferrite/Extensions/View.swift
Normal file
44
Ferrite/Extensions/View.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// View.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/15/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
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,
|
||||||
|
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
|
||||||
|
{
|
||||||
|
modifier(ConditionalContextMenuModifier(internalContent, id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func conditionalId(_ id: some Hashable) -> some View {
|
||||||
|
modifier(ConditionalIdModifier(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
|
||||||
|
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableInteraction(_ disabled: Bool) -> some View {
|
||||||
|
modifier(DisableInteractionModifier(disabled: disabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
func inlinedList(inset: CGFloat) -> some View {
|
||||||
|
modifier(InlinedListModifier(inset: inset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,24 +12,28 @@ struct FerriteApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
let persistenceController = PersistenceController.shared
|
||||||
|
|
||||||
@StateObject var scrapingModel: ScrapingViewModel = .init()
|
@StateObject var scrapingModel: ScrapingViewModel = .init()
|
||||||
@StateObject var toastModel: ToastViewModel = .init()
|
@StateObject var logManager: LoggingManager = .init()
|
||||||
@StateObject var debridManager: DebridManager = .init()
|
@StateObject var debridManager: DebridManager = .init()
|
||||||
@StateObject var navigationModel: NavigationViewModel = .init()
|
@StateObject var navModel: NavigationViewModel = .init()
|
||||||
@StateObject var sourceManager: SourceManager = .init()
|
@StateObject var pluginManager: PluginManager = .init()
|
||||||
|
@StateObject var backupManager: BackupManager = .init()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
MainView()
|
MainView()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrapingModel.toastModel = toastModel
|
scrapingModel.logManager = logManager
|
||||||
debridManager.toastModel = toastModel
|
debridManager.logManager = logManager
|
||||||
sourceManager.toastModel = toastModel
|
pluginManager.logManager = logManager
|
||||||
|
backupManager.logManager = logManager
|
||||||
|
navModel.logManager = logManager
|
||||||
}
|
}
|
||||||
.environmentObject(debridManager)
|
.environmentObject(debridManager)
|
||||||
.environmentObject(scrapingModel)
|
.environmentObject(scrapingModel)
|
||||||
.environmentObject(toastModel)
|
.environmentObject(logManager)
|
||||||
.environmentObject(navigationModel)
|
.environmentObject(navModel)
|
||||||
.environmentObject(sourceManager)
|
.environmentObject(pluginManager)
|
||||||
|
.environmentObject(backupManager)
|
||||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
Ferrite/Info.plist
Normal file
61
Ferrite/Info.plist
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>Ferrite Backup</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Owner</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>me.kingbri.Ferrite.feb</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>Ferrite</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>ferrite</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<false/>
|
||||||
|
<key>UTExportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.json</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Ferrite Backup</string>
|
||||||
|
<key>UTTypeIconFiles</key>
|
||||||
|
<array/>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>me.kingbri.Ferrite.feb</string>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>feb</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
64
Ferrite/LaunchScreen.storyboard
Normal file
64
Ferrite/LaunchScreen.storyboard
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21219" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21200"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="obG-Y5-kRd">
|
||||||
|
<rect key="frame" x="0.0" y="876" width="414" height="0.0"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
|
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="AppImageRounded" translatesAutoresizingMaskIntoConstraints="NO" id="eI5-UQ-q7U">
|
||||||
|
<rect key="frame" x="132" y="373" width="150" height="150"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" constant="150" id="NSQ-Nx-Fht"/>
|
||||||
|
<constraint firstAttribute="height" constant="150" id="m09-Jt-van"/>
|
||||||
|
</constraints>
|
||||||
|
</imageView>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Ferrite" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TiD-yU-GG4">
|
||||||
|
<rect key="frame" x="149" y="538" width="116.5" height="48"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="40"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="obG-Y5-kRd" secondAttribute="centerX" id="5cz-MP-9tL"/>
|
||||||
|
<constraint firstItem="eI5-UQ-q7U" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="8Ge-dQ-rv3"/>
|
||||||
|
<constraint firstItem="TiD-yU-GG4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="CAz-EE-nwF"/>
|
||||||
|
<constraint firstItem="obG-Y5-kRd" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" constant="20" symbolic="YES" id="SfN-ll-jLj"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="obG-Y5-kRd" secondAttribute="bottom" constant="20" id="Y44-ml-fuU"/>
|
||||||
|
<constraint firstItem="eI5-UQ-q7U" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="fQT-g2-stJ"/>
|
||||||
|
<constraint firstItem="TiD-yU-GG4" firstAttribute="top" secondItem="eI5-UQ-q7U" secondAttribute="bottom" constant="15" id="owz-Jf-eR0"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="AppImageRounded" width="90" height="90"/>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
106
Ferrite/Models/ActionModels.swift
Normal file
106
Ferrite/Models/ActionModels.swift
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
//
|
||||||
|
// ActionModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 1/11/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ActionJson: Codable, Hashable, PluginJson {
|
||||||
|
let name: String
|
||||||
|
let version: Int16
|
||||||
|
let minVersion: String?
|
||||||
|
let about: String?
|
||||||
|
let website: String?
|
||||||
|
let requires: [ActionRequirement]
|
||||||
|
let deeplink: [DeeplinkActionJson]?
|
||||||
|
let author: String?
|
||||||
|
let listId: UUID?
|
||||||
|
let listName: String?
|
||||||
|
let tags: [PluginTagJson]?
|
||||||
|
|
||||||
|
init(name: String,
|
||||||
|
version: Int16,
|
||||||
|
minVersion: String?,
|
||||||
|
about: String?,
|
||||||
|
website: String?,
|
||||||
|
requires: [ActionRequirement],
|
||||||
|
deeplink: [DeeplinkActionJson]?,
|
||||||
|
author: String?,
|
||||||
|
listId: UUID?,
|
||||||
|
listName: String?,
|
||||||
|
tags: [PluginTagJson]?)
|
||||||
|
{
|
||||||
|
self.name = name
|
||||||
|
self.version = version
|
||||||
|
self.minVersion = minVersion
|
||||||
|
self.about = about
|
||||||
|
self.website = website
|
||||||
|
self.requires = requires
|
||||||
|
self.deeplink = deeplink
|
||||||
|
self.author = author
|
||||||
|
self.listId = listId
|
||||||
|
self.listName = listName
|
||||||
|
self.tags = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
version = try container.decode(Int16.self, forKey: .version)
|
||||||
|
minVersion = try container.decodeIfPresent(String.self, forKey: .minVersion)
|
||||||
|
about = try container.decodeIfPresent(String.self, forKey: .about)
|
||||||
|
website = try container.decodeIfPresent(String.self, forKey: .website)
|
||||||
|
requires = try container.decode([ActionRequirement].self, forKey: .requires)
|
||||||
|
author = try container.decodeIfPresent(String.self, forKey: .author)
|
||||||
|
listId = nil
|
||||||
|
listName = nil
|
||||||
|
tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags)
|
||||||
|
|
||||||
|
if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) {
|
||||||
|
deeplink = [DeeplinkActionJson(os: [], scheme: deeplinkString)]
|
||||||
|
} else if let deeplinkAction = try? container.decode([DeeplinkActionJson].self, forKey: .deeplink) {
|
||||||
|
deeplink = deeplinkAction
|
||||||
|
} else {
|
||||||
|
deeplink = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeeplinkActionJson: Codable, Hashable {
|
||||||
|
let os: [String]
|
||||||
|
let scheme: String
|
||||||
|
|
||||||
|
init(os: [String], scheme: String) {
|
||||||
|
self.os = os
|
||||||
|
self.scheme = scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
if let os = try? container.decode(String.self, forKey: .os) {
|
||||||
|
self.os = [os]
|
||||||
|
} else if let os = try? container.decode([String].self, forKey: .os) {
|
||||||
|
self.os = os
|
||||||
|
} else {
|
||||||
|
os = []
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme = try container.decode(String.self, forKey: .scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ActionJson {
|
||||||
|
// Fetches all tags without optional requirement
|
||||||
|
// Avoids the need for extra tag additions in DB
|
||||||
|
func getTags() -> [PluginTagJson] {
|
||||||
|
requires.map { PluginTagJson(name: $0.rawValue, colorHex: nil) } + (tags.map { $0 } ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActionRequirement: String, Codable {
|
||||||
|
case magnet
|
||||||
|
case debrid
|
||||||
|
}
|
||||||
155
Ferrite/Models/AllDebridModels.swift
Normal file
155
Ferrite/Models/AllDebridModels.swift
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
//
|
||||||
|
// AllDebridModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/25/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension AllDebrid {
|
||||||
|
// MARK: - Generic AllDebrid response
|
||||||
|
|
||||||
|
// Uses a generic parametr for whatever underlying response is present
|
||||||
|
struct ADResponse<ADData: Codable>: Codable {
|
||||||
|
let status: String
|
||||||
|
let data: ADData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PinResponse
|
||||||
|
|
||||||
|
struct PinResponse: Codable {
|
||||||
|
let pin, check: String
|
||||||
|
let expiresIn: Int
|
||||||
|
let userURL, baseURL, checkURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case pin, check
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case userURL = "user_url"
|
||||||
|
case baseURL = "base_url"
|
||||||
|
case checkURL = "check_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ApiKeyResponse
|
||||||
|
|
||||||
|
struct ApiKeyResponse: Codable {
|
||||||
|
let apikey: String
|
||||||
|
let activated: Bool
|
||||||
|
let expiresIn: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case apikey, activated
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AddMagnetResponse
|
||||||
|
|
||||||
|
struct AddMagnetResponse: Codable {
|
||||||
|
let magnets: [AddMagnetData]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AddMagnetData
|
||||||
|
|
||||||
|
struct AddMagnetData: Codable {
|
||||||
|
let magnet, hash, name, filenameOriginal: String
|
||||||
|
let size: Int
|
||||||
|
let ready: Bool
|
||||||
|
let id: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case magnet, hash, name
|
||||||
|
case filenameOriginal = "filename_original"
|
||||||
|
case size, ready, id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MagnetStatusResponse
|
||||||
|
|
||||||
|
struct MagnetStatusResponse: Codable {
|
||||||
|
let magnets: [MagnetStatusData]
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
|
||||||
|
magnets = [data]
|
||||||
|
} else if let data = try? container.decode([MagnetStatusData].self, forKey: .magnets) {
|
||||||
|
magnets = data
|
||||||
|
} else {
|
||||||
|
magnets = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MagnetStatusData
|
||||||
|
|
||||||
|
struct MagnetStatusData: Codable {
|
||||||
|
let id: Int
|
||||||
|
let filename: String
|
||||||
|
let size: Int
|
||||||
|
let hash, status: String
|
||||||
|
let statusCode, downloaded, uploaded, seeders: Int
|
||||||
|
let downloadSpeed, processingPerc, uploadSpeed, uploadDate: Int
|
||||||
|
let completionDate: Int
|
||||||
|
let links: [MagnetStatusLink]
|
||||||
|
let type: String
|
||||||
|
let notified: Bool
|
||||||
|
let version: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MagnetStatusLink
|
||||||
|
|
||||||
|
// Abridged for required parameters
|
||||||
|
struct MagnetStatusLink: Codable {
|
||||||
|
let link: String
|
||||||
|
let filename: String
|
||||||
|
let size: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UnlockLinkResponse
|
||||||
|
|
||||||
|
// Abridged for required parameters
|
||||||
|
struct UnlockLinkResponse: Codable {
|
||||||
|
let link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SavedLinksResponse
|
||||||
|
|
||||||
|
struct SavedLinksResponse: Codable {
|
||||||
|
let links: [SavedLink]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SavedLink: Codable, Hashable {
|
||||||
|
let link: String
|
||||||
|
let date: Int
|
||||||
|
let filename: String
|
||||||
|
let size: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - InstantAvailabilityResponse
|
||||||
|
|
||||||
|
struct InstantAvailabilityResponse: Codable {
|
||||||
|
let magnets: [InstantAvailabilityMagnet]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - IAMagnetResponse
|
||||||
|
|
||||||
|
struct InstantAvailabilityMagnet: Codable {
|
||||||
|
let magnet, hash: String
|
||||||
|
let instant: Bool
|
||||||
|
let files: [InstantAvailabilityFile]?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - IAFileResponse
|
||||||
|
|
||||||
|
struct InstantAvailabilityFile: Codable {
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name = "n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Ferrite/Models/BackupModels.swift
Normal file
58
Ferrite/Models/BackupModels.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//
|
||||||
|
// BackupModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/17/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Version is optional until v1 is phased out
|
||||||
|
struct Backup: Codable {
|
||||||
|
let version: Int?
|
||||||
|
var bookmarks: [BookmarkJson]?
|
||||||
|
var history: [HistoryJson]?
|
||||||
|
var sourceNames: [String]?
|
||||||
|
var actionNames: [String]?
|
||||||
|
var pluginListUrls: [String]?
|
||||||
|
|
||||||
|
// MARK: Remove once v1 backups are unsupported
|
||||||
|
|
||||||
|
var sourceLists: [PluginListBackupJson]?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreData translation
|
||||||
|
|
||||||
|
// Don't typealias to search result as this is a reflection of CoreData's struct
|
||||||
|
struct BookmarkJson: Codable {
|
||||||
|
let title: String?
|
||||||
|
let source: String
|
||||||
|
let size: String?
|
||||||
|
let magnetLink: String?
|
||||||
|
let magnetHash: String?
|
||||||
|
let seeders: String?
|
||||||
|
let leechers: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date is an epoch timestamp
|
||||||
|
struct HistoryJson: Codable {
|
||||||
|
let dateString: String?
|
||||||
|
let date: Double
|
||||||
|
let entries: [HistoryEntryJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryEntryJson: Codable {
|
||||||
|
var name: String? = nil
|
||||||
|
var subName: String? = nil
|
||||||
|
var url: String? = nil
|
||||||
|
var timeStamp: Double? = nil
|
||||||
|
let source: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Differs from PluginListJson
|
||||||
|
struct PluginListBackupJson: Codable {
|
||||||
|
let name: String
|
||||||
|
let author: String
|
||||||
|
let id: String
|
||||||
|
let urlString: String
|
||||||
|
}
|
||||||
144
Ferrite/Models/DebridManagerModels.swift
Normal file
144
Ferrite/Models/DebridManagerModels.swift
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
//
|
||||||
|
// DebridManagerModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/27/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Base32
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Universal IA enum (IA = InstantAvailability)
|
||||||
|
|
||||||
|
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||||
|
case full = "Cached"
|
||||||
|
case partial = "Batch"
|
||||||
|
case none = "Uncached"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enum for debrid differentiation. 0 is nil
|
||||||
|
|
||||||
|
enum DebridType: Int, Codable, Hashable, CaseIterable {
|
||||||
|
case realDebrid = 1
|
||||||
|
case allDebrid = 2
|
||||||
|
case premiumize = 3
|
||||||
|
|
||||||
|
func toString(abbreviated: Bool = false) -> String {
|
||||||
|
switch self {
|
||||||
|
case .realDebrid:
|
||||||
|
return abbreviated ? "RD" : "RealDebrid"
|
||||||
|
case .allDebrid:
|
||||||
|
return abbreviated ? "AD" : "AllDebrid"
|
||||||
|
case .premiumize:
|
||||||
|
return abbreviated ? "PM" : "Premiumize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func website() -> String {
|
||||||
|
switch self {
|
||||||
|
case .realDebrid:
|
||||||
|
return "https://real-debrid.com"
|
||||||
|
case .allDebrid:
|
||||||
|
return "https://alldebrid.com"
|
||||||
|
case .premiumize:
|
||||||
|
return "https://premiumize.me"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper struct for magnet links to contain both the link and hash for easy access
|
||||||
|
struct Magnet: Codable, Hashable, Sendable {
|
||||||
|
var hash: String?
|
||||||
|
var link: String?
|
||||||
|
|
||||||
|
init(hash: String?, link: String?, title: String? = nil, trackers: [String]? = nil) {
|
||||||
|
if let hash, link == nil {
|
||||||
|
self.hash = parseHash(hash)
|
||||||
|
self.link = generateLink(hash: hash, title: title, trackers: trackers)
|
||||||
|
} else if let link, hash == nil {
|
||||||
|
let (link, hash) = parseLink(link)
|
||||||
|
|
||||||
|
self.link = link
|
||||||
|
self.hash = hash
|
||||||
|
} else {
|
||||||
|
self.hash = parseHash(hash)
|
||||||
|
self.link = parseLink(link).link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateLink(hash: String, title: String?, trackers: [String]?) -> String {
|
||||||
|
var magnetLinkArray = ["magnet:?xt=urn:btih:", hash]
|
||||||
|
|
||||||
|
if let title, let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
|
||||||
|
magnetLinkArray.append("&dn=\(encodedTitle)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let trackers {
|
||||||
|
for trackerUrl in trackers {
|
||||||
|
if URL(string: trackerUrl) != nil,
|
||||||
|
let encodedUrlString = trackerUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
|
||||||
|
{
|
||||||
|
magnetLinkArray.append("&tr=\(encodedUrlString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return magnetLinkArray.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractHash(link: String) -> String? {
|
||||||
|
if let firstSplit = link.split(separator: ":")[safe: 3],
|
||||||
|
let tempHash = firstSplit.split(separator: "&")[safe: 0]
|
||||||
|
{
|
||||||
|
return String(tempHash)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this a Base32hex hash?
|
||||||
|
func parseHash(_ magnetHash: String?) -> String? {
|
||||||
|
guard let magnetHash else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if magnetHash.count == 32 {
|
||||||
|
let decryptedMagnetHash = base32DecodeToData(String(magnetHash))
|
||||||
|
return decryptedMagnetHash?.hexEncodedString()
|
||||||
|
} else {
|
||||||
|
return String(magnetHash).lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) {
|
||||||
|
let separator = "magnet:?xt=urn:btih:"
|
||||||
|
|
||||||
|
// Remove percent encoding from the link and ensure it's a magnet
|
||||||
|
guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolate the magnet link if it's bundled with another protocol
|
||||||
|
let isolatedLink: String?
|
||||||
|
if decodedLink.starts(with: separator) {
|
||||||
|
isolatedLink = decodedLink
|
||||||
|
} else {
|
||||||
|
let splitLink = decodedLink.components(separatedBy: separator)
|
||||||
|
isolatedLink = splitLink.last.map { separator + $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let isolatedLink else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hash can be extracted, decrypt it if necessary and return the revised link + hash
|
||||||
|
if let originalHash = extractHash(link: isolatedLink),
|
||||||
|
let parsedHash = parseHash(originalHash)
|
||||||
|
{
|
||||||
|
let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash)
|
||||||
|
return (replacedLink, parsedHash)
|
||||||
|
} else {
|
||||||
|
return (decodedLink, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Ferrite/Models/DebridModels.swift
Normal file
55
Ferrite/Models/DebridModels.swift
Normal 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
|
||||||
|
}
|
||||||
20
Ferrite/Models/FilterModels.swift
Normal file
20
Ferrite/Models/FilterModels.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// FilterModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FilterType {
|
||||||
|
case source
|
||||||
|
case IA
|
||||||
|
case sort
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SortFilter: String, Hashable, CaseIterable {
|
||||||
|
case seeders = "Seeders"
|
||||||
|
case leechers = "Leechers"
|
||||||
|
case size = "Size"
|
||||||
|
}
|
||||||
20
Ferrite/Models/GithubModels.swift
Normal file
20
Ferrite/Models/GithubModels.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// GithubModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 8/28/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Github {
|
||||||
|
struct Release: Codable, Hashable, Sendable {
|
||||||
|
let htmlUrl: String
|
||||||
|
let tagName: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case htmlUrl = "html_url"
|
||||||
|
case tagName = "tag_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Ferrite/Models/KodiModels.swift
Normal file
39
Ferrite/Models/KodiModels.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// KodiModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Kodi {
|
||||||
|
enum KodiError: Error {
|
||||||
|
case ServerAddition(description: String)
|
||||||
|
case InvalidBaseUrl
|
||||||
|
case InvalidPlaybackUrl
|
||||||
|
case InvalidPostBody
|
||||||
|
case FailedRequest(description: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RPC payload
|
||||||
|
|
||||||
|
struct RPCPayload: Encodable {
|
||||||
|
let jsonrpc: String = "2.0"
|
||||||
|
let id: String = "1"
|
||||||
|
let method: String
|
||||||
|
let params: Params?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RPC Params
|
||||||
|
|
||||||
|
struct Params: Codable {
|
||||||
|
let item: Item
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RPC Item
|
||||||
|
|
||||||
|
struct Item: Codable {
|
||||||
|
let file: String
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Ferrite/Models/OffCloudModels.swift
Normal file
70
Ferrite/Models/OffCloudModels.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Ferrite/Models/PluginModels.swift
Normal file
39
Ferrite/Models/PluginModels.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// PluginModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 1/11/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PluginListJson: Codable {
|
||||||
|
let name: String
|
||||||
|
let author: String
|
||||||
|
var sources: [SourceJson]?
|
||||||
|
var actions: [ActionJson]?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color: Hex value
|
||||||
|
public struct PluginTagJson: Codable, Hashable, Sendable {
|
||||||
|
let name: String
|
||||||
|
let colorHex: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case colorHex = "color"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PluginManager {
|
||||||
|
enum PluginManagerError: Error {
|
||||||
|
case ListAddition(description: String)
|
||||||
|
case ActionAddition(description: String)
|
||||||
|
case PluginFetch(description: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AvailablePlugins {
|
||||||
|
let availableSources: [SourceJson]
|
||||||
|
let availableActions: [ActionJson]
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Ferrite/Models/PremiumizeModels.swift
Normal file
74
Ferrite/Models/PremiumizeModels.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
//
|
||||||
|
// PremiumizeModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/28/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Premiumize {
|
||||||
|
// MARK: - CacheCheckResponse
|
||||||
|
|
||||||
|
struct CacheCheckResponse: Codable {
|
||||||
|
let status: String
|
||||||
|
let response: [Bool]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DDLResponse
|
||||||
|
|
||||||
|
struct DDLResponse: Codable {
|
||||||
|
let status: String
|
||||||
|
let content: [DDLData]?
|
||||||
|
let filename: String
|
||||||
|
let filesize: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Content
|
||||||
|
|
||||||
|
struct DDLData: Codable {
|
||||||
|
let path: String
|
||||||
|
let size: Int
|
||||||
|
let link: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case path, size, link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AllItemsResponse (listall endpoint)
|
||||||
|
|
||||||
|
struct AllItemsResponse: Codable {
|
||||||
|
let status: String
|
||||||
|
let files: [UserItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: User Items
|
||||||
|
|
||||||
|
// Abridged for required parameters
|
||||||
|
struct UserItem: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let mimeType: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case mimeType = "mime_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ItemDetailsResponse
|
||||||
|
|
||||||
|
// Abridged for required parameters
|
||||||
|
struct ItemDetailsResponse: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let link: String
|
||||||
|
let mimeType: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, link
|
||||||
|
case mimeType = "mime_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,157 +2,176 @@
|
||||||
// RealDebridModels.swift
|
// RealDebridModels.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/5/22.
|
// Created by Brian Dashore on 11/19/22.
|
||||||
//
|
//
|
||||||
// Structures generated from Quicktype
|
// Structures generated from Quicktype
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - device code endpoint
|
extension RealDebrid {
|
||||||
|
// MARK: - device code endpoint
|
||||||
|
|
||||||
public struct DeviceCodeResponse: Codable {
|
struct DeviceCodeResponse: Codable, Sendable {
|
||||||
let deviceCode, userCode: String
|
let deviceCode, userCode: String
|
||||||
let interval, expiresIn: Int
|
let interval, expiresIn: Int
|
||||||
let verificationURL, directVerificationURL: String
|
let verificationURL, directVerificationURL: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case deviceCode = "device_code"
|
case deviceCode = "device_code"
|
||||||
case userCode = "user_code"
|
case userCode = "user_code"
|
||||||
case interval
|
case interval
|
||||||
case expiresIn = "expires_in"
|
case expiresIn = "expires_in"
|
||||||
case verificationURL = "verification_url"
|
case verificationURL = "verification_url"
|
||||||
case directVerificationURL = "direct_verification_url"
|
case directVerificationURL = "direct_verification_url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - device credentials endpoint
|
// MARK: - device credentials endpoint
|
||||||
|
|
||||||
public struct DeviceCredentialsResponse: Codable {
|
struct DeviceCredentialsResponse: Codable, Sendable {
|
||||||
let clientID, clientSecret: String?
|
let clientID, clientSecret: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case clientID = "client_id"
|
case clientID = "client_id"
|
||||||
case clientSecret = "client_secret"
|
case clientSecret = "client_secret"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - token endpoint
|
// MARK: - token endpoint
|
||||||
|
|
||||||
public struct TokenResponse: Codable {
|
struct TokenResponse: Codable, Sendable {
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
let expiresIn: Int
|
let expiresIn: Int
|
||||||
let refreshToken, tokenType: String
|
let refreshToken, tokenType: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case accessToken = "access_token"
|
case accessToken = "access_token"
|
||||||
case expiresIn = "expires_in"
|
case expiresIn = "expires_in"
|
||||||
case refreshToken = "refresh_token"
|
case refreshToken = "refresh_token"
|
||||||
case tokenType = "token_type"
|
case tokenType = "token_type"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - instantAvailability endpoint
|
// MARK: - instantAvailability endpoint
|
||||||
|
|
||||||
// Thanks Skitty!
|
// Thanks Skitty!
|
||||||
public struct InstantAvailabilityResponse: Codable {
|
struct InstantAvailabilityResponse: Codable, Sendable {
|
||||||
var data: InstantAvailabilityData?
|
var data: InstantAvailabilityData?
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
|
|
||||||
if let data = try? container.decode(InstantAvailabilityData.self) {
|
if let data = try? container.decode(InstantAvailabilityData.self) {
|
||||||
self.data = data
|
self.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InstantAvailabilityData: Codable, Sendable {
|
||||||
|
var rd: [[String: InstantAvailabilityInfo]]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InstantAvailabilityInfo: Codable, Sendable {
|
||||||
|
var filename: String
|
||||||
|
var filesize: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instant Availability batch structures (used for client-side conversion)
|
||||||
|
|
||||||
|
struct IABatch: Codable, Hashable, Sendable {
|
||||||
|
let files: [IABatchFile]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IABatchFile: Codable, Hashable, Sendable {
|
||||||
|
let id: Int
|
||||||
|
let fileName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - addMagnet endpoint
|
||||||
|
|
||||||
|
struct AddMagnetResponse: Codable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let uri: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - torrentInfo endpoint
|
||||||
|
|
||||||
|
struct TorrentInfoResponse: Codable, Sendable {
|
||||||
|
let id, filename, originalFilename, hash: String
|
||||||
|
let bytes, originalBytes: Int
|
||||||
|
let host: String
|
||||||
|
let split, progress: Int
|
||||||
|
let status, added: String
|
||||||
|
let files: [TorrentInfoFile]
|
||||||
|
let links: [String]
|
||||||
|
let ended: String?
|
||||||
|
let speed: Int?
|
||||||
|
let seeders: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, filename
|
||||||
|
case originalFilename = "original_filename"
|
||||||
|
case hash, bytes
|
||||||
|
case originalBytes = "original_bytes"
|
||||||
|
case host, split, progress, status, added, files, links, ended, speed, seeders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TorrentInfoFile: Codable, Sendable {
|
||||||
|
let id: Int
|
||||||
|
let path: String
|
||||||
|
let bytes, selected: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserTorrentsResponse: Codable, Hashable, Sendable {
|
||||||
|
let id, filename, hash: String
|
||||||
|
let bytes: Int
|
||||||
|
let host: String
|
||||||
|
let split, progress: Int
|
||||||
|
let status, added: String
|
||||||
|
let links: [String]
|
||||||
|
let speed, seeders: Int?
|
||||||
|
let ended: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - unrestrictLink endpoint
|
||||||
|
|
||||||
|
struct UnrestrictLinkResponse: Codable, Sendable {
|
||||||
|
let id, filename: String
|
||||||
|
let mimeType: String?
|
||||||
|
let filesize: Int
|
||||||
|
let link: String
|
||||||
|
let host: String
|
||||||
|
let hostIcon: String
|
||||||
|
let chunks, crc: Int
|
||||||
|
let download: String
|
||||||
|
let streamable: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, filename, mimeType, filesize, link, host
|
||||||
|
case hostIcon = "host_icon"
|
||||||
|
case chunks, crc, download, streamable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User downloads list
|
||||||
|
|
||||||
|
struct UserDownloadsResponse: Codable, Hashable, Sendable {
|
||||||
|
let id, filename: String
|
||||||
|
let mimeType: String?
|
||||||
|
let filesize: Int
|
||||||
|
let link: String
|
||||||
|
let host: String
|
||||||
|
let hostIcon: String
|
||||||
|
let chunks: Int
|
||||||
|
let download: String
|
||||||
|
let streamable: Int
|
||||||
|
let generated: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, filename, mimeType, filesize, link, host
|
||||||
|
case hostIcon = "host_icon"
|
||||||
|
case chunks, download, streamable, generated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InstantAvailabilityData: Codable {
|
|
||||||
var rd: [[String: InstantAvailabilityInfo]]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct InstantAvailabilityInfo: Codable {
|
|
||||||
var filename: String
|
|
||||||
var filesize: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Instant Availability client side structures
|
|
||||||
|
|
||||||
public struct RealDebridIA: Codable, Hashable {
|
|
||||||
let hash: String
|
|
||||||
var files: [RealDebridIAFile] = []
|
|
||||||
var batches: [RealDebridIABatch] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RealDebridIABatch: Codable, Hashable {
|
|
||||||
let files: [RealDebridIABatchFile]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RealDebridIABatchFile: Codable, Hashable {
|
|
||||||
let id: Int
|
|
||||||
let fileName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RealDebridIAFile: Codable, Hashable {
|
|
||||||
let name: String
|
|
||||||
let batchIndex: Int
|
|
||||||
let batchFileIndex: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RealDebridIAStatus: Codable, Hashable {
|
|
||||||
case full
|
|
||||||
case partial
|
|
||||||
case none
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - addMagnet endpoint
|
|
||||||
|
|
||||||
public struct AddMagnetResponse: Codable {
|
|
||||||
let id: String
|
|
||||||
let uri: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - torrentInfo endpoint
|
|
||||||
|
|
||||||
struct TorrentInfoResponse: Codable {
|
|
||||||
let id, filename, originalFilename, hash: String
|
|
||||||
let bytes, originalBytes: Int
|
|
||||||
let host: String
|
|
||||||
let split, progress: Int
|
|
||||||
let status, added: String
|
|
||||||
let files: [TorrentInfoFile]
|
|
||||||
let links: [String]
|
|
||||||
let ended: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id, filename
|
|
||||||
case originalFilename = "original_filename"
|
|
||||||
case hash, bytes
|
|
||||||
case originalBytes = "original_bytes"
|
|
||||||
case host, split, progress, status, added, files, links, ended
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TorrentInfoFile: Codable {
|
|
||||||
let id: Int
|
|
||||||
let path: String
|
|
||||||
let bytes, selected: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - unrestrictLink endpoint
|
|
||||||
|
|
||||||
struct UnrestrictLinkResponse: Codable {
|
|
||||||
let id, filename, mimeType: String
|
|
||||||
let filesize: Int
|
|
||||||
let link: String
|
|
||||||
let host: String
|
|
||||||
let hostIcon: String
|
|
||||||
let chunks, crc: Int
|
|
||||||
let download: String
|
|
||||||
let streamable: Int
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id, filename, mimeType, filesize, link, host
|
|
||||||
case hostIcon = "host_icon"
|
|
||||||
case chunks, crc, download, streamable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
62
Ferrite/Models/SearchModels.swift
Normal file
62
Ferrite/Models/SearchModels.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
//
|
||||||
|
// SearchModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/2/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// A raw search result structure displayed on the UI
|
||||||
|
struct SearchResult: Codable, Hashable, Sendable {
|
||||||
|
let title: String?
|
||||||
|
let source: String
|
||||||
|
let size: String?
|
||||||
|
let magnet: Magnet
|
||||||
|
let seeders: String?
|
||||||
|
let leechers: String?
|
||||||
|
|
||||||
|
// Converts size to a double
|
||||||
|
func rawSize() -> Double? {
|
||||||
|
guard let size else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let splitSize = size.split(separator: " ")
|
||||||
|
|
||||||
|
guard
|
||||||
|
let bytesString = splitSize.first,
|
||||||
|
let multipliedBytes = Double(bytesString),
|
||||||
|
let units = splitSize.last
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch units.lowercased() {
|
||||||
|
case "gb":
|
||||||
|
return multipliedBytes * 1e9
|
||||||
|
case "gib":
|
||||||
|
return multipliedBytes * pow(1024, 3)
|
||||||
|
case "mb":
|
||||||
|
return multipliedBytes * 1e6
|
||||||
|
case "mib":
|
||||||
|
return multipliedBytes * pow(1024, 2)
|
||||||
|
case "kb":
|
||||||
|
return multipliedBytes * 1e3
|
||||||
|
case "kib":
|
||||||
|
return multipliedBytes * 1024
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScrapingViewModel {
|
||||||
|
// Contains both search results and magnet links for scalability
|
||||||
|
struct SearchRequestResult: Sendable {
|
||||||
|
let results: [SearchResult]
|
||||||
|
let magnets: [Magnet]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrapingError: Error {}
|
||||||
|
}
|
||||||
19
Ferrite/Models/SettingsModels.swift
Normal file
19
Ferrite/Models/SettingsModels.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
//
|
||||||
|
// SettingsModels.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/20/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DefaultAction: Codable, CaseIterable, Hashable {
|
||||||
|
static var allCases: [DefaultAction] {
|
||||||
|
[.none, .share, .kodi, .custom(name: "", listId: "")]
|
||||||
|
}
|
||||||
|
|
||||||
|
case none
|
||||||
|
case share
|
||||||
|
case kodi
|
||||||
|
case custom(name: String, listId: String)
|
||||||
|
}
|
||||||
|
|
@ -7,23 +7,122 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct SourceJson: Codable {
|
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
||||||
let repoName: String?
|
case json
|
||||||
let repoAuthor: String?
|
case text
|
||||||
let sources: [TorrentSourceJson]
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
||||||
case repoName = "name"
|
let name: String
|
||||||
case repoAuthor = "author"
|
let version: Int16
|
||||||
case sources
|
let minVersion: String?
|
||||||
|
let about: String?
|
||||||
|
let website: String?
|
||||||
|
let dynamicWebsite: Bool?
|
||||||
|
let fallbackUrls: [String]?
|
||||||
|
let trackers: [String]?
|
||||||
|
let api: SourceApiJson?
|
||||||
|
let jsonParser: SourceJsonParserJson?
|
||||||
|
let rssParser: SourceRssParserJson?
|
||||||
|
let htmlParser: SourceHtmlParserJson?
|
||||||
|
let author: String?
|
||||||
|
let listId: UUID?
|
||||||
|
let listName: String?
|
||||||
|
let tags: [PluginTagJson]?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SourceJson {
|
||||||
|
// Fetches all tags without optional requirement
|
||||||
|
func getTags() -> [PluginTagJson] {
|
||||||
|
tags ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct TorrentSourceJson: Codable, Hashable {
|
enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
||||||
let name: String?
|
// case none = 0
|
||||||
let url: String
|
case scraping = 1
|
||||||
let rowQuery: String
|
case rss = 2
|
||||||
let linkQuery: String
|
case siteApi = 3
|
||||||
let titleQuery: String?
|
}
|
||||||
let sizeQuery: String?
|
|
||||||
|
struct SourceApiJson: Codable, Hashable, Sendable {
|
||||||
|
let apiUrl: String?
|
||||||
|
let clientId: SourceApiCredentialJson?
|
||||||
|
let clientSecret: SourceApiCredentialJson?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||||
|
let query: String?
|
||||||
|
let value: String?
|
||||||
|
let dynamic: Bool?
|
||||||
|
let url: String?
|
||||||
|
let responseType: ApiCredentialResponseType?
|
||||||
|
let expiryLength: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||||
|
let searchUrl: String
|
||||||
|
let request: SourceRequestJson?
|
||||||
|
let results: String?
|
||||||
|
let subResults: String?
|
||||||
|
let title: SourceComplexQueryJson
|
||||||
|
let magnetHash: SourceComplexQueryJson?
|
||||||
|
let magnetLink: SourceComplexQueryJson?
|
||||||
|
let subName: SourceComplexQueryJson?
|
||||||
|
let size: SourceComplexQueryJson?
|
||||||
|
let sl: SourceSLJson?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||||
|
let rssUrl: String?
|
||||||
|
let searchUrl: String
|
||||||
|
let request: SourceRequestJson?
|
||||||
|
let items: String
|
||||||
|
let title: SourceComplexQueryJson
|
||||||
|
let magnetHash: SourceComplexQueryJson?
|
||||||
|
let magnetLink: SourceComplexQueryJson?
|
||||||
|
let subName: SourceComplexQueryJson?
|
||||||
|
let size: SourceComplexQueryJson?
|
||||||
|
let sl: SourceSLJson?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||||
|
let searchUrl: String?
|
||||||
|
let request: SourceRequestJson?
|
||||||
|
let rows: String
|
||||||
|
let title: SourceComplexQueryJson
|
||||||
|
let magnet: SourceMagnetJson
|
||||||
|
let subName: SourceComplexQueryJson?
|
||||||
|
let size: SourceComplexQueryJson?
|
||||||
|
let sl: SourceSLJson?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceComplexQueryJson: Codable, Hashable, Sendable {
|
||||||
|
let query: String
|
||||||
|
let discriminator: String?
|
||||||
|
let attribute: String?
|
||||||
|
let regex: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceMagnetJson: Codable, Hashable, Sendable {
|
||||||
|
let query: String
|
||||||
|
let attribute: String
|
||||||
|
let regex: String?
|
||||||
|
let externalLinkQuery: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceSLJson: Codable, Hashable, Sendable {
|
||||||
|
let seeders: String?
|
||||||
|
let leechers: String?
|
||||||
|
let combined: String?
|
||||||
|
let attribute: String?
|
||||||
|
let discriminator: String?
|
||||||
|
let seederRegex: String?
|
||||||
|
let leecherRegex: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceRequestJson: Codable, Hashable, Sendable {
|
||||||
|
let method: String?
|
||||||
|
let headers: [String: String]?
|
||||||
|
let body: String?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
Ferrite/Models/TorBoxModels.swift
Normal file
110
Ferrite/Models/TorBoxModels.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Ferrite/Protocols/Debrid.swift
Normal file
83
Ferrite/Protocols/Debrid.swift
Normal 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
|
||||||
|
}
|
||||||
38
Ferrite/Protocols/Plugin.swift
Normal file
38
Ferrite/Protocols/Plugin.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// Plugin.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 1/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol Plugin: ObservableObject, NSManagedObject {
|
||||||
|
var id: UUID { get set }
|
||||||
|
var listId: UUID? { get set }
|
||||||
|
var name: String { get set }
|
||||||
|
var version: Int16 { get set }
|
||||||
|
var author: String { get set }
|
||||||
|
var about: String? { get set }
|
||||||
|
var website: String? { get set }
|
||||||
|
var enabled: Bool { get set }
|
||||||
|
var tags: NSOrderedSet? { get set }
|
||||||
|
func getTags() -> [PluginTagJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Plugin {
|
||||||
|
var tagArray: [PluginTag] {
|
||||||
|
tags?.array as? [PluginTag] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol PluginJson: Hashable {
|
||||||
|
var name: String { get }
|
||||||
|
var version: Int16 { get }
|
||||||
|
var author: String? { get }
|
||||||
|
var listId: UUID? { get }
|
||||||
|
var listName: String? { get }
|
||||||
|
var tags: [PluginTagJson]? { get }
|
||||||
|
func getTags() -> [PluginTagJson]
|
||||||
|
}
|
||||||
47
Ferrite/Utils/Application.swift
Normal file
47
Ferrite/Utils/Application.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// Application.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/16/22.
|
||||||
|
//
|
||||||
|
// A thread-safe UIApplication alternative for specifying app properties
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Application {
|
||||||
|
static let shared = Application()
|
||||||
|
|
||||||
|
// OS name for Plugins to read. Lowercase for ease of use
|
||||||
|
let os = "ios"
|
||||||
|
|
||||||
|
// Minimum OS version that Ferrite runs on
|
||||||
|
var minVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "MinimumOSVersion") as? String ?? "0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grabs the current user's OS version
|
||||||
|
let osVersion: OperatingSystemVersion = ProcessInfo().operatingSystemVersion
|
||||||
|
|
||||||
|
// Application version and build variables
|
||||||
|
var appVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
var appBuild: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug = development, Nightly = actions, Release = stable
|
||||||
|
var buildType: String {
|
||||||
|
#if DEBUG
|
||||||
|
return "Debug"
|
||||||
|
#else
|
||||||
|
if Bundle.main.isNightly {
|
||||||
|
return "Nightly"
|
||||||
|
} else {
|
||||||
|
return "Release"
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Ferrite/Utils/CodableWrapper.swift
Normal file
36
Ferrite/Utils/CodableWrapper.swift
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
//
|
||||||
|
// CodableWrapper.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/20/23.
|
||||||
|
//
|
||||||
|
// From https://forums.swift.org/t/rawrepresentable-conformance-leads-to-crash/51912/4
|
||||||
|
// Prevents recursion when using Codable with RawRepresentable without needing manual conformance
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CodableWrapper<Value: Codable> {
|
||||||
|
var value: Value
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CodableWrapper: RawRepresentable {
|
||||||
|
var rawValue: String {
|
||||||
|
guard
|
||||||
|
let data = try? JSONEncoder().encode(value),
|
||||||
|
let string = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(rawValue: String) {
|
||||||
|
guard
|
||||||
|
let data = rawValue.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(Value.self, from: data)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Ferrite/Utils/FerriteKeychain.swift
Normal file
13
Ferrite/Utils/FerriteKeychain.swift
Normal 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()
|
||||||
|
}
|
||||||
27
Ferrite/Utils/FormDataBody.swift
Normal file
27
Ferrite/Utils/FormDataBody.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
// FormDataBody.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 6/12/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FormDataBody {
|
||||||
|
let boundary: String = UUID().uuidString
|
||||||
|
let body: Data
|
||||||
|
|
||||||
|
init(params: [String: String]) {
|
||||||
|
var body = Data()
|
||||||
|
|
||||||
|
for (key, value) in params {
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append("\(value)\r\n".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
|
||||||
|
self.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
147
Ferrite/Utils/Store.swift
Normal file
147
Ferrite/Utils/Store.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
245
Ferrite/ViewModels/BackupManager.swift
Normal file
245
Ferrite/ViewModels/BackupManager.swift
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
//
|
||||||
|
// BackupManager.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 9/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class BackupManager: ObservableObject {
|
||||||
|
// Constant variable for backup versions
|
||||||
|
private let latestBackupVersion: Int = 2
|
||||||
|
|
||||||
|
var logManager: LoggingManager?
|
||||||
|
|
||||||
|
@Published var showRestoreAlert = false
|
||||||
|
@Published var showRestoreCompletedAlert = false
|
||||||
|
@Published var restoreCompletedMessage: [String] = []
|
||||||
|
|
||||||
|
@Published var backupUrls: [URL] = []
|
||||||
|
@Published var selectedBackupUrl: URL?
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateRestoreCompletedMessage(newString: String) {
|
||||||
|
restoreCompletedMessage.append(newString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func toggleRestoreCompletedAlert() {
|
||||||
|
showRestoreCompletedAlert.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateBackupUrls(newUrl: URL) {
|
||||||
|
backupUrls.append(newUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBackup() async {
|
||||||
|
var backup = Backup(version: latestBackupVersion)
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
let bookmarkRequest = Bookmark.fetchRequest()
|
||||||
|
if let fetchedBookmarks = try? backgroundContext.fetch(bookmarkRequest) {
|
||||||
|
backup.bookmarks = fetchedBookmarks.compactMap {
|
||||||
|
BookmarkJson(
|
||||||
|
title: $0.title,
|
||||||
|
source: $0.source,
|
||||||
|
size: $0.size,
|
||||||
|
magnetLink: $0.magnetLink,
|
||||||
|
magnetHash: $0.magnetHash,
|
||||||
|
seeders: $0.seeders,
|
||||||
|
leechers: $0.leechers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let historyRequest = History.fetchRequest()
|
||||||
|
if let fetchedHistory = try? backgroundContext.fetch(historyRequest) {
|
||||||
|
backup.history = fetchedHistory.compactMap { history in
|
||||||
|
if history.entries == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return HistoryJson(
|
||||||
|
dateString: history.dateString,
|
||||||
|
date: history.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970,
|
||||||
|
entries: history.entryArray.compactMap { entry in
|
||||||
|
if let name = entry.name, let url = entry.url {
|
||||||
|
return HistoryEntryJson(
|
||||||
|
name: name,
|
||||||
|
subName: entry.subName,
|
||||||
|
url: url,
|
||||||
|
timeStamp: entry.timeStamp,
|
||||||
|
source: entry.source
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceRequest = Source.fetchRequest()
|
||||||
|
if let sources = try? backgroundContext.fetch(sourceRequest) {
|
||||||
|
backup.sourceNames = sources.map(\.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionRequest = Action.fetchRequest()
|
||||||
|
if let actions = try? backgroundContext.fetch(actionRequest) {
|
||||||
|
backup.actionNames = actions.map(\.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginListRequest = PluginList.fetchRequest()
|
||||||
|
if let pluginLists = try? backgroundContext.fetch(pluginListRequest) {
|
||||||
|
backup.pluginListUrls = pluginLists.map(\.urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let encodedJson = try JSONEncoder().encode(backup)
|
||||||
|
let backupsPath = FileManager.default.appDirectory.appendingPathComponent("Backups")
|
||||||
|
if !FileManager.default.fileExists(atPath: backupsPath.path) {
|
||||||
|
try FileManager.default.createDirectory(atPath: backupsPath.path, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = Int(Date().timeIntervalSince1970.rounded())
|
||||||
|
let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb")
|
||||||
|
|
||||||
|
try encodedJson.write(to: writeUrl)
|
||||||
|
|
||||||
|
await updateBackupUrls(newUrl: writeUrl)
|
||||||
|
} catch {
|
||||||
|
await logManager?.error("Backup: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup is in local documents directory, so no need to restore it from the shared URL
|
||||||
|
// Pass the pluginManager reference since it's not used throughout the class like logManager
|
||||||
|
func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async {
|
||||||
|
guard let backupUrl = selectedBackupUrl else {
|
||||||
|
await logManager?.error(
|
||||||
|
"Backup restore: Could not find backup in app directory.",
|
||||||
|
description: "Could not find the selected backup in the local directory."
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Delete all relevant entities to prevent issues with restoration if overwrite is selected
|
||||||
|
if doOverwrite {
|
||||||
|
try PersistenceController.shared.batchDelete("Bookmark")
|
||||||
|
try PersistenceController.shared.batchDelete("History")
|
||||||
|
try PersistenceController.shared.batchDelete("HistoryEntry")
|
||||||
|
try PersistenceController.shared.batchDelete("PluginList")
|
||||||
|
try PersistenceController.shared.batchDelete("Source")
|
||||||
|
try PersistenceController.shared.batchDelete("Action")
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = try Data(contentsOf: backupUrl)
|
||||||
|
|
||||||
|
let backup = try JSONDecoder().decode(Backup.self, from: file)
|
||||||
|
|
||||||
|
if let bookmarks = backup.bookmarks {
|
||||||
|
for bookmark in bookmarks {
|
||||||
|
PersistenceController.shared.createBookmark(bookmark, performSave: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let storedHistories = backup.history {
|
||||||
|
for storedHistory in storedHistories {
|
||||||
|
for storedEntry in storedHistory.entries {
|
||||||
|
PersistenceController.shared.createHistory(
|
||||||
|
storedEntry,
|
||||||
|
performSave: false,
|
||||||
|
isBackup: true,
|
||||||
|
date: storedHistory.date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = backup.version ?? -1
|
||||||
|
|
||||||
|
if let storedLists = backup.sourceLists, version < 2 {
|
||||||
|
// Only present in v1 or no version backups
|
||||||
|
for list in storedLists {
|
||||||
|
try await pluginManager.addPluginList(list.urlString, existingPluginList: nil)
|
||||||
|
}
|
||||||
|
} else if let pluginListUrls = backup.pluginListUrls {
|
||||||
|
// v2 and up
|
||||||
|
for listUrl in pluginListUrls {
|
||||||
|
try await pluginManager.addPluginList(listUrl, existingPluginList: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sourceNames = backup.sourceNames {
|
||||||
|
await updateRestoreCompletedMessage(newString: sourceNames.isEmpty ? "No sources need to be reinstalled" : "Reinstall sources: \(sourceNames.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actionNames = backup.actionNames {
|
||||||
|
await updateRestoreCompletedMessage(newString: actionNames.isEmpty ? "No actions need to be reinstalled" : "Reinstall actions: \(actionNames.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistenceController.shared.save(backgroundContext)
|
||||||
|
|
||||||
|
await toggleRestoreCompletedAlert()
|
||||||
|
} catch {
|
||||||
|
await logManager?.error(
|
||||||
|
"Backup restore: \(error)",
|
||||||
|
description: "A backup restore error was logged"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the backup from files and then the list
|
||||||
|
// Removes an index if it's provided
|
||||||
|
func removeBackup(backupUrl: URL, index: Int?) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: backupUrl)
|
||||||
|
|
||||||
|
if let index {
|
||||||
|
backupUrls.remove(at: index)
|
||||||
|
} else {
|
||||||
|
backupUrls.removeAll(where: { $0 == backupUrl })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Task {
|
||||||
|
await logManager?.error("Backup removal: \(error)")
|
||||||
|
print("Backup removal: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBackup(backupUrl: URL) {
|
||||||
|
let backupSecured = backupUrl.startAccessingSecurityScopedResource()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
if backupSecured {
|
||||||
|
backupUrl.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupsPath = FileManager.default.appDirectory.appendingPathComponent("Backups")
|
||||||
|
let localBackupPath = backupsPath.appendingPathComponent(backupUrl.lastPathComponent)
|
||||||
|
|
||||||
|
do {
|
||||||
|
if FileManager.default.fileExists(atPath: localBackupPath.path) {
|
||||||
|
try FileManager.default.removeItem(at: localBackupPath)
|
||||||
|
} else if !FileManager.default.fileExists(atPath: backupsPath.path) {
|
||||||
|
try FileManager.default.createDirectory(atPath: backupsPath.path, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
try FileManager.default.copyItem(at: backupUrl, to: localBackupPath)
|
||||||
|
|
||||||
|
selectedBackupUrl = localBackupPath
|
||||||
|
} catch {
|
||||||
|
Task {
|
||||||
|
await logManager?.error("Backup copy: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,127 +8,486 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public class DebridManager: ObservableObject {
|
@MainActor
|
||||||
|
class DebridManager: ObservableObject {
|
||||||
|
// Linked classes
|
||||||
|
var logManager: LoggingManager?
|
||||||
|
@Published var realDebrid: RealDebrid = .init()
|
||||||
|
@Published var allDebrid: AllDebrid = .init()
|
||||||
|
@Published var premiumize: Premiumize = .init()
|
||||||
|
@Published var torbox: TorBox = .init()
|
||||||
|
@Published var offcloud: OffCloud = .init()
|
||||||
|
|
||||||
|
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
|
||||||
|
|
||||||
// UI Variables
|
// UI Variables
|
||||||
var toastModel: ToastViewModel?
|
|
||||||
@Published var showWebView: Bool = false
|
@Published var showWebView: Bool = false
|
||||||
|
@Published var showAuthSession: Bool = false
|
||||||
|
@Published var enabledDebrids: [DebridSource] = []
|
||||||
|
|
||||||
// RealDebrid variables
|
@Published var selectedDebridSource: DebridSource? {
|
||||||
let realDebrid: RealDebrid = .init()
|
didSet {
|
||||||
|
UserDefaults.standard.set(selectedDebridSource?.id ?? "", forKey: "Debrid.PreferredService")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
|
var selectedDebridItem: DebridIA?
|
||||||
|
var selectedDebridFile: DebridIAFile?
|
||||||
|
var requiresUnrestrict: Bool = false
|
||||||
|
|
||||||
@Published var realDebridHashes: [RealDebridIA] = []
|
// TODO: Figure out a way to remove this var
|
||||||
@Published var realDebridAuthUrl: String = ""
|
private var selectedOAuthDebridSource: OAuthDebridSource?
|
||||||
@Published var realDebridDownloadUrl: String = ""
|
|
||||||
@Published var selectedRealDebridItem: RealDebridIA?
|
@Published var filteredIAStatus: Set<IAStatus> = []
|
||||||
@Published var selectedRealDebridFile: RealDebridIAFile?
|
|
||||||
|
var currentDebridTask: Task<Void, Never>?
|
||||||
|
var downloadUrl: String = ""
|
||||||
|
var authUrl: URL?
|
||||||
|
|
||||||
|
@Published var showDeleteAlert: Bool = false
|
||||||
|
@Published var showWebLoginAlert: Bool = false
|
||||||
|
@Published var showNotImplementedAlert: Bool = false
|
||||||
|
@Published var notImplementedMessage: String = ""
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
realDebrid.parentManager = self
|
// Update the UI for debrid services that are enabled
|
||||||
}
|
enabledDebrids = debridSources.filter(\.isLoggedIn)
|
||||||
|
|
||||||
public func populateDebridHashes(_ searchResults: [SearchResult]) async {
|
// Set the preferred service. Contains migration logic for earlier versions
|
||||||
var hashes: [String] = []
|
if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") {
|
||||||
|
let debridServiceId: String?
|
||||||
|
|
||||||
for result in searchResults {
|
if let preferredServiceInt = Int(rawPreferredService) {
|
||||||
if let hash = result.magnetHash {
|
debridServiceId = migratePreferredService(preferredServiceInt)
|
||||||
hashes.append(hash)
|
} else {
|
||||||
}
|
debridServiceId = rawPreferredService
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let debridHashes = try await realDebrid.instantAvailability(magnetHashes: hashes)
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
realDebridHashes = debridHashes
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Task { @MainActor in
|
|
||||||
toastModel?.toastDescription = "RealDebrid hash error: \(error)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
print(error)
|
// Only set the debrid source if it's logged in
|
||||||
|
// Otherwise remove the key
|
||||||
|
let tempDebridSource = debridSources.first { $0.id == debridServiceId }
|
||||||
|
if tempDebridSource?.isLoggedIn ?? false {
|
||||||
|
selectedDebridSource = tempDebridSource
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: "Debrid.PreferredService")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus {
|
// TODO: Remove after v0.8.0
|
||||||
guard let result = result else {
|
// Function to migrate the preferred service to the new string ID format
|
||||||
|
private func migratePreferredService(_ idInt: Int) -> String? {
|
||||||
|
// Undo the EnabledDebrids key
|
||||||
|
UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray")
|
||||||
|
|
||||||
|
return DebridType(rawValue: idInt)?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper function to match error descriptions
|
||||||
|
// Error can be suppressed to end user but must be printed in logs
|
||||||
|
private func sendDebridError(
|
||||||
|
_ error: Error,
|
||||||
|
prefix: String,
|
||||||
|
presentError: Bool = true,
|
||||||
|
cancelString: String? = nil
|
||||||
|
) async {
|
||||||
|
let error = error as NSError
|
||||||
|
if presentError {
|
||||||
|
switch error.code {
|
||||||
|
case -1009:
|
||||||
|
logManager?.info(
|
||||||
|
"DebridManager: The connection is offline",
|
||||||
|
description: "The connection is offline"
|
||||||
|
)
|
||||||
|
case -999:
|
||||||
|
if let cancelString {
|
||||||
|
logManager?.info(cancelString, description: cancelString)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logManager?.error("\(prefix): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleans all cached IA values in the event of a full IA refresh
|
||||||
|
func clearIAValues() {
|
||||||
|
for debridSource in debridSources {
|
||||||
|
debridSource.IAValues = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears all selected files and items
|
||||||
|
func clearSelectedDebridItems() {
|
||||||
|
selectedDebridItem = nil
|
||||||
|
selectedDebridFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common function to populate hashes for debrid services
|
||||||
|
func populateDebridIA(_ resultMagnets: [Magnet]) async {
|
||||||
|
for debridSource in debridSources {
|
||||||
|
if !debridSource.isLoggedIn {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't exit the function if the API fetch errors
|
||||||
|
do {
|
||||||
|
try await debridSource.instantAvailability(magnets: resultMagnets)
|
||||||
|
} catch {
|
||||||
|
await sendDebridError(error, prefix: "\(debridSource.id) IA fetch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common function to match a magnet hash with a provided debrid service
|
||||||
|
func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
|
||||||
|
guard let magnetHash = magnet.hash else {
|
||||||
return .none
|
return .none
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let debridMatch = realDebridHashes.first(where: { result.magnetHash == $0.hash }) else {
|
if let selectedDebridSource,
|
||||||
return .none
|
let match = selectedDebridSource.IAValues.first(where: { magnetHash == $0.magnet.hash })
|
||||||
}
|
{
|
||||||
|
return match.files.count > 1 ? .partial : .full
|
||||||
if debridMatch.batches.isEmpty {
|
|
||||||
return .full
|
|
||||||
} else {
|
} else {
|
||||||
return .partial
|
return .none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
func selectDebridResult(magnet: Magnet) -> Bool {
|
||||||
public func setSelectedRdResult(result: SearchResult) -> Bool {
|
guard let magnetHash = magnet.hash else {
|
||||||
guard let magnetHash = result.magnetHash else {
|
logManager?.error("DebridManager: Could not find the magnet hash")
|
||||||
toastModel?.toastDescription = "Could not find the torrent magnet hash"
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if let realDebridItem = realDebridHashes.first(where: { magnetHash == $0.hash }) {
|
guard let selectedSource = selectedDebridSource else {
|
||||||
selectedRealDebridItem = realDebridItem
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) {
|
||||||
|
selectedDebridItem = IAItem
|
||||||
|
|
||||||
|
if IAItem.files.count == 1 {
|
||||||
|
selectedDebridFile = IAItem.files[safe: 0]
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
toastModel?.toastDescription = "Could not find the associated RealDebrid entry for magnet hash \(magnetHash)"
|
logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func authenticateRd() async {
|
// MARK: - Authentication UI linked functions
|
||||||
do {
|
|
||||||
let url = try await realDebrid.getVerificationInfo()
|
|
||||||
|
|
||||||
Task { @MainActor in
|
// Common function to delegate what debrid service to authenticate with
|
||||||
realDebridAuthUrl = url
|
func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
|
||||||
showWebView.toggle()
|
defer {
|
||||||
}
|
// Don't cancel processing if using OAuth
|
||||||
} catch {
|
if !(debridSource is OAuthDebridSource) {
|
||||||
Task { @MainActor in
|
debridSource.authProcessing = false
|
||||||
toastModel?.toastDescription = "RealDebrid Authentication error: \(error)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
print(error)
|
if enabledDebrids.count == 1 {
|
||||||
|
selectedDebridSource = debridSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set an API key if manually provided
|
||||||
|
if let apiKey {
|
||||||
|
debridSource.setApiKey(apiKey)
|
||||||
|
enabledDebrids.append(debridSource)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processing has started
|
||||||
|
debridSource.authProcessing = true
|
||||||
|
|
||||||
|
if let pollingSource = debridSource as? PollingDebridSource {
|
||||||
|
do {
|
||||||
|
let authUrl = try await pollingSource.getAuthUrl()
|
||||||
|
|
||||||
|
if validateAuthUrl(authUrl) {
|
||||||
|
try await pollingSource.authTask?.value
|
||||||
|
enabledDebrids.append(debridSource)
|
||||||
|
} else {
|
||||||
|
throw DebridError.AuthQuery(description: "The authentication URL was invalid")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
|
||||||
|
|
||||||
|
pollingSource.authTask?.cancel()
|
||||||
|
}
|
||||||
|
} else if let oauthSource = debridSource as? OAuthDebridSource {
|
||||||
|
do {
|
||||||
|
let tempAuthUrl = try oauthSource.getAuthUrl()
|
||||||
|
selectedOAuthDebridSource = oauthSource
|
||||||
|
|
||||||
|
validateAuthUrl(tempAuthUrl, useAuthSession: true)
|
||||||
|
} catch {
|
||||||
|
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Let the user know that a traditional auth method doesn't exist
|
||||||
|
showWebLoginAlert.toggle()
|
||||||
|
|
||||||
|
logManager?.error(
|
||||||
|
"DebridManager: Auth: \(debridSource.id) does not have a login portal.",
|
||||||
|
showToast: false
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async {
|
// Get a truncated manual API key if it's being used
|
||||||
|
func getManualAuthKey(_ debridSource: some DebridSource) async -> String? {
|
||||||
|
if let debridToken = debridSource.manualToken {
|
||||||
|
let splitString = debridToken.suffix(4)
|
||||||
|
|
||||||
|
if debridToken.count > 4 {
|
||||||
|
return String(repeating: "*", count: debridToken.count - 4) + splitString
|
||||||
|
} else {
|
||||||
|
return String(splitString)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper function to validate and present an auth URL to the user
|
||||||
|
@discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
|
||||||
|
guard let url else {
|
||||||
|
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
authUrl = url
|
||||||
|
if useAuthSession {
|
||||||
|
showAuthSession.toggle()
|
||||||
|
} else {
|
||||||
|
showWebView.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently handles Premiumize callback
|
||||||
|
func handleAuthCallback(url: URL?, error: Error?) async {
|
||||||
|
defer {
|
||||||
|
if enabledDebrids.count == 1 {
|
||||||
|
selectedDebridSource = selectedOAuthDebridSource
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedOAuthDebridSource?.authProcessing = false
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let realDebridId = try await realDebrid.addMagnet(magnetLink: searchResult.magnetLink)
|
guard let oauthDebridSource = selectedOAuthDebridSource else {
|
||||||
|
throw DebridError.AuthQuery(description: "OAuth source couldn't be found for callback. Aborting.")
|
||||||
|
}
|
||||||
|
|
||||||
var fileIds: [Int] = []
|
if let error {
|
||||||
|
throw DebridError.AuthQuery(description: "OAuth callback Error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let callbackUrl = url {
|
||||||
|
try oauthDebridSource.handleAuthCallback(url: callbackUrl)
|
||||||
|
enabledDebrids.append(oauthDebridSource)
|
||||||
|
} else {
|
||||||
|
throw DebridError.AuthQuery(description: "The callback URL was invalid")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await sendDebridError(error, prefix: "Premiumize authentication error (callback)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logout UI functions
|
||||||
|
|
||||||
|
func logout(_ debridSource: some DebridSource) async {
|
||||||
|
await debridSource.logout()
|
||||||
|
|
||||||
|
if selectedDebridSource?.id == debridSource.id {
|
||||||
|
selectedDebridSource = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledDebrids.removeAll { $0.id == debridSource.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debrid fetch UI linked functions
|
||||||
|
|
||||||
|
// Common function to delegate what debrid service to fetch from
|
||||||
|
// Cloudinfo is used for any extra information provided by debrid cloud
|
||||||
|
func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
|
||||||
|
defer {
|
||||||
|
logManager?.hideIndeterminateToast()
|
||||||
|
|
||||||
|
if !requiresUnrestrict {
|
||||||
|
clearSelectedDebridItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDebridTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||||
|
self.currentDebridTask?.cancel()
|
||||||
|
self.currentDebridTask = nil
|
||||||
|
})
|
||||||
|
|
||||||
|
guard let debridSource = selectedDebridSource else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Cleanup beforehand
|
||||||
|
requiresUnrestrict = false
|
||||||
|
|
||||||
|
if let cloudInfo {
|
||||||
|
downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let magnet {
|
||||||
|
let (restrictedFile, newIA) = try await debridSource.getRestrictedFile(
|
||||||
|
magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// Indicate that a link needs to be selected (batch)
|
||||||
|
if let newIA {
|
||||||
|
if newIA.files.isEmpty {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDebridItem = newIA
|
||||||
|
requiresUnrestrict = true
|
||||||
|
|
||||||
if let iaFile = iaFile {
|
|
||||||
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fileIds = iaBatchFromFile.files.map(\.id)
|
guard let restrictedFile else {
|
||||||
|
throw DebridError.FailedRequest(description: "No files found for your request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the UI
|
||||||
|
downloadUrl = try await debridSource.unrestrictFile(restrictedFile)
|
||||||
|
} else {
|
||||||
|
throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API")
|
||||||
}
|
}
|
||||||
|
|
||||||
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
|
// Fetch one more time to add updated data into the RD cloud cache
|
||||||
|
await fetchDebridCloud(bypassTTL: true)
|
||||||
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile == nil ? 0 : iaFile?.batchFileIndex)
|
|
||||||
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
realDebridDownloadUrl = downloadLink
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
Task { @MainActor in
|
switch error {
|
||||||
toastModel?.toastDescription = "RealDebrid download error: \(error)"
|
case DebridError.IsCaching:
|
||||||
|
showDeleteAlert.toggle()
|
||||||
|
default:
|
||||||
|
await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
print(error)
|
func unrestrictDownload() async {
|
||||||
|
defer {
|
||||||
|
logManager?.hideIndeterminateToast()
|
||||||
|
requiresUnrestrict = false
|
||||||
|
clearSelectedDebridItems()
|
||||||
|
currentDebridTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||||
|
self.currentDebridTask?.cancel()
|
||||||
|
self.currentDebridTask = nil
|
||||||
|
})
|
||||||
|
|
||||||
|
guard let debridFile = selectedDebridFile, let debridSource = selectedDebridSource else {
|
||||||
|
logManager?.error("DebridManager: Could not unrestrict the selected debrid file.")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let downloadLink = try await debridSource.unrestrictFile(debridFile)
|
||||||
|
|
||||||
|
downloadUrl = downloadLink
|
||||||
|
} catch {
|
||||||
|
await sendDebridError(error, prefix: "\(debridSource.id) unrestrict error", cancelString: "Unrestrict cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper to handle cloud fetching
|
||||||
|
func fetchDebridCloud(bypassTTL: Bool = false) async {
|
||||||
|
guard let selectedSource = selectedDebridSource else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL {
|
||||||
|
do {
|
||||||
|
// Populates the inner downloads and magnet arrays
|
||||||
|
try await selectedSource.getUserDownloads()
|
||||||
|
try await selectedSource.getUserMagnets()
|
||||||
|
|
||||||
|
// Update the TTL to 5 minutes from now
|
||||||
|
selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300
|
||||||
|
} catch {
|
||||||
|
let error = error as NSError
|
||||||
|
if error.code != -999 {
|
||||||
|
await sendDebridError(error, prefix: "\(selectedSource.id) cloud fetch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCloudDownload(_ download: DebridCloudDownload) async {
|
||||||
|
guard let selectedSource = selectedDebridSource else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await selectedSource.deleteUserDownload(downloadId: download.id)
|
||||||
|
|
||||||
|
await fetchDebridCloud(bypassTTL: true)
|
||||||
|
} catch {
|
||||||
|
switch error {
|
||||||
|
case DebridError.NotImplemented:
|
||||||
|
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
|
||||||
|
|
||||||
|
notImplementedMessage = message
|
||||||
|
showNotImplementedAlert.toggle()
|
||||||
|
logManager?.error(
|
||||||
|
"DebridManager: \(message)",
|
||||||
|
showToast: false
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async {
|
||||||
|
guard let selectedSource = selectedDebridSource else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id)
|
||||||
|
|
||||||
|
await fetchDebridCloud(bypassTTL: true)
|
||||||
|
} catch {
|
||||||
|
switch error {
|
||||||
|
case DebridError.NotImplemented:
|
||||||
|
let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website."
|
||||||
|
|
||||||
|
notImplementedMessage = message
|
||||||
|
showNotImplementedAlert.toggle()
|
||||||
|
logManager?.error(
|
||||||
|
"DebridManager: \(message)",
|
||||||
|
showToast: false
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
Ferrite/ViewModels/LoggingManager.swift
Normal file
181
Ferrite/ViewModels/LoggingManager.swift
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
//
|
||||||
|
// LoggingManager.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 7/19/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class LoggingManager: ObservableObject {
|
||||||
|
let logFormatter = DateFormatter()
|
||||||
|
|
||||||
|
struct Log: Hashable {
|
||||||
|
let level: LogLevel
|
||||||
|
let message: String
|
||||||
|
let timeStamp: Date = .init()
|
||||||
|
var isExpanded: Bool = false
|
||||||
|
|
||||||
|
func toMessage() -> String {
|
||||||
|
"[\(level.rawValue)]: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogLevel: String, Identifiable {
|
||||||
|
var id: Int {
|
||||||
|
hashValue
|
||||||
|
}
|
||||||
|
|
||||||
|
case info = "INFO"
|
||||||
|
case warn = "WARN"
|
||||||
|
case error = "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var messageArray: [Log] = []
|
||||||
|
@Published var showLogExportedAlert = false
|
||||||
|
|
||||||
|
// Toast variables
|
||||||
|
@Published var toastDescription: String? = nil {
|
||||||
|
didSet {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(seconds: 0.1)
|
||||||
|
showToast = true
|
||||||
|
|
||||||
|
try? await Task.sleep(seconds: 3)
|
||||||
|
|
||||||
|
showToast = false
|
||||||
|
toastType = .error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var showToast: Bool = false
|
||||||
|
// Default the toast type to error since the majority of toasts are errors
|
||||||
|
@Published var toastType: LogLevel = .error
|
||||||
|
var showErrorToasts: Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: "Debug.ShowErrorToasts")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var indeterminateToastDescription: String? = nil
|
||||||
|
@Published var indeterminateCancelAction: (() -> Void)? = nil
|
||||||
|
@Published var showIndeterminateToast: Bool = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
logFormatter.dateStyle = .short
|
||||||
|
logFormatter.timeStyle = .long
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logging functions
|
||||||
|
|
||||||
|
// TODO: Maybe append to a constant logfile?
|
||||||
|
|
||||||
|
func info(_ message: String,
|
||||||
|
description: String? = nil)
|
||||||
|
{
|
||||||
|
let log = Log(
|
||||||
|
level: .info,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
|
||||||
|
if let description {
|
||||||
|
toastType = .info
|
||||||
|
toastDescription = description
|
||||||
|
}
|
||||||
|
|
||||||
|
messageArray.append(log)
|
||||||
|
|
||||||
|
print("LOG: \(log.toMessage())")
|
||||||
|
}
|
||||||
|
|
||||||
|
func warn(_ message: String,
|
||||||
|
description: String? = nil)
|
||||||
|
{
|
||||||
|
let log = Log(
|
||||||
|
level: .warn,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
|
||||||
|
if let description {
|
||||||
|
toastType = .warn
|
||||||
|
toastDescription = description
|
||||||
|
}
|
||||||
|
|
||||||
|
messageArray.append(log)
|
||||||
|
|
||||||
|
print("LOG: \(log.toMessage())")
|
||||||
|
}
|
||||||
|
|
||||||
|
func error(_ message: String,
|
||||||
|
description: String? = nil,
|
||||||
|
showToast: Bool = true)
|
||||||
|
{
|
||||||
|
let log = Log(
|
||||||
|
level: .error,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
|
||||||
|
// If a task is run in parallel, don't show a toast on error
|
||||||
|
// Only gate generic error toasts behind the settings option
|
||||||
|
if showToast {
|
||||||
|
if let description {
|
||||||
|
toastDescription = description
|
||||||
|
} else if showErrorToasts {
|
||||||
|
toastDescription = "An error was logged. Please look at logs in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageArray.append(log)
|
||||||
|
|
||||||
|
print("LOG: \(log.toMessage())")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Indeterminate functions
|
||||||
|
|
||||||
|
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
||||||
|
indeterminateToastDescription = description
|
||||||
|
|
||||||
|
if let cancelAction {
|
||||||
|
indeterminateCancelAction = cancelAction
|
||||||
|
}
|
||||||
|
|
||||||
|
if !showIndeterminateToast {
|
||||||
|
showIndeterminateToast = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideIndeterminateToast() {
|
||||||
|
showIndeterminateToast = false
|
||||||
|
indeterminateToastDescription = ""
|
||||||
|
indeterminateCancelAction = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportLogs() {
|
||||||
|
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||||
|
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
|
||||||
|
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")
|
||||||
|
let logPath = logFolderPath.appendingPathComponent(logFileName)
|
||||||
|
|
||||||
|
logFormatter.dateStyle = .short
|
||||||
|
logFormatter.timeStyle = .long
|
||||||
|
let joinedMessages = messageArray.map { "\(logFormatter.string(from: $0.timeStamp)): \($0.toMessage())" }.joined(separator: "\n")
|
||||||
|
|
||||||
|
do {
|
||||||
|
if FileManager.default.fileExists(atPath: logPath.path) {
|
||||||
|
try FileManager.default.removeItem(at: logPath)
|
||||||
|
} else if !FileManager.default.fileExists(atPath: logFolderPath.path) {
|
||||||
|
try FileManager.default.createDirectory(atPath: logFolderPath.path, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
try joinedMessages.write(to: logPath, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
info("Log \(logFileName) was written to path \(logPath.description)")
|
||||||
|
showLogExportedAlert.toggle()
|
||||||
|
} catch {
|
||||||
|
self.error(
|
||||||
|
"Log export for file \(logFileName): \(error)",
|
||||||
|
description: "Exporting your log file failed. Please check the logs page."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,15 +7,118 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class NavigationViewModel: ObservableObject {
|
class NavigationViewModel: ObservableObject {
|
||||||
|
var logManager: LoggingManager?
|
||||||
|
|
||||||
|
// Used between SearchResultsView and MagnetChoiceView
|
||||||
enum ChoiceSheetType: Identifiable {
|
enum ChoiceSheetType: Identifiable {
|
||||||
var id: Int {
|
var id: Int {
|
||||||
hashValue
|
hashValue
|
||||||
}
|
}
|
||||||
|
|
||||||
case magnet
|
case action
|
||||||
case batch
|
case batch
|
||||||
|
case activity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ViewTab {
|
||||||
|
case search
|
||||||
|
case plugins
|
||||||
|
case settings
|
||||||
|
case library
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LibraryPickerSegment {
|
||||||
|
case bookmarks
|
||||||
|
case history
|
||||||
|
case debridCloud
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PluginPickerSegment {
|
||||||
|
case sources
|
||||||
|
case actions
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var selectedMagnet: Magnet?
|
||||||
|
@Published var selectedHistoryInfo: HistoryEntryJson?
|
||||||
|
@Published var resultFromCloud: Bool = false
|
||||||
|
|
||||||
|
// For giving information in magnet choice sheet
|
||||||
|
@Published var selectedTitle: String = ""
|
||||||
|
@Published var selectedBatchTitle: String = ""
|
||||||
|
|
||||||
|
// For filters
|
||||||
|
@Published var enabledFilters: Set<FilterType> = []
|
||||||
|
@Published var currentSortFilter: SortFilter?
|
||||||
|
@Published var currentSortOrder: SortOrder = .forward
|
||||||
|
|
||||||
|
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||||
|
switch currentSortFilter {
|
||||||
|
case .leechers:
|
||||||
|
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSortOrder == .forward ? lhsLeechers > rhsLeechers : lhsLeechers < rhsLeechers
|
||||||
|
case .seeders:
|
||||||
|
guard let lhsSeeders = lhs.seeders, let rhsSeeders = rhs.seeders else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSortOrder == .forward ? lhsSeeders > rhsSeeders : lhsSeeders < rhsSeeders
|
||||||
|
case .size:
|
||||||
|
guard let lhsSize = lhs.rawSize(), let rhsSize = rhs.rawSize() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSortOrder == .forward ? lhsSize > rhsSize : lhsSize < rhsSize
|
||||||
|
case .none:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var kodiExpanded: Bool = false
|
||||||
|
|
||||||
@Published var currentChoiceSheet: ChoiceSheetType?
|
@Published var currentChoiceSheet: ChoiceSheetType?
|
||||||
|
var activityItems: [Any] = []
|
||||||
|
|
||||||
|
// Used to show the activity sheet in the share menu
|
||||||
|
@Published var showLocalActivitySheet = false
|
||||||
|
|
||||||
|
@Published var selectedTab: ViewTab = .search
|
||||||
|
|
||||||
|
// Used between service views and editor views in Settings
|
||||||
|
@Published var selectedPluginList: PluginList?
|
||||||
|
@Published var selectedKodiServer: KodiServer?
|
||||||
|
|
||||||
|
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
|
||||||
|
@Published var pluginPickerSelection: PluginPickerSegment = .sources
|
||||||
|
|
||||||
|
@Published var searchPrompt: String = "Search"
|
||||||
|
@Published var lastSearchPromptIndex: Int = -1
|
||||||
|
private let searchBarTextArray: [String] = [
|
||||||
|
"What's on your mind?",
|
||||||
|
"Discover something interesting",
|
||||||
|
"Find an engaging show",
|
||||||
|
"Feeling adventurous?",
|
||||||
|
"Look for something new",
|
||||||
|
"The classics are a good idea"
|
||||||
|
]
|
||||||
|
|
||||||
|
func getSearchPrompt() {
|
||||||
|
if UserDefaults.standard.bool(forKey: "Behavior.UsesRandomSearchText") {
|
||||||
|
let num = Int.random(in: 0 ..< searchBarTextArray.count - 1)
|
||||||
|
if num == lastSearchPromptIndex {
|
||||||
|
lastSearchPromptIndex = num + 1
|
||||||
|
searchPrompt = searchBarTextArray[safe: num + 1] ?? "Search"
|
||||||
|
} else {
|
||||||
|
lastSearchPromptIndex = num
|
||||||
|
searchPrompt = searchBarTextArray[safe: num] ?? "Search"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastSearchPromptIndex = -1
|
||||||
|
searchPrompt = "Search"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
852
Ferrite/ViewModels/PluginManager.swift
Normal file
852
Ferrite/ViewModels/PluginManager.swift
Normal file
|
|
@ -0,0 +1,852 @@
|
||||||
|
//
|
||||||
|
// PluginManager.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 7/25/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Yams
|
||||||
|
|
||||||
|
class PluginManager: ObservableObject {
|
||||||
|
var logManager: LoggingManager?
|
||||||
|
let kodi: Kodi = .init()
|
||||||
|
|
||||||
|
@Published var availableSources: [SourceJson] = []
|
||||||
|
@Published var availableActions: [ActionJson] = []
|
||||||
|
|
||||||
|
@Published var filteredInstalledSources: Set<Source> = []
|
||||||
|
|
||||||
|
@Published var showActionErrorAlert = false
|
||||||
|
@Published var actionErrorAlertMessage: String = ""
|
||||||
|
|
||||||
|
@Published var showActionSuccessAlert = false
|
||||||
|
@Published var actionSuccessAlertMessage: String = ""
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func cleanAvailablePlugins() {
|
||||||
|
availableSources = []
|
||||||
|
availableActions = []
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
|
||||||
|
availableSources += newPlugins.availableSources
|
||||||
|
availableActions += newPlugins.availableActions
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPluginsFromUrl() async {
|
||||||
|
let pluginListRequest = PluginList.fetchRequest()
|
||||||
|
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
|
||||||
|
await logManager?.error("PluginManager: No plugin lists found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean availablePlugin arrays for repopulation
|
||||||
|
await cleanAvailablePlugins()
|
||||||
|
|
||||||
|
await logManager?.info("Starting fetch of plugin lists")
|
||||||
|
|
||||||
|
await withTaskGroup(of: (AvailablePlugins?, String).self) { group in
|
||||||
|
for pluginList in pluginLists {
|
||||||
|
guard let url = URL(string: pluginList.urlString) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group.addTask {
|
||||||
|
var availablePlugins: AvailablePlugins?
|
||||||
|
|
||||||
|
do {
|
||||||
|
availablePlugins = try await self.fetchPluginList(pluginList: pluginList, url: url)
|
||||||
|
} catch {
|
||||||
|
let error = error as NSError
|
||||||
|
|
||||||
|
switch error.code {
|
||||||
|
case -999:
|
||||||
|
await self.logManager?.info("PluginManager: \(pluginList.name): List fetch cancelled")
|
||||||
|
case -1009:
|
||||||
|
await self.logManager?.info("PluginManager: \(pluginList.name): The connection is offline")
|
||||||
|
default:
|
||||||
|
await self.logManager?.error("Plugin fetch: \(pluginList.name): \(error)", showToast: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (availablePlugins, pluginList.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedLists: [String] = []
|
||||||
|
for await (availablePlugins, pluginListName) in group {
|
||||||
|
if let availablePlugins {
|
||||||
|
await updateAvailablePlugins(availablePlugins)
|
||||||
|
} else {
|
||||||
|
failedLists.append(pluginListName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !failedLists.isEmpty {
|
||||||
|
let joinedLists = failedLists.joined(separator: ", ")
|
||||||
|
await logManager?.info(
|
||||||
|
"Plugins: Errors in plugin lists \(joinedLists). See above.",
|
||||||
|
description: "There were errors in plugin lists \(joinedLists). Check the logs for more details."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logManager?.info("Plugin list fetch finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
|
||||||
|
var tempSources: [SourceJson] = []
|
||||||
|
var tempActions: [ActionJson] = []
|
||||||
|
|
||||||
|
// Always get the up-to-date source list
|
||||||
|
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
let pluginResponse: PluginListJson?
|
||||||
|
|
||||||
|
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
|
||||||
|
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
|
||||||
|
pluginResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
} else {
|
||||||
|
pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let pluginResponse else {
|
||||||
|
throw PluginManagerError.PluginFetch(description: "Could not decode plugin list data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sources = pluginResponse.sources {
|
||||||
|
// Faster and more performant to map instead of a for loop
|
||||||
|
tempSources += sources.compactMap { inputJson in
|
||||||
|
if checkAppVersion(minVersion: inputJson.minVersion) {
|
||||||
|
return SourceJson(
|
||||||
|
name: inputJson.name,
|
||||||
|
version: inputJson.version,
|
||||||
|
minVersion: inputJson.minVersion,
|
||||||
|
about: inputJson.about,
|
||||||
|
website: inputJson.website,
|
||||||
|
dynamicWebsite: inputJson.dynamicWebsite,
|
||||||
|
fallbackUrls: inputJson.fallbackUrls,
|
||||||
|
trackers: inputJson.trackers,
|
||||||
|
api: inputJson.api,
|
||||||
|
jsonParser: inputJson.jsonParser,
|
||||||
|
rssParser: inputJson.rssParser,
|
||||||
|
htmlParser: inputJson.htmlParser,
|
||||||
|
author: pluginList.author,
|
||||||
|
listId: pluginList.id,
|
||||||
|
listName: pluginList.name,
|
||||||
|
tags: inputJson.tags
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actions = pluginResponse.actions {
|
||||||
|
tempActions += actions.compactMap { inputJson in
|
||||||
|
if
|
||||||
|
let deeplink = inputJson.deeplink,
|
||||||
|
checkAppVersion(minVersion: inputJson.minVersion),
|
||||||
|
let filteredDeeplinks = getFilteredDeeplinks(deeplink)
|
||||||
|
{
|
||||||
|
return ActionJson(
|
||||||
|
name: inputJson.name,
|
||||||
|
version: inputJson.version,
|
||||||
|
minVersion: inputJson.minVersion,
|
||||||
|
about: inputJson.about,
|
||||||
|
website: inputJson.website,
|
||||||
|
requires: inputJson.requires,
|
||||||
|
deeplink: filteredDeeplinks,
|
||||||
|
author: pluginList.author,
|
||||||
|
listId: pluginList.id,
|
||||||
|
listName: pluginList.name,
|
||||||
|
tags: inputJson.tags
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AvailablePlugins(availableSources: tempSources, availableActions: tempActions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
|
||||||
|
private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
|
||||||
|
let osArray = deeplinks.filter { deeplink in
|
||||||
|
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if osArray.count == 1 {
|
||||||
|
return osArray
|
||||||
|
} else {
|
||||||
|
let universalArray = deeplinks.filter { deeplink in
|
||||||
|
deeplink.os.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
if universalArray.count == 1 {
|
||||||
|
return universalArray
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forType required to guide generic inferences
|
||||||
|
func fetchFilteredPlugins<PJ: PluginJson>(forType: PJ.Type,
|
||||||
|
installedPlugins: FetchedResults<some Plugin>,
|
||||||
|
searchText: String) -> [PJ]
|
||||||
|
{
|
||||||
|
let availablePlugins: [PJ] = fetchCastedPlugins(forType)
|
||||||
|
|
||||||
|
return availablePlugins
|
||||||
|
.filter { availablePlugin in
|
||||||
|
let pluginExists = installedPlugins.contains(where: {
|
||||||
|
availablePlugin.name == $0.name &&
|
||||||
|
availablePlugin.listId == $0.listId &&
|
||||||
|
availablePlugin.author == $0.author
|
||||||
|
})
|
||||||
|
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return !pluginExists
|
||||||
|
} else {
|
||||||
|
return !pluginExists && availablePlugin.name.lowercased().contains(searchText.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUpdatedPlugins<PJ: PluginJson>(forType: PJ.Type,
|
||||||
|
installedPlugins: FetchedResults<some Plugin>,
|
||||||
|
searchText: String) -> [PJ]
|
||||||
|
{
|
||||||
|
var updatedPlugins: [PJ] = []
|
||||||
|
let availablePlugins: [PJ] = fetchCastedPlugins(forType)
|
||||||
|
|
||||||
|
for plugin in installedPlugins {
|
||||||
|
if let availablePlugin = availablePlugins.first(where: {
|
||||||
|
plugin.listId == $0.listId &&
|
||||||
|
plugin.name == $0.name &&
|
||||||
|
plugin.author == $0.author
|
||||||
|
}),
|
||||||
|
availablePlugin.version > plugin.version
|
||||||
|
{
|
||||||
|
updatedPlugins.append(availablePlugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedPlugins
|
||||||
|
.filter {
|
||||||
|
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
||||||
|
switch String(describing: PJ.self) {
|
||||||
|
case "SourceJson":
|
||||||
|
return availableSources as? [PJ] ?? []
|
||||||
|
case "ActionJson":
|
||||||
|
return availableActions as? [PJ] ?? []
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if the current app version is supported by the source
|
||||||
|
private func checkAppVersion(minVersion: String?) -> Bool {
|
||||||
|
// If there's no min version, assume that every version is supported
|
||||||
|
guard let minVersion else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Application.shared.appVersion >= minVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches sources using the background context
|
||||||
|
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
|
||||||
|
return Array(filteredInstalledSources)
|
||||||
|
} else if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
|
||||||
|
return sources.compactMap { $0 }
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
|
||||||
|
let context = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
guard let urlString else {
|
||||||
|
logManager?.error("Default action: Could not run because the URL is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultsKey: String
|
||||||
|
// Assume this is a magnet link
|
||||||
|
if urlString.starts(with: "magnet") {
|
||||||
|
defaultsKey = "Actions.DefaultMagnet"
|
||||||
|
} else {
|
||||||
|
defaultsKey = "Actions.DefaultDebrid"
|
||||||
|
}
|
||||||
|
|
||||||
|
if
|
||||||
|
let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
|
||||||
|
let defaultAction = CodableWrapper<DefaultAction>(rawValue: rawValue)?.value
|
||||||
|
{
|
||||||
|
switch defaultAction {
|
||||||
|
case .none:
|
||||||
|
navModel.currentChoiceSheet = .action
|
||||||
|
case .share:
|
||||||
|
navModel.activityItems = [urlString]
|
||||||
|
navModel.currentChoiceSheet = .activity
|
||||||
|
case .kodi:
|
||||||
|
navModel.kodiExpanded = true
|
||||||
|
navModel.currentChoiceSheet = .action
|
||||||
|
case let .custom(name, listId):
|
||||||
|
let actionFetchRequest = Action.fetchRequest()
|
||||||
|
actionFetchRequest.fetchLimit = 1
|
||||||
|
actionFetchRequest.predicate = NSPredicate(format: "name == %@ AND listId == %@", name, listId)
|
||||||
|
|
||||||
|
if let fetchedAction = try? context.fetch(actionFetchRequest).first {
|
||||||
|
runDeeplinkAction(fetchedAction, urlString: urlString)
|
||||||
|
} else {
|
||||||
|
navModel.currentChoiceSheet = .action
|
||||||
|
UserDefaults.standard.set(CodableWrapper<DefaultAction>(value: .none).rawValue, forKey: "Actions.DefaultDebrid")
|
||||||
|
|
||||||
|
actionErrorAlertMessage =
|
||||||
|
"The default action could not be run. The action choice sheet has been opened. \n\n" +
|
||||||
|
"Please check your default actions in Settings"
|
||||||
|
showActionErrorAlert.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navModel.currentChoiceSheet = .action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The iOS version of Ferrite only runs deeplink actions
|
||||||
|
@MainActor
|
||||||
|
func runDeeplinkAction(_ action: Action, urlString: String?) {
|
||||||
|
guard let deeplink = action.deeplink, let urlString else {
|
||||||
|
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
|
||||||
|
showActionErrorAlert.toggle()
|
||||||
|
|
||||||
|
logManager?.error("Could not run action: \(action.name) since there is no deeplink to execute.")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let playbackUrl = URL(string: deeplink.replacingOccurrences(of: "{link}", with: urlString))
|
||||||
|
|
||||||
|
if let playbackUrl {
|
||||||
|
UIApplication.shared.open(playbackUrl)
|
||||||
|
} else {
|
||||||
|
actionErrorAlertMessage = "Could not run action: \(action.name) because the created deeplink was invalid. Contact the action dev!"
|
||||||
|
showActionErrorAlert.toggle()
|
||||||
|
|
||||||
|
logManager?.error("Could not run action: \(action.name) because the created deeplink (\(String(describing: playbackUrl))) was invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func sendToKodi(urlString: String?, server: KodiServer) async {
|
||||||
|
guard let urlString else {
|
||||||
|
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
|
||||||
|
showActionErrorAlert.toggle()
|
||||||
|
|
||||||
|
logManager?.error("Kodi action: Could not send URL to Kodi since there is no playback URL to send")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await kodi.sendVideoUrl(urlString: urlString, server: server)
|
||||||
|
|
||||||
|
actionSuccessAlertMessage = "Your URL should be playing on Kodi"
|
||||||
|
showActionSuccessAlert.toggle()
|
||||||
|
|
||||||
|
logManager?.info("URL \(urlString) is playing on Kodi")
|
||||||
|
} catch {
|
||||||
|
actionErrorAlertMessage = "Kodi Error: \(error)"
|
||||||
|
showActionErrorAlert.toggle()
|
||||||
|
|
||||||
|
logManager?.error("Kodi action: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
||||||
|
guard let actionJson else {
|
||||||
|
await logManager?.error("Action addition: No action present. Contact the app dev!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
if actionJson.requires.count < 1 {
|
||||||
|
await logManager?.error("Action addition: actions must require an input. Please contact the action dev!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let deeplinks = actionJson.deeplink else {
|
||||||
|
await logManager?.error("Action addition: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingActionRequest = Action.fetchRequest()
|
||||||
|
existingActionRequest.predicate = NSPredicate(format: "name == %@", actionJson.name)
|
||||||
|
existingActionRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existingAction = try? backgroundContext.fetch(existingActionRequest).first {
|
||||||
|
if doUpsert {
|
||||||
|
PersistenceController.shared.delete(existingAction, context: backgroundContext)
|
||||||
|
} else {
|
||||||
|
await logManager?.error("Action addition: Could not install action with name \(actionJson.name) because it is already installed")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newAction = Action(context: backgroundContext)
|
||||||
|
newAction.id = UUID()
|
||||||
|
newAction.name = actionJson.name
|
||||||
|
newAction.version = actionJson.version
|
||||||
|
newAction.website = actionJson.website
|
||||||
|
newAction.about = actionJson.about
|
||||||
|
newAction.author = actionJson.author ?? "Unknown"
|
||||||
|
newAction.listId = actionJson.listId
|
||||||
|
newAction.requires = actionJson.requires.map(\.rawValue)
|
||||||
|
newAction.enabled = true
|
||||||
|
|
||||||
|
if let jsonTags = actionJson.tags {
|
||||||
|
for tag in jsonTags {
|
||||||
|
let newTag = PluginTag(context: backgroundContext)
|
||||||
|
newTag.name = tag.name
|
||||||
|
newTag.colorHex = tag.colorHex
|
||||||
|
|
||||||
|
newTag.parentAction = newAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one deeplink is left in this action JSON because of the previous filtering logic
|
||||||
|
guard let deeplinkJson = deeplinks.first else {
|
||||||
|
await logManager?.error("Action addition: No deeplink was present in action with name \(actionJson.name). Contact the action dev!")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAction.deeplink = deeplinkJson.scheme
|
||||||
|
|
||||||
|
do {
|
||||||
|
try backgroundContext.save()
|
||||||
|
} catch {
|
||||||
|
await logManager?.error("Action addition: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
||||||
|
guard let sourceJson else {
|
||||||
|
await logManager?.error("Source addition: No source present. Contact the app dev!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
// If there's no base URL and it isn't dynamic, return before any transactions occur
|
||||||
|
let dynamicWebsite = sourceJson.dynamicWebsite ?? false
|
||||||
|
if !dynamicWebsite, sourceJson.website == nil {
|
||||||
|
await logManager?.error("Not adding this source because website parameters are malformed. Please contact the source dev.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a source exists, don't add the new one unless upserting
|
||||||
|
let existingSourceRequest = Source.fetchRequest()
|
||||||
|
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
|
||||||
|
existingSourceRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first {
|
||||||
|
if doUpsert {
|
||||||
|
PersistenceController.shared.delete(existingSource, context: backgroundContext)
|
||||||
|
} else {
|
||||||
|
await logManager?.error("Source addition: Could not install source with name \(sourceJson.name) because it is already installed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newSource = Source(context: backgroundContext)
|
||||||
|
newSource.id = UUID()
|
||||||
|
newSource.name = sourceJson.name
|
||||||
|
newSource.version = sourceJson.version
|
||||||
|
newSource.about = sourceJson.about
|
||||||
|
newSource.website = sourceJson.website
|
||||||
|
newSource.dynamicWebsite = dynamicWebsite
|
||||||
|
newSource.fallbackUrls = dynamicWebsite ? nil : sourceJson.fallbackUrls
|
||||||
|
newSource.author = sourceJson.author ?? "Unknown"
|
||||||
|
newSource.listId = sourceJson.listId
|
||||||
|
newSource.trackers = sourceJson.trackers
|
||||||
|
|
||||||
|
if let jsonTags = sourceJson.tags {
|
||||||
|
for tag in jsonTags {
|
||||||
|
let newTag = PluginTag(context: backgroundContext)
|
||||||
|
newTag.name = tag.name
|
||||||
|
newTag.colorHex = tag.colorHex
|
||||||
|
|
||||||
|
newTag.parentSource = newSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sourceApiJson = sourceJson.api {
|
||||||
|
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let jsonParserJson = sourceJson.jsonParser {
|
||||||
|
addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds an RSS parser if present
|
||||||
|
if let rssParserJson = sourceJson.rssParser {
|
||||||
|
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds an HTML parser if present
|
||||||
|
if let htmlParserJson = sourceJson.htmlParser {
|
||||||
|
addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an API condition as well
|
||||||
|
if newSource.jsonParser != nil {
|
||||||
|
newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue)
|
||||||
|
} else if newSource.rssParser != nil {
|
||||||
|
newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
|
||||||
|
} else {
|
||||||
|
newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource.enabled = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
try backgroundContext.save()
|
||||||
|
} catch {
|
||||||
|
await logManager?.error("Source addition error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
let newSourceApi = SourceApi(context: backgroundContext)
|
||||||
|
newSourceApi.apiUrl = apiJson.apiUrl
|
||||||
|
|
||||||
|
if let clientIdJson = apiJson.clientId {
|
||||||
|
let newClientId = SourceApiClientId(context: backgroundContext)
|
||||||
|
newClientId.query = clientIdJson.query
|
||||||
|
newClientId.urlString = clientIdJson.url
|
||||||
|
newClientId.dynamic = clientIdJson.dynamic ?? false
|
||||||
|
newClientId.value = clientIdJson.value
|
||||||
|
newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
|
||||||
|
newClientId.expiryLength = clientIdJson.expiryLength ?? 0
|
||||||
|
newClientId.timeStamp = Date()
|
||||||
|
|
||||||
|
newSourceApi.clientId = newClientId
|
||||||
|
}
|
||||||
|
|
||||||
|
if let clientSecretJson = apiJson.clientSecret {
|
||||||
|
let newClientSecret = SourceApiClientSecret(context: backgroundContext)
|
||||||
|
newClientSecret.query = clientSecretJson.query
|
||||||
|
newClientSecret.urlString = clientSecretJson.url
|
||||||
|
newClientSecret.dynamic = clientSecretJson.dynamic ?? false
|
||||||
|
newClientSecret.value = clientSecretJson.value
|
||||||
|
newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
|
||||||
|
newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0
|
||||||
|
newClientSecret.timeStamp = Date()
|
||||||
|
|
||||||
|
newSourceApi.clientSecret = newClientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource.api = newSourceApi
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Migrate parser addition to a common protocol
|
||||||
|
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
|
||||||
|
newSourceJsonParser.searchUrl = jsonParserJson.searchUrl
|
||||||
|
newSourceJsonParser.results = jsonParserJson.results
|
||||||
|
newSourceJsonParser.subResults = jsonParserJson.subResults
|
||||||
|
|
||||||
|
if let requestJson = newSourceJsonParser.request {
|
||||||
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
|
newParserRequest.method = requestJson.method
|
||||||
|
newParserRequest.headers = requestJson.headers
|
||||||
|
newParserRequest.body = requestJson.body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tune these complex queries to the final JSON parser format
|
||||||
|
if let magnetLinkJson = jsonParserJson.magnetLink {
|
||||||
|
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||||
|
newSourceMagnetLink.query = magnetLinkJson.query
|
||||||
|
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
|
||||||
|
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
|
||||||
|
|
||||||
|
newSourceJsonParser.magnetLink = newSourceMagnetLink
|
||||||
|
}
|
||||||
|
|
||||||
|
if let magnetHashJson = jsonParserJson.magnetHash {
|
||||||
|
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
|
||||||
|
newSourceMagnetHash.query = magnetHashJson.query
|
||||||
|
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
|
||||||
|
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
|
||||||
|
|
||||||
|
newSourceJsonParser.magnetHash = newSourceMagnetHash
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subNameJson = jsonParserJson.subName {
|
||||||
|
let newSourceSubName = SourceSubName(context: backgroundContext)
|
||||||
|
newSourceSubName.query = subNameJson.query
|
||||||
|
newSourceSubName.attribute = subNameJson.query
|
||||||
|
newSourceSubName.discriminator = subNameJson.discriminator
|
||||||
|
|
||||||
|
newSourceJsonParser.subName = newSourceSubName
|
||||||
|
}
|
||||||
|
|
||||||
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
|
newSourceTitle.query = jsonParserJson.title.query
|
||||||
|
newSourceTitle.attribute = jsonParserJson.title.attribute ?? "text"
|
||||||
|
newSourceTitle.discriminator = jsonParserJson.title.discriminator
|
||||||
|
|
||||||
|
newSourceJsonParser.title = newSourceTitle
|
||||||
|
|
||||||
|
if let sizeJson = jsonParserJson.size {
|
||||||
|
let newSourceSize = SourceSize(context: backgroundContext)
|
||||||
|
newSourceSize.query = sizeJson.query
|
||||||
|
newSourceSize.attribute = sizeJson.attribute ?? "text"
|
||||||
|
newSourceSize.discriminator = sizeJson.discriminator
|
||||||
|
|
||||||
|
newSourceJsonParser.size = newSourceSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if let seedLeechJson = jsonParserJson.sl {
|
||||||
|
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
|
||||||
|
newSourceSeedLeech.seeders = seedLeechJson.seeders
|
||||||
|
newSourceSeedLeech.leechers = seedLeechJson.leechers
|
||||||
|
newSourceSeedLeech.combined = seedLeechJson.combined
|
||||||
|
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
|
||||||
|
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
|
||||||
|
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
|
||||||
|
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
|
||||||
|
|
||||||
|
newSourceJsonParser.seedLeech = newSourceSeedLeech
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource.jsonParser = newSourceJsonParser
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
let newSourceRssParser = SourceRssParser(context: backgroundContext)
|
||||||
|
newSourceRssParser.rssUrl = rssParserJson.rssUrl
|
||||||
|
newSourceRssParser.searchUrl = rssParserJson.searchUrl
|
||||||
|
newSourceRssParser.items = rssParserJson.items
|
||||||
|
|
||||||
|
if let requestJson = newSourceRssParser.request {
|
||||||
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
|
newParserRequest.method = requestJson.method
|
||||||
|
newParserRequest.headers = requestJson.headers
|
||||||
|
newParserRequest.body = requestJson.body
|
||||||
|
}
|
||||||
|
|
||||||
|
if let magnetLinkJson = rssParserJson.magnetLink {
|
||||||
|
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||||
|
newSourceMagnetLink.query = magnetLinkJson.query
|
||||||
|
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
|
||||||
|
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
|
||||||
|
newSourceMagnetLink.regex = magnetLinkJson.regex
|
||||||
|
|
||||||
|
newSourceRssParser.magnetLink = newSourceMagnetLink
|
||||||
|
}
|
||||||
|
|
||||||
|
if let magnetHashJson = rssParserJson.magnetHash {
|
||||||
|
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
|
||||||
|
newSourceMagnetHash.query = magnetHashJson.query
|
||||||
|
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
|
||||||
|
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
|
||||||
|
newSourceMagnetHash.regex = magnetHashJson.regex
|
||||||
|
|
||||||
|
newSourceRssParser.magnetHash = newSourceMagnetHash
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subNameJson = rssParserJson.subName {
|
||||||
|
let newSourceSubName = SourceSubName(context: backgroundContext)
|
||||||
|
newSourceSubName.query = subNameJson.query
|
||||||
|
newSourceSubName.attribute = subNameJson.attribute ?? "text"
|
||||||
|
newSourceSubName.discriminator = subNameJson.discriminator
|
||||||
|
newSourceSubName.regex = subNameJson.regex
|
||||||
|
|
||||||
|
newSourceRssParser.subName = newSourceSubName
|
||||||
|
}
|
||||||
|
|
||||||
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
|
newSourceTitle.query = rssParserJson.title.query
|
||||||
|
newSourceTitle.attribute = rssParserJson.title.attribute ?? "text"
|
||||||
|
newSourceTitle.discriminator = rssParserJson.title.discriminator
|
||||||
|
newSourceTitle.regex = rssParserJson.title.regex
|
||||||
|
|
||||||
|
newSourceRssParser.title = newSourceTitle
|
||||||
|
|
||||||
|
if let sizeJson = rssParserJson.size {
|
||||||
|
let newSourceSize = SourceSize(context: backgroundContext)
|
||||||
|
newSourceSize.query = sizeJson.query
|
||||||
|
newSourceSize.attribute = sizeJson.attribute ?? "text"
|
||||||
|
newSourceSize.discriminator = sizeJson.discriminator
|
||||||
|
newSourceSize.regex = sizeJson.regex
|
||||||
|
|
||||||
|
newSourceRssParser.size = newSourceSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if let seedLeechJson = rssParserJson.sl {
|
||||||
|
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
|
||||||
|
newSourceSeedLeech.seeders = seedLeechJson.seeders
|
||||||
|
newSourceSeedLeech.leechers = seedLeechJson.leechers
|
||||||
|
newSourceSeedLeech.combined = seedLeechJson.combined
|
||||||
|
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
|
||||||
|
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
|
||||||
|
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
|
||||||
|
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
|
||||||
|
|
||||||
|
newSourceRssParser.seedLeech = newSourceSeedLeech
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource.rssParser = newSourceRssParser
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
|
||||||
|
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
|
||||||
|
newSourceHtmlParser.rows = htmlParserJson.rows
|
||||||
|
|
||||||
|
if let subNameJson = htmlParserJson.subName {
|
||||||
|
let newSourceSubName = SourceSubName(context: backgroundContext)
|
||||||
|
newSourceSubName.query = subNameJson.query
|
||||||
|
newSourceSubName.attribute = subNameJson.attribute ?? "text"
|
||||||
|
newSourceSubName.regex = subNameJson.regex
|
||||||
|
|
||||||
|
newSourceHtmlParser.subName = newSourceSubName
|
||||||
|
}
|
||||||
|
|
||||||
|
if let requestJson = htmlParserJson.request {
|
||||||
|
print(requestJson)
|
||||||
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
|
newParserRequest.method = requestJson.method
|
||||||
|
newParserRequest.headers = requestJson.headers
|
||||||
|
newParserRequest.body = requestJson.body
|
||||||
|
|
||||||
|
newSourceHtmlParser.request = newParserRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a title complex query
|
||||||
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
|
newSourceTitle.query = htmlParserJson.title.query
|
||||||
|
newSourceTitle.attribute = htmlParserJson.title.attribute ?? "text"
|
||||||
|
newSourceTitle.regex = htmlParserJson.title.regex
|
||||||
|
|
||||||
|
newSourceHtmlParser.title = newSourceTitle
|
||||||
|
|
||||||
|
// Adds a size complex query if present
|
||||||
|
if let sizeJson = htmlParserJson.size {
|
||||||
|
let newSourceSize = SourceSize(context: backgroundContext)
|
||||||
|
newSourceSize.query = sizeJson.query
|
||||||
|
newSourceSize.attribute = sizeJson.attribute ?? "text"
|
||||||
|
newSourceSize.regex = sizeJson.regex
|
||||||
|
|
||||||
|
newSourceHtmlParser.size = newSourceSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if let seedLeechJson = htmlParserJson.sl {
|
||||||
|
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
|
||||||
|
newSourceSeedLeech.seeders = seedLeechJson.seeders
|
||||||
|
newSourceSeedLeech.leechers = seedLeechJson.leechers
|
||||||
|
newSourceSeedLeech.combined = seedLeechJson.combined
|
||||||
|
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
|
||||||
|
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
|
||||||
|
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
|
||||||
|
|
||||||
|
newSourceHtmlParser.seedLeech = newSourceSeedLeech
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a magnet complex query and its unique properties
|
||||||
|
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
|
||||||
|
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
|
||||||
|
newSourceMagnet.query = htmlParserJson.magnet.query
|
||||||
|
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
|
||||||
|
newSourceMagnet.regex = htmlParserJson.magnet.regex
|
||||||
|
|
||||||
|
newSourceHtmlParser.magnetLink = newSourceMagnet
|
||||||
|
|
||||||
|
newSource.htmlParser = newSourceHtmlParser
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a plugin list
|
||||||
|
// Can move this to PersistenceController if needed
|
||||||
|
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
||||||
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
|
||||||
|
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
|
||||||
|
|
||||||
|
let rawResponse: PluginListJson?
|
||||||
|
|
||||||
|
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
|
||||||
|
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
|
||||||
|
rawResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
} else {
|
||||||
|
rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let rawResponse else {
|
||||||
|
throw PluginManagerError.ListAddition(description: "Could not decode the plugin list from URL \(urlString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existingPluginList {
|
||||||
|
existingPluginList.urlString = urlString
|
||||||
|
existingPluginList.name = rawResponse.name
|
||||||
|
existingPluginList.author = rawResponse.author
|
||||||
|
|
||||||
|
try PersistenceController.shared.container.viewContext.save()
|
||||||
|
} else {
|
||||||
|
let pluginListRequest = PluginList.fetchRequest()
|
||||||
|
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
|
||||||
|
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
|
||||||
|
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
|
||||||
|
pluginListRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existingPluginList = try? backgroundContext.fetch(pluginListRequest).first, !isSheet {
|
||||||
|
PersistenceController.shared.delete(existingPluginList, context: backgroundContext)
|
||||||
|
} else if isSheet {
|
||||||
|
throw PluginManagerError.ListAddition(description: "An existing plugin list with this information was found. Please try editing an existing plugin list instead.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPluginList = PluginList(context: backgroundContext)
|
||||||
|
newPluginList.id = UUID()
|
||||||
|
newPluginList.urlString = urlString
|
||||||
|
newPluginList.name = rawResponse.name
|
||||||
|
newPluginList.author = rawResponse.author
|
||||||
|
|
||||||
|
try backgroundContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,119 +0,0 @@
|
||||||
//
|
|
||||||
// SourceViewModel.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/25/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public class SourceManager: ObservableObject {
|
|
||||||
var toastModel: ToastViewModel?
|
|
||||||
|
|
||||||
@Published var availableSources: [TorrentSourceJson] = []
|
|
||||||
|
|
||||||
@Published var urlErrorAlertText = ""
|
|
||||||
@Published var showUrlErrorAlert = false
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public func fetchSourcesFromUrl() async {
|
|
||||||
let sourceUrlRequest = TorrentSourceUrl.fetchRequest()
|
|
||||||
do {
|
|
||||||
let sourceUrls = try PersistenceController.shared.backgroundContext.fetch(sourceUrlRequest)
|
|
||||||
var tempSourceUrls: [TorrentSourceJson] = []
|
|
||||||
|
|
||||||
for sourceUrl in sourceUrls {
|
|
||||||
guard let url = URL(string: sourceUrl.urlString) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
|
|
||||||
let sourceResponse = try JSONDecoder().decode(SourceJson.self, from: data)
|
|
||||||
|
|
||||||
tempSourceUrls += sourceResponse.sources
|
|
||||||
}
|
|
||||||
|
|
||||||
availableSources = tempSourceUrls
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func installSource(sourceJson: TorrentSourceJson) {
|
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
|
||||||
|
|
||||||
// If a source exists, don't add the new one
|
|
||||||
if let name = sourceJson.name {
|
|
||||||
let existingSourceRequest = TorrentSource.fetchRequest()
|
|
||||||
existingSourceRequest.predicate = NSPredicate(format: "name == %@", name)
|
|
||||||
existingSourceRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
let existingSource = try? backgroundContext.fetch(existingSourceRequest).first
|
|
||||||
if existingSource != nil {
|
|
||||||
Task { @MainActor in
|
|
||||||
toastModel?.toastDescription = "Could not install source \(sourceJson.name ?? "Unknown source") because it is already installed."
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newTorrentSource = TorrentSource(context: backgroundContext)
|
|
||||||
newTorrentSource.name = sourceJson.name
|
|
||||||
newTorrentSource.url = sourceJson.url
|
|
||||||
newTorrentSource.rowQuery = sourceJson.rowQuery
|
|
||||||
newTorrentSource.linkQuery = sourceJson.linkQuery
|
|
||||||
newTorrentSource.titleQuery = sourceJson.titleQuery
|
|
||||||
newTorrentSource.sizeQuery = sourceJson.sizeQuery
|
|
||||||
|
|
||||||
newTorrentSource.enabled = true
|
|
||||||
|
|
||||||
do {
|
|
||||||
try backgroundContext.save()
|
|
||||||
} catch {
|
|
||||||
Task { @MainActor in
|
|
||||||
toastModel?.toastDescription = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public func addSourceList(sourceUrl: String) async -> Bool {
|
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
|
||||||
|
|
||||||
if sourceUrl.isEmpty || URL(string: sourceUrl) == nil {
|
|
||||||
urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly."
|
|
||||||
showUrlErrorAlert.toggle()
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let sourceUrlRequest = TorrentSourceUrl.fetchRequest()
|
|
||||||
sourceUrlRequest.predicate = NSPredicate(format: "urlString == %@", sourceUrl)
|
|
||||||
sourceUrlRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
if let existingSourceUrl = try? backgroundContext.fetch(sourceUrlRequest).first {
|
|
||||||
print("Existing source URL found")
|
|
||||||
PersistenceController.shared.delete(existingSourceUrl, context: backgroundContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
let newSourceUrl = TorrentSourceUrl(context: backgroundContext)
|
|
||||||
newSourceUrl.urlString = sourceUrl
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!))
|
|
||||||
if let rawResponse = try? JSONDecoder().decode(SourceJson.self, from: data) {
|
|
||||||
newSourceUrl.repoName = rawResponse.repoName
|
|
||||||
}
|
|
||||||
|
|
||||||
try backgroundContext.save()
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
urlErrorAlertText = error.localizedDescription
|
|
||||||
showUrlErrorAlert.toggle()
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
//
|
|
||||||
// ErrorViewModel.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/19/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ToastViewModel: ObservableObject {
|
|
||||||
enum ToastType: Identifiable {
|
|
||||||
var id: Int {
|
|
||||||
hashValue
|
|
||||||
}
|
|
||||||
|
|
||||||
case info
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toast variables
|
|
||||||
@Published var toastDescription: String? = nil {
|
|
||||||
didSet {
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(seconds: 0.1)
|
|
||||||
showToast = true
|
|
||||||
|
|
||||||
try await Task.sleep(seconds: 5)
|
|
||||||
|
|
||||||
showToast = false
|
|
||||||
toastType = .error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var showToast: Bool = false
|
|
||||||
|
|
||||||
// Default the toast type to error since the majority of toasts are errors
|
|
||||||
@Published var toastType: ToastType = .error
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue