Compare commits
170 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4184cf1b9 | ||
|
|
cfc4a74afe | ||
|
|
7bb4ed5f7c | ||
|
|
f40f71bca3 | ||
|
|
68a7c60c2d | ||
|
|
8b00d11e44 | ||
|
|
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 |
179 changed files with 10506 additions and 3854 deletions
19
.github/workflows/nightly.yml
vendored
19
.github/workflows/nightly.yml
vendored
|
|
@ -6,26 +6,27 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-12
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest
|
||||
xcode-version: latest-stable
|
||||
- name: Get commit SHA
|
||||
id: commitinfo
|
||||
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- name: Build
|
||||
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
||||
env:
|
||||
IS_NIGHTLY: YES
|
||||
- name: Package ipa
|
||||
run: |
|
||||
mkdir Payload
|
||||
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
|
||||
zip -r Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa Payload
|
||||
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
|
||||
path: Ferrite-iOS_nightly-${{ steps.commitinfo.outputs.sha_short }}.ipa
|
||||
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
||||
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
||||
if-no-files-found: error
|
||||
|
|
|
|||
36
.github/workflows/release.yml
vendored
Normal file
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"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,22 +7,22 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class Github {
|
||||
public func fetchLatestRelease() async throws -> GithubRelease? {
|
||||
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases/latest")!
|
||||
class Github {
|
||||
func fetchLatestRelease() async throws -> Release? {
|
||||
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")!
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
|
||||
let rawResponse = try JSONDecoder().decode(GithubRelease.self, from: data)
|
||||
let rawResponse = try JSONDecoder().decode(Release.self, from: data)
|
||||
return rawResponse
|
||||
}
|
||||
|
||||
public func fetchReleases() async throws -> [GithubRelease]? {
|
||||
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases")!
|
||||
func fetchReleases() async throws -> [Release]? {
|
||||
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")!
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
|
||||
let rawResponse = try JSONDecoder().decode([GithubRelease].self, from: data)
|
||||
let rawResponse = try JSONDecoder().decode([Release].self, from: data)
|
||||
return rawResponse
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,31 +6,69 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import KeychainSwift
|
||||
|
||||
public enum RealDebridError: Error {
|
||||
case InvalidUrl
|
||||
case InvalidPostBody
|
||||
case InvalidResponse
|
||||
case InvalidToken
|
||||
case EmptyData
|
||||
case EmptyTorrents
|
||||
case FailedRequest(description: String)
|
||||
case AuthQuery(description: String)
|
||||
}
|
||||
|
||||
public class RealDebrid {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let keychain = KeychainSwift()
|
||||
|
||||
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
||||
let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
||||
let openSourceClientId = "X245A4XAIBGVM"
|
||||
class RealDebrid: PollingDebridSource, ObservableObject {
|
||||
let id = "RealDebrid"
|
||||
let abbreviation = "RD"
|
||||
let website = "https://real-debrid.com"
|
||||
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
|
||||
"You must pay to access this service. \n\n" +
|
||||
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||
"This service does not inform if a magnet link is a batch before downloading."
|
||||
|
||||
let cachedStatus: [String] = ["downloaded"]
|
||||
var authTask: Task<Void, Error>?
|
||||
|
||||
@Published var authProcessing: Bool = false
|
||||
|
||||
// Check the manual token since getTokens() is async
|
||||
var isLoggedIn: Bool {
|
||||
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
|
||||
}
|
||||
|
||||
var manualToken: String? {
|
||||
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
|
||||
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Published var IAValues: [DebridIA] = []
|
||||
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||
var cloudTTL: Double = 0.0
|
||||
|
||||
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
||||
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
||||
private let openSourceClientId = "X245A4XAIBGVM"
|
||||
|
||||
private let jsonDecoder = JSONDecoder()
|
||||
|
||||
@MainActor
|
||||
private func setUserDefaultsValue(_ value: Any, forKey: String) {
|
||||
UserDefaults.standard.set(value, forKey: forKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func removeUserDefaultsValue(forKey: String) {
|
||||
UserDefaults.standard.removeObject(forKey: forKey)
|
||||
}
|
||||
|
||||
init() {
|
||||
// Populate user downloads and magnets
|
||||
Task {
|
||||
try? await getUserDownloads()
|
||||
try? await getUserMagnets()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
// Fetches the device code from RD
|
||||
public func getVerificationInfo() async throws -> DeviceCodeResponse {
|
||||
func getAuthUrl() async throws -> URL {
|
||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||
|
|
@ -38,23 +76,33 @@ public class RealDebrid {
|
|||
]
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
throw RealDebridError.InvalidUrl
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
|
||||
let request = URLRequest(url: url)
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// Validate the URL before doing anything else
|
||||
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
|
||||
return rawResponse
|
||||
guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else {
|
||||
throw DebridError.AuthQuery(description: "The verification URL is invalid")
|
||||
}
|
||||
|
||||
// Spawn the polling task separately
|
||||
authTask = Task {
|
||||
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
|
||||
}
|
||||
|
||||
return directVerificationUrl
|
||||
} catch {
|
||||
print("Couldn't get the new client creds!")
|
||||
throw RealDebridError.AuthQuery(description: error.localizedDescription)
|
||||
throw DebridError.AuthQuery(description: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches the user's client ID and secret
|
||||
public func getDeviceCredentials(deviceCode: String) async throws {
|
||||
func getDeviceCredentials(deviceCode: String) async throws {
|
||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
|
||||
urlComponents.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||
|
|
@ -62,55 +110,49 @@ public class RealDebrid {
|
|||
]
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
throw RealDebridError.InvalidUrl
|
||||
throw DebridError.InvalidUrl
|
||||
}
|
||||
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
// Timer to poll RD API for credentials
|
||||
authTask = Task {
|
||||
var count = 0
|
||||
var count = 0
|
||||
|
||||
while count < 20 {
|
||||
if Task.isCancelled {
|
||||
throw RealDebridError.AuthQuery(description: "Token request cancelled.")
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// We don't care if this fails
|
||||
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
||||
|
||||
// If there's a client ID from the response, end the task successfully
|
||||
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
||||
UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId")
|
||||
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
||||
|
||||
try await getTokens(deviceCode: deviceCode)
|
||||
|
||||
return
|
||||
} else {
|
||||
try await Task.sleep(seconds: 5)
|
||||
count += 1
|
||||
}
|
||||
while count < 12 {
|
||||
if Task.isCancelled {
|
||||
throw DebridError.AuthQuery(description: "Token request cancelled.")
|
||||
}
|
||||
|
||||
throw RealDebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// We don't care if this fails
|
||||
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
||||
|
||||
// If there's a client ID from the response, end the task successfully
|
||||
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
||||
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
|
||||
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
||||
|
||||
try await getApiTokens(deviceCode: deviceCode)
|
||||
|
||||
return
|
||||
} else {
|
||||
try await Task.sleep(seconds: 5)
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
if case let .failure(error) = await authTask?.result {
|
||||
throw error
|
||||
}
|
||||
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
||||
}
|
||||
|
||||
// Fetch all tokens for the user and store in keychain
|
||||
public func getTokens(deviceCode: String) async throws {
|
||||
// Fetch all tokens for the user and store in FerriteKeychain.shared
|
||||
func getApiTokens(deviceCode: String) async throws {
|
||||
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
||||
throw RealDebridError.EmptyData
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
|
||||
throw RealDebridError.EmptyData
|
||||
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
|
||||
|
|
@ -131,20 +173,20 @@ public class RealDebrid {
|
|||
|
||||
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
|
||||
|
||||
keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
||||
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
||||
FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
||||
FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
||||
|
||||
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
|
||||
UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
||||
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
||||
}
|
||||
|
||||
public func fetchToken() async -> String? {
|
||||
func getToken() async -> String? {
|
||||
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
||||
|
||||
if Date().timeIntervalSince1970 > accessTokenStamp {
|
||||
do {
|
||||
if let refreshToken = keychain.get("RealDebrid.RefreshToken") {
|
||||
try await getTokens(deviceCode: refreshToken)
|
||||
if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
|
||||
try await getApiTokens(deviceCode: refreshToken)
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
|
|
@ -152,29 +194,43 @@ public class RealDebrid {
|
|||
}
|
||||
}
|
||||
|
||||
return keychain.get("RealDebrid.AccessToken")
|
||||
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||
}
|
||||
|
||||
public func deleteTokens() async throws {
|
||||
keychain.delete("RealDebrid.RefreshToken")
|
||||
keychain.delete("RealDebrid.ClientSecret")
|
||||
UserDefaults.standard.removeObject(forKey: "RealDebrid.ClientId")
|
||||
UserDefaults.standard.removeObject(forKey: "RealDebrid.AccessTokenStamp")
|
||||
// Adds a manual API key instead of web auth
|
||||
// Clear out existing refresh tokens and timestamps
|
||||
func setApiKey(_ key: String) {
|
||||
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
|
||||
|
||||
UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey")
|
||||
}
|
||||
|
||||
// Deletes tokens from device and RD's servers
|
||||
func logout() async {
|
||||
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
|
||||
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
|
||||
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
|
||||
|
||||
// Run the request, doesn't matter if it fails
|
||||
if let token = keychain.get("RealDebrid.AccessToken") {
|
||||
if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
_ = try? await URLSession.shared.data(for: request)
|
||||
|
||||
keychain.delete("RealDebrid.AccessToken")
|
||||
FerriteKeychain.shared.delete("RealDebrid.AccessToken")
|
||||
await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Common request
|
||||
|
||||
// Wrapper request function which matches the responses and returns data
|
||||
@discardableResult public func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||
guard let token = await fetchToken() else {
|
||||
throw RealDebridError.InvalidToken
|
||||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||
guard let token = await getToken() else {
|
||||
throw DebridError.InvalidToken
|
||||
}
|
||||
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
|
@ -182,97 +238,118 @@ public class RealDebrid {
|
|||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw RealDebridError.FailedRequest(description: "No HTTP response given")
|
||||
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||
}
|
||||
|
||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||
return data
|
||||
} else if response.statusCode == 401 {
|
||||
try await deleteTokens()
|
||||
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
|
||||
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
|
||||
} else {
|
||||
throw RealDebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if the magnet is streamable on RD
|
||||
// Currently does not work for batch links
|
||||
public func instantAvailability(magnetHashes: [String]) async throws -> [RealDebridIA] {
|
||||
var availableHashes: [RealDebridIA] = []
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnetHashes.joined(separator: "/"))")!)
|
||||
// MARK: - Instant availability
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
// Post-API changes
|
||||
// Use user magnets to check for IA instead
|
||||
func instantAvailability(magnets: [Magnet]) async throws {
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
// Does not account for torrent packs at the moment
|
||||
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
|
||||
|
||||
for (hash, response) in rawResponseDict {
|
||||
guard let data = response.data else {
|
||||
continue
|
||||
}
|
||||
|
||||
if data.rd.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
// Is this a batch
|
||||
if data.rd.count > 1 || data.rd[0].count > 1 {
|
||||
// Batch array
|
||||
let batches = data.rd.map { fileDict in
|
||||
let batchFiles: [RealDebridIABatchFile] = fileDict.map { key, value in
|
||||
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
|
||||
RealDebridIABatchFile(id: Int(key)!, fileName: value.filename)
|
||||
}.sorted(by: { $0.id < $1.id })
|
||||
|
||||
return RealDebridIABatch(files: batchFiles)
|
||||
let sendMagnets = magnets.filter { magnet in
|
||||
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||
IAValues.remove(at: IAIndex)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
// RD files array
|
||||
// Possibly sort this in the future, but not sure how at the moment
|
||||
var files: [RealDebridIAFile] = []
|
||||
|
||||
for index in batches.indices {
|
||||
let batchFiles = batches[index].files
|
||||
|
||||
for batchFileIndex in batchFiles.indices {
|
||||
let batchFile = batchFiles[batchFileIndex]
|
||||
|
||||
if !files.contains(where: { $0.name == batchFile.fileName }) {
|
||||
files.append(
|
||||
RealDebridIAFile(
|
||||
name: batchFile.fileName,
|
||||
batchIndex: index,
|
||||
batchFileIndex: batchFileIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TTL: 5 minutes
|
||||
availableHashes.append(
|
||||
RealDebridIA(
|
||||
hash: hash,
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: files,
|
||||
batches: batches
|
||||
)
|
||||
)
|
||||
} else {
|
||||
availableHashes.append(
|
||||
RealDebridIA(
|
||||
hash: hash,
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the user magnets to the latest version
|
||||
try await getUserMagnets()
|
||||
|
||||
for cloudMagnet in cloudMagnets {
|
||||
if cachedStatus.contains(cloudMagnet.status),
|
||||
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||
{
|
||||
IAValues.append(
|
||||
DebridIA(
|
||||
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||
files: []
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableHashes
|
||||
// MARK: - Downloading
|
||||
|
||||
// Wrapper function to fetch a download link from the API
|
||||
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||
var selectedMagnetId = ""
|
||||
|
||||
do {
|
||||
// Don't queue a new job if the magnet already exists in the user's library
|
||||
if let existingCloudMagnet = cloudMagnets.first(where: {
|
||||
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||
}) {
|
||||
selectedMagnetId = existingCloudMagnet.id
|
||||
} else {
|
||||
selectedMagnetId = try await addMagnet(magnet: magnet)
|
||||
|
||||
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
|
||||
}
|
||||
|
||||
let response = try await torrentInfo(debridID: selectedMagnetId)
|
||||
let filteredFiles = response.files.filter { $0.selected == 1 }
|
||||
|
||||
// Need to return this to the user
|
||||
if filteredFiles.count > 1, iaFile == nil {
|
||||
var copiedIA = ia
|
||||
|
||||
copiedIA?.files = response.files.enumerated().compactMap { index, file in
|
||||
DebridIAFile(
|
||||
id: index,
|
||||
name: file.path,
|
||||
streamUrlString: response.links[safe: index]
|
||||
)
|
||||
}
|
||||
|
||||
return (nil, copiedIA)
|
||||
}
|
||||
|
||||
// RealDebrid has 1 as the first ID for a file
|
||||
let selectedFileId = iaFile?.id ?? 1
|
||||
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
|
||||
|
||||
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
|
||||
return (restrictedFile, nil)
|
||||
} catch {
|
||||
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
|
||||
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
|
||||
}
|
||||
|
||||
// Re-raise the error to the calling function
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a magnet link to the user's RD account
|
||||
public func addMagnet(magnetLink: String) async throws -> String {
|
||||
func addMagnet(magnet: Magnet) async throws -> String {
|
||||
guard let magnetLink = magnet.link else {
|
||||
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
|
@ -289,7 +366,7 @@ public class RealDebrid {
|
|||
}
|
||||
|
||||
// Queues the magnet link for downloading
|
||||
public func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
||||
func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
|
@ -309,48 +386,31 @@ public class RealDebrid {
|
|||
}
|
||||
|
||||
// Gets the info of a torrent from a given ID
|
||||
public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String {
|
||||
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
|
||||
|
||||
// Let the user know if a torrent is downloading
|
||||
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
|
||||
return torrentLink
|
||||
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
|
||||
throw RealDebridError.EmptyTorrents
|
||||
} else {
|
||||
throw RealDebridError.EmptyData
|
||||
// Let the user know if a magnet is downloading
|
||||
switch rawResponse.status {
|
||||
case "downloaded":
|
||||
return rawResponse
|
||||
case "downloading", "queued":
|
||||
throw DebridError.IsCaching
|
||||
default:
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
}
|
||||
|
||||
// Gets the user's torrent library
|
||||
public func userTorrents() async throws -> [UserTorrentsResponse] {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
|
||||
|
||||
return rawResponse
|
||||
}
|
||||
|
||||
// Deletes a torrent download from RD
|
||||
public func deleteTorrent(debridID: String) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!)
|
||||
request.httpMethod = "DELETE"
|
||||
|
||||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
|
||||
// Downloads link from selectFiles for playback
|
||||
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
|
||||
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var bodyComponents = URLComponents()
|
||||
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
|
||||
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)]
|
||||
|
||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||
|
||||
|
|
@ -360,13 +420,68 @@ public class RealDebrid {
|
|||
return rawResponse.download
|
||||
}
|
||||
|
||||
// MARK: - Cloud methods
|
||||
|
||||
// Gets the user's cloud magnet library
|
||||
func getUserMagnets() async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
|
||||
cloudMagnets = rawResponse.map { response in
|
||||
DebridCloudMagnet(
|
||||
id: response.id,
|
||||
fileName: response.filename,
|
||||
status: response.status,
|
||||
hash: response.hash,
|
||||
links: [response.id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes a magnet download from RD
|
||||
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||
let deleteId: String
|
||||
|
||||
if let cloudMagnetId {
|
||||
deleteId = cloudMagnetId
|
||||
} else {
|
||||
// Refresh the user magnet list
|
||||
// The first file is the currently caching one
|
||||
let _ = try await getUserMagnets()
|
||||
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
|
||||
throw DebridError.EmptyUserMagnets
|
||||
}
|
||||
|
||||
deleteId = firstCloudMagnet.id
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
|
||||
request.httpMethod = "DELETE"
|
||||
|
||||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
|
||||
// Gets the user's downloads
|
||||
public func userDownloads() async throws -> [UserDownloadsResponse] {
|
||||
func getUserDownloads() async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
|
||||
|
||||
let data = try await performRequest(request: &request, requestName: #function)
|
||||
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
|
||||
cloudDownloads = rawResponse.map { response in
|
||||
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
|
||||
}
|
||||
}
|
||||
|
||||
return rawResponse
|
||||
// Not used
|
||||
func checkUserDownloads(link: String) -> String? {
|
||||
link
|
||||
}
|
||||
|
||||
func deleteUserDownload(downloadId: String) async throws {
|
||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
|
||||
request.httpMethod = "DELETE"
|
||||
|
||||
try await performRequest(request: &request, requestName: #function)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,30 +0,0 @@
|
|||
//
|
||||
// Application.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/16/22.
|
||||
//
|
||||
// A thread-safe UIApplication alternative for specifying app properties
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public class Application {
|
||||
static let shared = Application()
|
||||
|
||||
var appVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
|
||||
}
|
||||
|
||||
var appBuild: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
|
||||
}
|
||||
|
||||
var buildType: String {
|
||||
#if DEBUG
|
||||
return "Debug"
|
||||
#else
|
||||
return "Release"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
|
|
@ -10,16 +10,4 @@ import CoreData
|
|||
import Foundation
|
||||
|
||||
@objc(Bookmark)
|
||||
public class Bookmark: NSManagedObject {
|
||||
func toSearchResult() -> SearchResult {
|
||||
SearchResult(
|
||||
title: title,
|
||||
source: source,
|
||||
size: size,
|
||||
magnetLink: magnetLink,
|
||||
magnetHash: magnetHash,
|
||||
seeders: seeders,
|
||||
leechers: leechers
|
||||
)
|
||||
}
|
||||
}
|
||||
class Bookmark: NSManagedObject {}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public extension Bookmark {
|
||||
extension Bookmark {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
|
||||
NSFetchRequest<Bookmark>(entityName: "Bookmark")
|
||||
}
|
||||
|
|
@ -22,6 +22,17 @@ public extension Bookmark {
|
|||
@NSManaged var source: String
|
||||
@NSManaged var title: String?
|
||||
@NSManaged var orderNum: Int16
|
||||
|
||||
func toSearchResult() -> SearchResult {
|
||||
SearchResult(
|
||||
title: title,
|
||||
source: source,
|
||||
size: size,
|
||||
magnet: Magnet(hash: magnetHash, link: magnetLink),
|
||||
seeders: seeders,
|
||||
leechers: leechers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Bookmark: Identifiable {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -10,4 +10,4 @@ import CoreData
|
|||
import Foundation
|
||||
|
||||
@objc(Source)
|
||||
public class Source: NSManagedObject {}
|
||||
public class Source: NSManagedObject, Plugin {}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Source+CoreDataProperties.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 8/3/22.
|
||||
// Created by Brian Dashore on 2/6/23.
|
||||
//
|
||||
//
|
||||
|
||||
|
|
@ -15,9 +15,10 @@ public extension Source {
|
|||
}
|
||||
|
||||
@NSManaged var id: UUID
|
||||
@NSManaged var baseUrl: String?
|
||||
@NSManaged var about: String?
|
||||
@NSManaged var website: String?
|
||||
@NSManaged var dynamicWebsite: Bool
|
||||
@NSManaged var fallbackUrls: [String]?
|
||||
@NSManaged var dynamicBaseUrl: Bool
|
||||
@NSManaged var enabled: Bool
|
||||
@NSManaged var name: String
|
||||
@NSManaged var author: String
|
||||
|
|
@ -29,6 +30,45 @@ public extension Source {
|
|||
@NSManaged var jsonParser: SourceJsonParser?
|
||||
@NSManaged var api: SourceApi?
|
||||
@NSManaged var trackers: [String]?
|
||||
@NSManaged var tags: NSOrderedSet?
|
||||
|
||||
func getTags() -> [PluginTagJson] {
|
||||
tagArray.map { $0.toJson() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for tags
|
||||
|
||||
public extension Source {
|
||||
@objc(insertObject:inTagsAtIndex:)
|
||||
@NSManaged func insertIntoTags(_ value: PluginTag, at idx: Int)
|
||||
|
||||
@objc(removeObjectFromTagsAtIndex:)
|
||||
@NSManaged func removeFromTags(at idx: Int)
|
||||
|
||||
@objc(insertTags:atIndexes:)
|
||||
@NSManaged func insertIntoTags(_ values: [PluginTag], at indexes: NSIndexSet)
|
||||
|
||||
@objc(removeTagsAtIndexes:)
|
||||
@NSManaged func removeFromTags(at indexes: NSIndexSet)
|
||||
|
||||
@objc(replaceObjectInTagsAtIndex:withObject:)
|
||||
@NSManaged func replaceTags(at idx: Int, with value: PluginTag)
|
||||
|
||||
@objc(replaceTagsAtIndexes:withTags:)
|
||||
@NSManaged func replaceTags(at indexes: NSIndexSet, with values: [PluginTag])
|
||||
|
||||
@objc(addTagsObject:)
|
||||
@NSManaged func addToTags(_ value: PluginTag)
|
||||
|
||||
@objc(removeTagsObject:)
|
||||
@NSManaged func removeFromTags(_ value: PluginTag)
|
||||
|
||||
@objc(addTags:)
|
||||
@NSManaged func addToTags(_ values: NSOrderedSet)
|
||||
|
||||
@objc(removeTags:)
|
||||
@NSManaged func removeFromTags(_ values: NSOrderedSet)
|
||||
}
|
||||
|
||||
extension Source: Identifiable {}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ public extension SourceHtmlParser {
|
|||
}
|
||||
|
||||
@NSManaged var rows: String
|
||||
@NSManaged var searchUrl: String
|
||||
@NSManaged var searchUrl: String?
|
||||
@NSManaged var request: SourceRequest?
|
||||
@NSManaged var magnetHash: SourceMagnetHash?
|
||||
@NSManaged var magnetLink: SourceMagnetLink?
|
||||
@NSManaged var parentSource: Source?
|
||||
@NSManaged var seedLeech: SourceSeedLeech?
|
||||
@NSManaged var size: SourceSize?
|
||||
@NSManaged var title: SourceTitle?
|
||||
@NSManaged var subName: SourceSubName?
|
||||
}
|
||||
|
||||
extension SourceHtmlParser: Identifiable {}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@ public extension SourceJsonParser {
|
|||
@NSManaged var results: String?
|
||||
@NSManaged var subResults: String?
|
||||
@NSManaged var searchUrl: String
|
||||
@NSManaged var request: SourceRequest?
|
||||
@NSManaged var magnetHash: SourceMagnetHash?
|
||||
@NSManaged var magnetLink: SourceMagnetLink?
|
||||
@NSManaged var parentSource: Source?
|
||||
@NSManaged var seedLeech: SourceSeedLeech?
|
||||
@NSManaged var size: SourceSize?
|
||||
@NSManaged var title: SourceTitle?
|
||||
@NSManaged var subName: SourceSubName?
|
||||
}
|
||||
|
||||
extension SourceJsonParser: Identifiable {}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
//
|
||||
// SourceList+CoreDataClass.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/30/22.
|
||||
//
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
@objc(SourceList)
|
||||
public class SourceList: NSManagedObject {}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
//
|
||||
// SourceList+CoreDataProperties.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/30/22.
|
||||
//
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public extension SourceList {
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<SourceList> {
|
||||
NSFetchRequest<SourceList>(entityName: "SourceList")
|
||||
}
|
||||
|
||||
@NSManaged var id: UUID
|
||||
@NSManaged var author: String
|
||||
@NSManaged var name: String
|
||||
@NSManaged var urlString: String
|
||||
}
|
||||
|
||||
extension SourceList: Identifiable {}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -17,12 +17,14 @@ public extension SourceRssParser {
|
|||
@NSManaged var items: String
|
||||
@NSManaged var rssUrl: String?
|
||||
@NSManaged var searchUrl: String
|
||||
@NSManaged var request: SourceRequest?
|
||||
@NSManaged var magnetHash: SourceMagnetHash?
|
||||
@NSManaged var magnetLink: SourceMagnetLink?
|
||||
@NSManaged var parentSource: Source?
|
||||
@NSManaged var seedLeech: SourceSeedLeech?
|
||||
@NSManaged var size: SourceSize?
|
||||
@NSManaged var title: SourceTitle?
|
||||
@NSManaged var subName: SourceSubName?
|
||||
}
|
||||
|
||||
extension SourceRssParser: Identifiable {}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>FerriteDB.xcdatamodel</string>
|
||||
<string>FerriteDB_v2.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -91,7 +91,7 @@ struct PersistenceController {
|
|||
save()
|
||||
}
|
||||
|
||||
func createBookmark(_ bookmarkJson: BookmarkJson) {
|
||||
func createBookmark(_ bookmarkJson: BookmarkJson, performSave: Bool) {
|
||||
let bookmarkRequest = Bookmark.fetchRequest()
|
||||
bookmarkRequest.predicate = NSPredicate(
|
||||
format: "source == %@ AND title == %@ AND magnetLink == %@",
|
||||
|
|
@ -112,31 +112,32 @@ struct PersistenceController {
|
|||
newBookmark.magnetLink = bookmarkJson.magnetLink
|
||||
newBookmark.seeders = bookmarkJson.seeders
|
||||
newBookmark.leechers = bookmarkJson.leechers
|
||||
|
||||
if performSave {
|
||||
save(backgroundContext)
|
||||
}
|
||||
}
|
||||
|
||||
func createHistory(entryJson: HistoryEntryJson, date: Double?) {
|
||||
func createHistory(_ entryJson: HistoryEntryJson, performSave: Bool, isBackup: Bool = false, date: Double? = nil) {
|
||||
let historyDate = date.map { Date(timeIntervalSince1970: $0) } ?? Date()
|
||||
let historyDateString = DateFormatter.historyDateFormatter.string(from: historyDate)
|
||||
|
||||
let newHistoryEntry = HistoryEntry(context: backgroundContext)
|
||||
|
||||
newHistoryEntry.source = entryJson.source
|
||||
newHistoryEntry.name = entryJson.name
|
||||
newHistoryEntry.url = entryJson.url
|
||||
newHistoryEntry.subName = entryJson.subName
|
||||
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
|
||||
|
||||
let historyRequest = History.fetchRequest()
|
||||
historyRequest.predicate = NSPredicate(format: "dateString = %@", historyDateString)
|
||||
var existingHistory: History?
|
||||
|
||||
// Safely add entries to a parent history if it exists
|
||||
if var histories = try? backgroundContext.fetch(historyRequest) {
|
||||
for (i, history) in histories.enumerated() {
|
||||
let existingEntries = history.entryArray.filter { $0.url == newHistoryEntry.url && $0.name == newHistoryEntry.name }
|
||||
let existingEntries = history.entryArray.filter { $0.url == entryJson.url && $0.name == entryJson.name }
|
||||
|
||||
// Maybe add !isBackup here
|
||||
if !existingEntries.isEmpty {
|
||||
for entry in existingEntries {
|
||||
PersistenceController.shared.delete(entry, context: backgroundContext)
|
||||
if isBackup {
|
||||
continue
|
||||
} else {
|
||||
for entry in existingEntries {
|
||||
PersistenceController.shared.delete(entry, context: backgroundContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,13 +147,24 @@ struct PersistenceController {
|
|||
}
|
||||
}
|
||||
|
||||
newHistoryEntry.parentHistory = histories.first ?? History(context: backgroundContext)
|
||||
} else {
|
||||
newHistoryEntry.parentHistory = History(context: backgroundContext)
|
||||
existingHistory = histories.first
|
||||
}
|
||||
|
||||
let newHistoryEntry = HistoryEntry(context: backgroundContext)
|
||||
|
||||
newHistoryEntry.source = entryJson.source
|
||||
newHistoryEntry.name = entryJson.name
|
||||
newHistoryEntry.url = entryJson.url
|
||||
newHistoryEntry.subName = entryJson.subName
|
||||
newHistoryEntry.timeStamp = entryJson.timeStamp ?? Date().timeIntervalSince1970
|
||||
|
||||
newHistoryEntry.parentHistory = existingHistory ?? History(context: backgroundContext)
|
||||
newHistoryEntry.parentHistory?.dateString = historyDateString
|
||||
newHistoryEntry.parentHistory?.date = historyDate
|
||||
|
||||
if performSave {
|
||||
save(backgroundContext)
|
||||
}
|
||||
}
|
||||
|
||||
func getHistoryPredicate(range: HistoryDeleteRange) -> NSPredicate? {
|
||||
|
|
@ -196,8 +208,7 @@ struct PersistenceController {
|
|||
return predicate
|
||||
}
|
||||
|
||||
// Always use the background context to batch delete
|
||||
// Merge changes into both contexts to update views
|
||||
// Wrapper to batch delete history objects
|
||||
func batchDeleteHistory(range: HistoryDeleteRange) throws {
|
||||
let predicate = getHistoryPredicate(range: range)
|
||||
|
||||
|
|
@ -209,6 +220,13 @@ struct PersistenceController {
|
|||
throw HistoryDeleteError.noDate("No history date range was provided and you weren't trying to clear everything! Try relaunching the app?")
|
||||
}
|
||||
|
||||
try batchDelete("History", predicate: predicate)
|
||||
}
|
||||
|
||||
// Always use the background context to batch delete
|
||||
// Merge changes into both contexts to update views
|
||||
func batchDelete(_ entity: String, predicate: NSPredicate? = nil) throws {
|
||||
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entity)
|
||||
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||
|
||||
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||
|
|
|
|||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
// A static DateFormatter is better than initializing new ones
|
||||
extension DateFormatter {
|
||||
static let historyDateFormatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,21 @@
|
|||
//
|
||||
// Created by Brian Dashore on 8/31/22.
|
||||
//
|
||||
// From https://stackoverflow.com/a/59307884
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
// From https://www.hackingwithswift.com/example-code/strings/how-to-capitalize-the-first-letter-of-a-string
|
||||
func capitalizingFirstLetter() -> String {
|
||||
prefix(1).capitalized + dropFirst()
|
||||
}
|
||||
|
||||
mutating func capitalizeFirstLetter() {
|
||||
self = capitalizingFirstLetter()
|
||||
}
|
||||
|
||||
// From https://stackoverflow.com/a/59307884
|
||||
private func compare(toVersion targetVersion: String) -> ComparisonResult {
|
||||
let versionDelimiter = "."
|
||||
var result: ComparisonResult = .orderedSame
|
||||
|
|
|
|||
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 }
|
||||
}
|
||||
}
|
||||
14
Ferrite/Extensions/UIDevice.swift
Normal file
14
Ferrite/Extensions/UIDevice.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// UIDevice.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/16/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIDevice {
|
||||
var hasNotch: Bool {
|
||||
UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
|
||||
}
|
||||
}
|
||||
|
|
@ -5,45 +5,29 @@
|
|||
// Created by Brian Dashore on 8/15/22.
|
||||
//
|
||||
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
// MARK: Custom introspect functions
|
||||
// Modifies properties of a view. Works the same way as a ViewModifier
|
||||
// From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
|
||||
func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
|
||||
var result = self
|
||||
body(&result)
|
||||
|
||||
func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View {
|
||||
inject(UIKitIntrospectionView(
|
||||
selector: { introspectionView in
|
||||
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
|
||||
return nil
|
||||
}
|
||||
return Introspect.previousSibling(containing: UICollectionView.self, from: viewHost)
|
||||
},
|
||||
customize: customize
|
||||
))
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: Modifiers
|
||||
|
||||
func conditionalContextMenu(id: some Hashable,
|
||||
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
|
||||
{
|
||||
modifier(ConditionalContextMenu(internalContent, id: id))
|
||||
}
|
||||
|
||||
func conditionalId(_ id: some Hashable) -> some View {
|
||||
modifier(ConditionalId(id: id))
|
||||
}
|
||||
|
||||
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
|
||||
modifier(DisabledAppearance(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
|
||||
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
|
||||
}
|
||||
|
||||
func disableInteraction(_ disabled: Bool) -> some View {
|
||||
modifier(DisableInteraction(disabled: disabled))
|
||||
modifier(DisableInteractionModifier(disabled: disabled))
|
||||
}
|
||||
|
||||
func inlinedList() -> some View {
|
||||
modifier(InlinedList())
|
||||
func inlinedList(inset: CGFloat) -> some View {
|
||||
modifier(InlinedListModifier(inset: inset))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,27 +12,27 @@ struct FerriteApp: App {
|
|||
let persistenceController = PersistenceController.shared
|
||||
|
||||
@StateObject var scrapingModel: ScrapingViewModel = .init()
|
||||
@StateObject var toastModel: ToastViewModel = .init()
|
||||
@StateObject var logManager: LoggingManager = .init()
|
||||
@StateObject var debridManager: DebridManager = .init()
|
||||
@StateObject var navModel: NavigationViewModel = .init()
|
||||
@StateObject var sourceManager: SourceManager = .init()
|
||||
@StateObject var pluginManager: PluginManager = .init()
|
||||
@StateObject var backupManager: BackupManager = .init()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainView()
|
||||
.onAppear {
|
||||
scrapingModel.toastModel = toastModel
|
||||
debridManager.toastModel = toastModel
|
||||
sourceManager.toastModel = toastModel
|
||||
backupManager.toastModel = toastModel
|
||||
navModel.toastModel = toastModel
|
||||
scrapingModel.logManager = logManager
|
||||
debridManager.logManager = logManager
|
||||
pluginManager.logManager = logManager
|
||||
backupManager.logManager = logManager
|
||||
navModel.logManager = logManager
|
||||
}
|
||||
.environmentObject(debridManager)
|
||||
.environmentObject(scrapingModel)
|
||||
.environmentObject(toastModel)
|
||||
.environmentObject(logManager)
|
||||
.environmentObject(navModel)
|
||||
.environmentObject(sourceManager)
|
||||
.environmentObject(pluginManager)
|
||||
.environmentObject(backupManager)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,19 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Ferrite</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ferrite</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,16 +7,32 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct Backup: Codable {
|
||||
// Version is optional until v1 is phased out
|
||||
struct Backup: Codable {
|
||||
let version: Int?
|
||||
var bookmarks: [BookmarkJson]?
|
||||
var history: [HistoryJson]?
|
||||
var sourceNames: [String]?
|
||||
var sourceLists: [SourceListBackupJson]?
|
||||
var actionNames: [String]?
|
||||
var pluginListUrls: [String]?
|
||||
|
||||
// MARK: Remove once v1 backups are unsupported
|
||||
|
||||
var sourceLists: [PluginListBackupJson]?
|
||||
}
|
||||
|
||||
// MARK: - CoreData translation
|
||||
|
||||
typealias BookmarkJson = SearchResult
|
||||
// Don't typealias to search result as this is a reflection of CoreData's struct
|
||||
struct BookmarkJson: Codable {
|
||||
let title: String?
|
||||
let source: String
|
||||
let size: String?
|
||||
let magnetLink: String?
|
||||
let magnetHash: String?
|
||||
let seeders: String?
|
||||
let leechers: String?
|
||||
}
|
||||
|
||||
// Date is an epoch timestamp
|
||||
struct HistoryJson: Codable {
|
||||
|
|
@ -26,15 +42,15 @@ struct HistoryJson: Codable {
|
|||
}
|
||||
|
||||
struct HistoryEntryJson: Codable {
|
||||
let name: String
|
||||
let subName: String?
|
||||
let url: String
|
||||
let timeStamp: Double?
|
||||
var name: String? = nil
|
||||
var subName: String? = nil
|
||||
var url: String? = nil
|
||||
var timeStamp: Double? = nil
|
||||
let source: String?
|
||||
}
|
||||
|
||||
// Differs from SourceListJson
|
||||
struct SourceListBackupJson: Codable {
|
||||
// Differs from PluginListJson
|
||||
struct PluginListBackupJson: Codable {
|
||||
let name: String
|
||||
let author: String
|
||||
let id: String
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
|
|
@ -7,12 +7,14 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct GithubRelease: Codable, Hashable, Sendable {
|
||||
let htmlUrl: String
|
||||
let tagName: String
|
||||
extension Github {
|
||||
struct Release: Codable, Hashable, Sendable {
|
||||
let htmlUrl: String
|
||||
let tagName: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case htmlUrl = "html_url"
|
||||
case tagName = "tag_name"
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case htmlUrl = "html_url"
|
||||
case tagName = "tag_name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,187 +8,170 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
// MARK: - device code endpoint
|
||||
extension RealDebrid {
|
||||
// MARK: - device code endpoint
|
||||
|
||||
public struct DeviceCodeResponse: Codable, Sendable {
|
||||
let deviceCode, userCode: String
|
||||
let interval, expiresIn: Int
|
||||
let verificationURL, directVerificationURL: String
|
||||
struct DeviceCodeResponse: Codable, Sendable {
|
||||
let deviceCode, userCode: String
|
||||
let interval, expiresIn: Int
|
||||
let verificationURL, directVerificationURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case deviceCode = "device_code"
|
||||
case userCode = "user_code"
|
||||
case interval
|
||||
case expiresIn = "expires_in"
|
||||
case verificationURL = "verification_url"
|
||||
case directVerificationURL = "direct_verification_url"
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case deviceCode = "device_code"
|
||||
case userCode = "user_code"
|
||||
case interval
|
||||
case expiresIn = "expires_in"
|
||||
case verificationURL = "verification_url"
|
||||
case directVerificationURL = "direct_verification_url"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - device credentials endpoint
|
||||
// MARK: - device credentials endpoint
|
||||
|
||||
public struct DeviceCredentialsResponse: Codable, Sendable {
|
||||
let clientID, clientSecret: String?
|
||||
struct DeviceCredentialsResponse: Codable, Sendable {
|
||||
let clientID, clientSecret: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case clientID = "client_id"
|
||||
case clientSecret = "client_secret"
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case clientID = "client_id"
|
||||
case clientSecret = "client_secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - token endpoint
|
||||
// MARK: - token endpoint
|
||||
|
||||
public struct TokenResponse: Codable, Sendable {
|
||||
let accessToken: String
|
||||
let expiresIn: Int
|
||||
let refreshToken, tokenType: String
|
||||
struct TokenResponse: Codable, Sendable {
|
||||
let accessToken: String
|
||||
let expiresIn: Int
|
||||
let refreshToken, tokenType: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case expiresIn = "expires_in"
|
||||
case refreshToken = "refresh_token"
|
||||
case tokenType = "token_type"
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case expiresIn = "expires_in"
|
||||
case refreshToken = "refresh_token"
|
||||
case tokenType = "token_type"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - instantAvailability endpoint
|
||||
// MARK: - instantAvailability endpoint
|
||||
|
||||
// Thanks Skitty!
|
||||
public struct InstantAvailabilityResponse: Codable, Sendable {
|
||||
var data: InstantAvailabilityData?
|
||||
// Thanks Skitty!
|
||||
struct InstantAvailabilityResponse: Codable, Sendable {
|
||||
var data: InstantAvailabilityData?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if let data = try? container.decode(InstantAvailabilityData.self) {
|
||||
self.data = data
|
||||
if let data = try? container.decode(InstantAvailabilityData.self) {
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InstantAvailabilityData: Codable, Sendable {
|
||||
var rd: [[String: InstantAvailabilityInfo]]
|
||||
}
|
||||
|
||||
struct InstantAvailabilityInfo: Codable, Sendable {
|
||||
var filename: String
|
||||
var filesize: Int
|
||||
}
|
||||
|
||||
// MARK: - Instant Availability batch structures (used for client-side conversion)
|
||||
|
||||
struct IABatch: Codable, Hashable, Sendable {
|
||||
let files: [IABatchFile]
|
||||
}
|
||||
|
||||
struct IABatchFile: Codable, Hashable, Sendable {
|
||||
let id: Int
|
||||
let fileName: String
|
||||
}
|
||||
|
||||
// MARK: - addMagnet endpoint
|
||||
|
||||
struct AddMagnetResponse: Codable, Sendable {
|
||||
let id: String
|
||||
let uri: String
|
||||
}
|
||||
|
||||
// MARK: - torrentInfo endpoint
|
||||
|
||||
struct TorrentInfoResponse: Codable, Sendable {
|
||||
let id, filename, originalFilename, hash: String
|
||||
let bytes, originalBytes: Int
|
||||
let host: String
|
||||
let split, progress: Int
|
||||
let status, added: String
|
||||
let files: [TorrentInfoFile]
|
||||
let links: [String]
|
||||
let ended: String?
|
||||
let speed: Int?
|
||||
let seeders: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename
|
||||
case originalFilename = "original_filename"
|
||||
case hash, bytes
|
||||
case originalBytes = "original_bytes"
|
||||
case host, split, progress, status, added, files, links, ended, speed, seeders
|
||||
}
|
||||
}
|
||||
|
||||
struct TorrentInfoFile: Codable, Sendable {
|
||||
let id: Int
|
||||
let path: String
|
||||
let bytes, selected: Int
|
||||
}
|
||||
|
||||
struct UserTorrentsResponse: Codable, Hashable, Sendable {
|
||||
let id, filename, hash: String
|
||||
let bytes: Int
|
||||
let host: String
|
||||
let split, progress: Int
|
||||
let status, added: String
|
||||
let links: [String]
|
||||
let speed, seeders: Int?
|
||||
let ended: String?
|
||||
}
|
||||
|
||||
// MARK: - unrestrictLink endpoint
|
||||
|
||||
struct UnrestrictLinkResponse: Codable, Sendable {
|
||||
let id, filename: String
|
||||
let mimeType: String?
|
||||
let filesize: Int
|
||||
let link: String
|
||||
let host: String
|
||||
let hostIcon: String
|
||||
let chunks, crc: Int
|
||||
let download: String
|
||||
let streamable: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename, mimeType, filesize, link, host
|
||||
case hostIcon = "host_icon"
|
||||
case chunks, crc, download, streamable
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User downloads list
|
||||
|
||||
struct UserDownloadsResponse: Codable, Hashable, Sendable {
|
||||
let id, filename: String
|
||||
let mimeType: String?
|
||||
let filesize: Int
|
||||
let link: String
|
||||
let host: String
|
||||
let hostIcon: String
|
||||
let chunks: Int
|
||||
let download: String
|
||||
let streamable: Int
|
||||
let generated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename, mimeType, filesize, link, host
|
||||
case hostIcon = "host_icon"
|
||||
case chunks, download, streamable, generated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instant Availability client side structures
|
||||
|
||||
struct InstantAvailabilityData: Codable, Sendable {
|
||||
var rd: [[String: InstantAvailabilityInfo]]
|
||||
}
|
||||
|
||||
struct InstantAvailabilityInfo: Codable, Sendable {
|
||||
var filename: String
|
||||
var filesize: Int
|
||||
}
|
||||
|
||||
public struct RealDebridIA: Codable, Hashable, Sendable {
|
||||
let hash: String
|
||||
let expiryTimeStamp: Double
|
||||
var files: [RealDebridIAFile] = []
|
||||
var batches: [RealDebridIABatch] = []
|
||||
}
|
||||
|
||||
public struct RealDebridIABatch: Codable, Hashable, Sendable {
|
||||
let files: [RealDebridIABatchFile]
|
||||
}
|
||||
|
||||
public struct RealDebridIABatchFile: Codable, Hashable, Sendable {
|
||||
let id: Int
|
||||
let fileName: String
|
||||
}
|
||||
|
||||
public struct RealDebridIAFile: Codable, Hashable, Sendable {
|
||||
let name: String
|
||||
let batchIndex: Int
|
||||
let batchFileIndex: Int
|
||||
}
|
||||
|
||||
public enum RealDebridIAStatus: Codable, Hashable, Sendable {
|
||||
case full
|
||||
case partial
|
||||
case none
|
||||
}
|
||||
|
||||
// MARK: - addMagnet endpoint
|
||||
|
||||
public struct AddMagnetResponse: Codable, Sendable {
|
||||
let id: String
|
||||
let uri: String
|
||||
}
|
||||
|
||||
// MARK: - torrentInfo endpoint
|
||||
|
||||
struct TorrentInfoResponse: Codable, Sendable {
|
||||
let id, filename, originalFilename, hash: String
|
||||
let bytes, originalBytes: Int
|
||||
let host: String
|
||||
let split, progress: Int
|
||||
let status, added: String
|
||||
let files: [TorrentInfoFile]
|
||||
let links: [String]
|
||||
let ended: String?
|
||||
let speed: Int?
|
||||
let seeders: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename
|
||||
case originalFilename = "original_filename"
|
||||
case hash, bytes
|
||||
case originalBytes = "original_bytes"
|
||||
case host, split, progress, status, added, files, links, ended, speed, seeders
|
||||
}
|
||||
}
|
||||
|
||||
struct TorrentInfoFile: Codable, Sendable {
|
||||
let id: Int
|
||||
let path: String
|
||||
let bytes, selected: Int
|
||||
}
|
||||
|
||||
public struct UserTorrentsResponse: Codable, Sendable {
|
||||
let id, filename, hash: String
|
||||
let bytes: Int
|
||||
let host: String
|
||||
let split, progress: Int
|
||||
let status, added: String
|
||||
let links: [String]
|
||||
let speed, seeders: Int?
|
||||
let ended: String?
|
||||
}
|
||||
|
||||
// MARK: - unrestrictLink endpoint
|
||||
|
||||
struct UnrestrictLinkResponse: Codable, Sendable {
|
||||
let id, filename: String
|
||||
let mimeType: String?
|
||||
let filesize: Int
|
||||
let link: String
|
||||
let host: String
|
||||
let hostIcon: String
|
||||
let chunks, crc: Int
|
||||
let download: String
|
||||
let streamable: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename, mimeType, filesize, link, host
|
||||
case hostIcon = "host_icon"
|
||||
case chunks, crc, download, streamable
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User downloads list
|
||||
|
||||
public struct UserDownloadsResponse: Codable, Sendable {
|
||||
let id, filename: String
|
||||
let mimeType: String?
|
||||
let filesize: Int
|
||||
let link: String
|
||||
let host: String
|
||||
let hostIcon: String
|
||||
let chunks: Int
|
||||
let download: String
|
||||
let streamable: Int
|
||||
let generated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, filename, mimeType, filesize, link, host
|
||||
case hostIcon = "host_icon"
|
||||
case chunks, download, streamable, generated
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,56 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct SearchResult: Hashable, Codable, Sendable {
|
||||
// A raw search result structure displayed on the UI
|
||||
struct SearchResult: Codable, Hashable, Sendable {
|
||||
let title: String?
|
||||
let source: String
|
||||
let size: String?
|
||||
let magnetLink: String?
|
||||
let magnetHash: String?
|
||||
let magnet: Magnet
|
||||
let seeders: String?
|
||||
let leechers: String?
|
||||
|
||||
// Converts size to a double
|
||||
func rawSize() -> Double? {
|
||||
guard let size else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let splitSize = size.split(separator: " ")
|
||||
|
||||
guard
|
||||
let bytesString = splitSize.first,
|
||||
let multipliedBytes = Double(bytesString),
|
||||
let units = splitSize.last
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch units.lowercased() {
|
||||
case "gb":
|
||||
return multipliedBytes * 1e9
|
||||
case "gib":
|
||||
return multipliedBytes * pow(1024, 3)
|
||||
case "mb":
|
||||
return multipliedBytes * 1e6
|
||||
case "mib":
|
||||
return multipliedBytes * pow(1024, 2)
|
||||
case "kb":
|
||||
return multipliedBytes * 1e3
|
||||
case "kib":
|
||||
return multipliedBytes * 1024
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScrapingViewModel {
|
||||
// Contains both search results and magnet links for scalability
|
||||
struct SearchRequestResult: Sendable {
|
||||
let results: [SearchResult]
|
||||
let magnets: [Magnet]
|
||||
}
|
||||
|
||||
struct ScrapingError: Error {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,18 @@
|
|||
// SettingsModels.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 8/11/22.
|
||||
// Created by Brian Dashore on 3/20/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DefaultMagnetActionType: Int, CaseIterable {
|
||||
// Let the user choose
|
||||
case none = 0
|
||||
enum DefaultAction: Codable, CaseIterable, Hashable {
|
||||
static var allCases: [DefaultAction] {
|
||||
[.none, .share, .kodi, .custom(name: "", listId: "")]
|
||||
}
|
||||
|
||||
// Open in actions come first
|
||||
case webtor = 1
|
||||
|
||||
// Sharing actions come last
|
||||
case shareMagnet = 2
|
||||
}
|
||||
|
||||
public enum DefaultDebridActionType: Int, CaseIterable {
|
||||
// Let the user choose
|
||||
case none = 0
|
||||
|
||||
// Open in actions come first
|
||||
case outplayer = 1
|
||||
case vlc = 2
|
||||
case infuse = 3
|
||||
|
||||
// Sharing actions come last
|
||||
case shareDownload = 4
|
||||
case none
|
||||
case share
|
||||
case kodi
|
||||
case custom(name: String, listId: String)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,47 +7,51 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
||||
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
||||
case json
|
||||
case text
|
||||
}
|
||||
|
||||
public struct SourceListJson: Codable, Sendable {
|
||||
let name: String
|
||||
let author: String
|
||||
var sources: [SourceJson]
|
||||
}
|
||||
|
||||
public struct SourceJson: Codable, Hashable, Sendable {
|
||||
struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
||||
let name: String
|
||||
let version: Int16
|
||||
let minVersion: String?
|
||||
let baseUrl: String?
|
||||
let about: String?
|
||||
let website: String?
|
||||
let dynamicWebsite: Bool?
|
||||
let fallbackUrls: [String]?
|
||||
var dynamicBaseUrl: Bool?
|
||||
var author: String?
|
||||
var listId: UUID?
|
||||
let trackers: [String]?
|
||||
let api: SourceApiJson?
|
||||
let jsonParser: SourceJsonParserJson?
|
||||
let rssParser: SourceRssParserJson?
|
||||
let htmlParser: SourceHtmlParserJson?
|
||||
let author: String?
|
||||
let listId: UUID?
|
||||
let listName: String?
|
||||
let tags: [PluginTagJson]?
|
||||
}
|
||||
|
||||
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
||||
extension SourceJson {
|
||||
// Fetches all tags without optional requirement
|
||||
func getTags() -> [PluginTagJson] {
|
||||
tags ?? []
|
||||
}
|
||||
}
|
||||
|
||||
enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
||||
// case none = 0
|
||||
case scraping = 1
|
||||
case rss = 2
|
||||
case siteApi = 3
|
||||
}
|
||||
|
||||
public struct SourceApiJson: Codable, Hashable, Sendable {
|
||||
struct SourceApiJson: Codable, Hashable, Sendable {
|
||||
let apiUrl: String?
|
||||
let clientId: SourceApiCredentialJson?
|
||||
let clientSecret: SourceApiCredentialJson?
|
||||
}
|
||||
|
||||
public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||
struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||
let query: String?
|
||||
let value: String?
|
||||
let dynamic: Bool?
|
||||
|
|
@ -56,52 +60,58 @@ public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
|||
let expiryLength: Double?
|
||||
}
|
||||
|
||||
public struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||
struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||
let searchUrl: String
|
||||
let request: SourceRequestJson?
|
||||
let results: String?
|
||||
let subResults: String?
|
||||
let magnetHash: SouceComplexQueryJson?
|
||||
let magnetLink: SouceComplexQueryJson?
|
||||
let title: SouceComplexQueryJson?
|
||||
let size: SouceComplexQueryJson?
|
||||
let title: SourceComplexQueryJson
|
||||
let magnetHash: SourceComplexQueryJson?
|
||||
let magnetLink: SourceComplexQueryJson?
|
||||
let subName: SourceComplexQueryJson?
|
||||
let size: SourceComplexQueryJson?
|
||||
let sl: SourceSLJson?
|
||||
}
|
||||
|
||||
public struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||
struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||
let rssUrl: String?
|
||||
let searchUrl: String
|
||||
let request: SourceRequestJson?
|
||||
let items: String
|
||||
let magnetHash: SouceComplexQueryJson?
|
||||
let magnetLink: SouceComplexQueryJson?
|
||||
let title: SouceComplexQueryJson?
|
||||
let size: SouceComplexQueryJson?
|
||||
let title: SourceComplexQueryJson
|
||||
let magnetHash: SourceComplexQueryJson?
|
||||
let magnetLink: SourceComplexQueryJson?
|
||||
let subName: SourceComplexQueryJson?
|
||||
let size: SourceComplexQueryJson?
|
||||
let sl: SourceSLJson?
|
||||
}
|
||||
|
||||
public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||
let searchUrl: String
|
||||
struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||
let searchUrl: String?
|
||||
let request: SourceRequestJson?
|
||||
let rows: String
|
||||
let title: SourceComplexQueryJson
|
||||
let magnet: SourceMagnetJson
|
||||
let title: SouceComplexQueryJson?
|
||||
let size: SouceComplexQueryJson?
|
||||
let subName: SourceComplexQueryJson?
|
||||
let size: SourceComplexQueryJson?
|
||||
let sl: SourceSLJson?
|
||||
}
|
||||
|
||||
public struct SouceComplexQueryJson: Codable, Hashable, Sendable {
|
||||
struct SourceComplexQueryJson: Codable, Hashable, Sendable {
|
||||
let query: String
|
||||
let discriminator: String?
|
||||
let attribute: String?
|
||||
let regex: String?
|
||||
}
|
||||
|
||||
public struct SourceMagnetJson: Codable, Hashable, Sendable {
|
||||
struct SourceMagnetJson: Codable, Hashable, Sendable {
|
||||
let query: String
|
||||
let attribute: String
|
||||
let regex: String?
|
||||
let externalLinkQuery: String?
|
||||
}
|
||||
|
||||
public struct SourceSLJson: Codable, Hashable, Sendable {
|
||||
struct SourceSLJson: Codable, Hashable, Sendable {
|
||||
let seeders: String?
|
||||
let leechers: String?
|
||||
let combined: String?
|
||||
|
|
@ -110,3 +120,9 @@ public struct SourceSLJson: Codable, Hashable, Sendable {
|
|||
let seederRegex: String?
|
||||
let leecherRegex: String?
|
||||
}
|
||||
|
||||
struct SourceRequestJson: Codable, Hashable, Sendable {
|
||||
let method: String?
|
||||
let headers: [String: String]?
|
||||
let body: String?
|
||||
}
|
||||
|
|
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,18 +7,36 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class BackupManager: ObservableObject {
|
||||
var toastModel: ToastViewModel?
|
||||
class BackupManager: ObservableObject {
|
||||
// Constant variable for backup versions
|
||||
private let latestBackupVersion: Int = 2
|
||||
|
||||
var logManager: LoggingManager?
|
||||
|
||||
@Published var showRestoreAlert = false
|
||||
@Published var showRestoreCompletedAlert = false
|
||||
@Published var restoreCompletedMessage: [String] = []
|
||||
|
||||
@Published var backupUrls: [URL] = []
|
||||
@Published var backupSourceNames: [String] = []
|
||||
@Published var selectedBackupUrl: URL?
|
||||
|
||||
func createBackup() {
|
||||
var backup = Backup()
|
||||
@MainActor
|
||||
private func updateRestoreCompletedMessage(newString: String) {
|
||||
restoreCompletedMessage.append(newString)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func toggleRestoreCompletedAlert() {
|
||||
showRestoreCompletedAlert.toggle()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateBackupUrls(newUrl: URL) {
|
||||
backupUrls.append(newUrl)
|
||||
}
|
||||
|
||||
func createBackup() async {
|
||||
var backup = Backup(version: latestBackupVersion)
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let bookmarkRequest = Bookmark.fetchRequest()
|
||||
|
|
@ -68,16 +86,14 @@ public class BackupManager: ObservableObject {
|
|||
backup.sourceNames = sources.map(\.name)
|
||||
}
|
||||
|
||||
let sourceListRequest = SourceList.fetchRequest()
|
||||
if let sourceLists = try? backgroundContext.fetch(sourceListRequest) {
|
||||
backup.sourceLists = sourceLists.map {
|
||||
SourceListBackupJson(
|
||||
name: $0.name,
|
||||
author: $0.author,
|
||||
id: $0.id.uuidString,
|
||||
urlString: $0.urlString
|
||||
)
|
||||
}
|
||||
let actionRequest = Action.fetchRequest()
|
||||
if let actions = try? backgroundContext.fetch(actionRequest) {
|
||||
backup.actionNames = actions.map(\.name)
|
||||
}
|
||||
|
||||
let pluginListRequest = PluginList.fetchRequest()
|
||||
if let pluginLists = try? backgroundContext.fetch(pluginListRequest) {
|
||||
backup.pluginListUrls = pluginLists.map(\.urlString)
|
||||
}
|
||||
|
||||
do {
|
||||
|
|
@ -91,18 +107,21 @@ public class BackupManager: ObservableObject {
|
|||
let writeUrl = backupsPath.appendingPathComponent("Ferrite-backup-\(snapshot).feb")
|
||||
|
||||
try encodedJson.write(to: writeUrl)
|
||||
backupUrls.append(writeUrl)
|
||||
|
||||
await updateBackupUrls(newUrl: writeUrl)
|
||||
} catch {
|
||||
print(error)
|
||||
await logManager?.error("Backup: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Backup is in local documents directory, so no need to restore it from the shared URL
|
||||
func restoreBackup() {
|
||||
// Pass the pluginManager reference since it's not used throughout the class like logManager
|
||||
func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async {
|
||||
guard let backupUrl = selectedBackupUrl else {
|
||||
Task {
|
||||
await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.")
|
||||
}
|
||||
await logManager?.error(
|
||||
"Backup restore: Could not find backup in app directory.",
|
||||
description: "Could not find the selected backup in the local directory."
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -110,64 +129,69 @@ public class BackupManager: ObservableObject {
|
|||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
do {
|
||||
// Delete all relevant entities to prevent issues with restoration if overwrite is selected
|
||||
if doOverwrite {
|
||||
try PersistenceController.shared.batchDelete("Bookmark")
|
||||
try PersistenceController.shared.batchDelete("History")
|
||||
try PersistenceController.shared.batchDelete("HistoryEntry")
|
||||
try PersistenceController.shared.batchDelete("PluginList")
|
||||
try PersistenceController.shared.batchDelete("Source")
|
||||
try PersistenceController.shared.batchDelete("Action")
|
||||
}
|
||||
|
||||
let file = try Data(contentsOf: backupUrl)
|
||||
|
||||
let backup = try JSONDecoder().decode(Backup.self, from: file)
|
||||
|
||||
if let bookmarks = backup.bookmarks {
|
||||
for bookmark in bookmarks {
|
||||
PersistenceController.shared.createBookmark(bookmark)
|
||||
PersistenceController.shared.createBookmark(bookmark, performSave: false)
|
||||
}
|
||||
}
|
||||
|
||||
if let storedHistories = backup.history {
|
||||
for storedHistory in storedHistories {
|
||||
for storedEntry in storedHistory.entries {
|
||||
PersistenceController.shared.createHistory(entryJson: storedEntry, date: storedHistory.date)
|
||||
PersistenceController.shared.createHistory(
|
||||
storedEntry,
|
||||
performSave: false,
|
||||
isBackup: true,
|
||||
date: storedHistory.date
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let storedLists = backup.sourceLists {
|
||||
let version = backup.version ?? -1
|
||||
|
||||
if let storedLists = backup.sourceLists, version < 2 {
|
||||
// Only present in v1 or no version backups
|
||||
for list in storedLists {
|
||||
let sourceListRequest = SourceList.fetchRequest()
|
||||
let urlPredicate = NSPredicate(format: "urlString == %@", list.urlString)
|
||||
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", list.author, list.name)
|
||||
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
|
||||
sourceListRequest.fetchLimit = 1
|
||||
|
||||
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
let newSourceList = SourceList(context: backgroundContext)
|
||||
newSourceList.name = list.name
|
||||
newSourceList.urlString = list.urlString
|
||||
newSourceList.id = UUID(uuidString: list.id) ?? UUID()
|
||||
newSourceList.author = list.author
|
||||
try await pluginManager.addPluginList(list.urlString, existingPluginList: nil)
|
||||
}
|
||||
} else if let pluginListUrls = backup.pluginListUrls {
|
||||
// v2 and up
|
||||
for listUrl in pluginListUrls {
|
||||
try await pluginManager.addPluginList(listUrl, existingPluginList: nil)
|
||||
}
|
||||
}
|
||||
|
||||
backupSourceNames = backup.sourceNames ?? []
|
||||
if let sourceNames = backup.sourceNames {
|
||||
await updateRestoreCompletedMessage(newString: sourceNames.isEmpty ? "No sources need to be reinstalled" : "Reinstall sources: \(sourceNames.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
if let actionNames = backup.actionNames {
|
||||
await updateRestoreCompletedMessage(newString: actionNames.isEmpty ? "No actions need to be reinstalled" : "Reinstall actions: \(actionNames.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
PersistenceController.shared.save(backgroundContext)
|
||||
|
||||
// if iOS 14 is available, sleep to prevent any issues with alerts
|
||||
if #available(iOS 15, *) {
|
||||
showRestoreCompletedAlert.toggle()
|
||||
} else {
|
||||
Task {
|
||||
try? await Task.sleep(seconds: 0.1)
|
||||
|
||||
Task { @MainActor in
|
||||
showRestoreCompletedAlert.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
await toggleRestoreCompletedAlert()
|
||||
} catch {
|
||||
Task {
|
||||
await toastModel?.updateToastDescription("Backup restore: \(error)")
|
||||
}
|
||||
await logManager?.error(
|
||||
"Backup restore: \(error)",
|
||||
description: "A backup restore error was logged"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +208,8 @@ public class BackupManager: ObservableObject {
|
|||
}
|
||||
} catch {
|
||||
Task {
|
||||
await toastModel?.updateToastDescription("Backup removal: \(error)")
|
||||
await logManager?.error("Backup removal: \(error)")
|
||||
print("Backup removal: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,7 +238,7 @@ public class BackupManager: ObservableObject {
|
|||
selectedBackupUrl = localBackupPath
|
||||
} catch {
|
||||
Task {
|
||||
await toastModel?.updateToastDescription("Backup copy: \(error)")
|
||||
await logManager?.error("Backup copy: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,225 +9,485 @@ import Foundation
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public class DebridManager: ObservableObject {
|
||||
class DebridManager: ObservableObject {
|
||||
// Linked classes
|
||||
var toastModel: ToastViewModel?
|
||||
let realDebrid: RealDebrid = .init()
|
||||
var logManager: LoggingManager?
|
||||
@Published var realDebrid: RealDebrid = .init()
|
||||
@Published var allDebrid: AllDebrid = .init()
|
||||
@Published var premiumize: Premiumize = .init()
|
||||
@Published var torbox: TorBox = .init()
|
||||
@Published var offcloud: OffCloud = .init()
|
||||
|
||||
lazy var debridSources: [DebridSource] = [realDebrid, allDebrid, premiumize, torbox, offcloud]
|
||||
|
||||
// UI Variables
|
||||
@Published var showWebView: Bool = false
|
||||
@Published var showLoadingProgress: Bool = false
|
||||
@Published var showAuthSession: Bool = false
|
||||
@Published var enabledDebrids: [DebridSource] = []
|
||||
|
||||
// Service agnostic variables
|
||||
var currentDebridTask: Task<Void, Never>?
|
||||
|
||||
// RealDebrid auth variables
|
||||
@Published var realDebridEnabled: Bool = false {
|
||||
@Published var selectedDebridSource: DebridSource? {
|
||||
didSet {
|
||||
UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled")
|
||||
UserDefaults.standard.set(selectedDebridSource?.id ?? "", forKey: "Debrid.PreferredService")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var realDebridAuthProcessing: Bool = false
|
||||
var realDebridAuthUrl: String = ""
|
||||
var selectedDebridItem: DebridIA?
|
||||
var selectedDebridFile: DebridIAFile?
|
||||
var requiresUnrestrict: Bool = false
|
||||
|
||||
// RealDebrid fetch variables
|
||||
@Published var realDebridIAValues: [RealDebridIA] = []
|
||||
var realDebridDownloadUrl: String = ""
|
||||
// TODO: Figure out a way to remove this var
|
||||
private var selectedOAuthDebridSource: OAuthDebridSource?
|
||||
|
||||
@Published var filteredIAStatus: Set<IAStatus> = []
|
||||
|
||||
var currentDebridTask: Task<Void, Never>?
|
||||
var downloadUrl: String = ""
|
||||
var authUrl: URL?
|
||||
|
||||
@Published var showDeleteAlert: Bool = false
|
||||
|
||||
// TODO: Switch to an individual item based sheet system to remove these variables
|
||||
var selectedRealDebridItem: RealDebridIA?
|
||||
var selectedRealDebridFile: RealDebridIAFile?
|
||||
var selectedRealDebridID: String?
|
||||
@Published var showWebLoginAlert: Bool = false
|
||||
@Published var showNotImplementedAlert: Bool = false
|
||||
@Published var notImplementedMessage: String = ""
|
||||
|
||||
init() {
|
||||
realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled")
|
||||
// Update the UI for debrid services that are enabled
|
||||
enabledDebrids = debridSources.filter(\.isLoggedIn)
|
||||
|
||||
// Set the preferred service. Contains migration logic for earlier versions
|
||||
if let rawPreferredService = UserDefaults.standard.string(forKey: "Debrid.PreferredService") {
|
||||
let debridServiceId: String?
|
||||
|
||||
if let preferredServiceInt = Int(rawPreferredService) {
|
||||
debridServiceId = migratePreferredService(preferredServiceInt)
|
||||
} else {
|
||||
debridServiceId = rawPreferredService
|
||||
}
|
||||
|
||||
// Only set the debrid source if it's logged in
|
||||
// Otherwise remove the key
|
||||
let tempDebridSource = debridSources.first { $0.id == debridServiceId }
|
||||
if tempDebridSource?.isLoggedIn ?? false {
|
||||
selectedDebridSource = tempDebridSource
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: "Debrid.PreferredService")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func populateDebridHashes(_ resultHashes: [String]) async {
|
||||
do {
|
||||
let now = Date()
|
||||
// TODO: Remove after v0.8.0
|
||||
// Function to migrate the preferred service to the new string ID format
|
||||
private func migratePreferredService(_ idInt: Int) -> String? {
|
||||
// Undo the EnabledDebrids key
|
||||
UserDefaults.standard.removeObject(forKey: "Debrid.EnabledArray")
|
||||
|
||||
// If a hash isn't found in the IA, update it
|
||||
// If the hash is expired, remove it and update it
|
||||
let sendHashes = resultHashes.filter { hash in
|
||||
if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }) {
|
||||
if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp {
|
||||
realDebridIAValues.remove(at: IAIndex)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return DebridType(rawValue: idInt)?.toString()
|
||||
}
|
||||
|
||||
// Wrapper function to match error descriptions
|
||||
// Error can be suppressed to end user but must be printed in logs
|
||||
private func sendDebridError(
|
||||
_ error: Error,
|
||||
prefix: String,
|
||||
presentError: Bool = true,
|
||||
cancelString: String? = nil
|
||||
) async {
|
||||
let error = error as NSError
|
||||
if presentError {
|
||||
switch error.code {
|
||||
case -1009:
|
||||
logManager?.info(
|
||||
"DebridManager: The connection is offline",
|
||||
description: "The connection is offline"
|
||||
)
|
||||
case -999:
|
||||
if let cancelString {
|
||||
logManager?.info(cancelString, description: cancelString)
|
||||
} else {
|
||||
return true
|
||||
break
|
||||
}
|
||||
default:
|
||||
logManager?.error("\(prefix): \(error)")
|
||||
}
|
||||
|
||||
if !sendHashes.isEmpty {
|
||||
let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes)
|
||||
|
||||
realDebridIAValues += fetchedIAValues
|
||||
}
|
||||
} catch {
|
||||
let error = error as NSError
|
||||
|
||||
if error.code != -999 {
|
||||
toastModel?.updateToastDescription("RealDebrid hash error: \(error)")
|
||||
}
|
||||
|
||||
print("RealDebrid hash error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func matchSearchResult(result: SearchResult?) -> RealDebridIAStatus {
|
||||
guard let result else {
|
||||
// Cleans all cached IA values in the event of a full IA refresh
|
||||
func clearIAValues() {
|
||||
for debridSource in debridSources {
|
||||
debridSource.IAValues = []
|
||||
}
|
||||
}
|
||||
|
||||
// Clears all selected files and items
|
||||
func clearSelectedDebridItems() {
|
||||
selectedDebridItem = nil
|
||||
selectedDebridFile = nil
|
||||
}
|
||||
|
||||
// Common function to populate hashes for debrid services
|
||||
func populateDebridIA(_ resultMagnets: [Magnet]) async {
|
||||
for debridSource in debridSources {
|
||||
if !debridSource.isLoggedIn {
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't exit the function if the API fetch errors
|
||||
do {
|
||||
try await debridSource.instantAvailability(magnets: resultMagnets)
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) IA fetch error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common function to match a magnet hash with a provided debrid service
|
||||
func matchMagnetHash(_ magnet: Magnet) -> IAStatus {
|
||||
guard let magnetHash = magnet.hash else {
|
||||
return .none
|
||||
}
|
||||
|
||||
guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
if debridMatch.batches.isEmpty {
|
||||
return .full
|
||||
if let selectedDebridSource,
|
||||
let match = selectedDebridSource.IAValues.first(where: { magnetHash == $0.magnet.hash })
|
||||
{
|
||||
return match.files.count > 1 ? .partial : .full
|
||||
} else {
|
||||
return .partial
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
public func setSelectedRdResult(result: SearchResult) -> Bool {
|
||||
guard let magnetHash = result.magnetHash else {
|
||||
toastModel?.updateToastDescription("Could not find the torrent magnet hash")
|
||||
func selectDebridResult(magnet: Magnet) -> Bool {
|
||||
guard let magnetHash = magnet.hash else {
|
||||
logManager?.error("DebridManager: Could not find the magnet hash")
|
||||
return false
|
||||
}
|
||||
|
||||
if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) {
|
||||
selectedRealDebridItem = realDebridItem
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let IAItem = selectedSource.IAValues.first(where: { magnetHash == $0.magnet.hash }) {
|
||||
selectedDebridItem = IAItem
|
||||
|
||||
if IAItem.files.count == 1 {
|
||||
selectedDebridFile = IAItem.files[safe: 0]
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
|
||||
logManager?.warn("DebridManager: Could not find the associated \(selectedSource.id) entry for magnet hash \(magnetHash)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func authenticateRd() async {
|
||||
do {
|
||||
realDebridAuthProcessing = true
|
||||
let verificationResponse = try await realDebrid.getVerificationInfo()
|
||||
// MARK: - Authentication UI linked functions
|
||||
|
||||
realDebridAuthUrl = verificationResponse.directVerificationURL
|
||||
showWebView.toggle()
|
||||
|
||||
try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode)
|
||||
|
||||
realDebridEnabled = true
|
||||
} catch {
|
||||
toastModel?.updateToastDescription("RealDebrid authentication error: \(error)")
|
||||
realDebrid.authTask?.cancel()
|
||||
|
||||
print("RealDebrid authentication error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func logoutRd() async {
|
||||
do {
|
||||
try await realDebrid.deleteTokens()
|
||||
realDebridEnabled = false
|
||||
realDebridAuthProcessing = false
|
||||
} catch {
|
||||
toastModel?.updateToastDescription("RealDebrid logout error: \(error)")
|
||||
|
||||
print("RealDebrid logout error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchRdDownload(searchResult: SearchResult) async {
|
||||
// Common function to delegate what debrid service to authenticate with
|
||||
func authenticateDebrid(_ debridSource: some DebridSource, apiKey: String?) async {
|
||||
defer {
|
||||
currentDebridTask = nil
|
||||
showLoadingProgress = false
|
||||
// Don't cancel processing if using OAuth
|
||||
if !(debridSource is OAuthDebridSource) {
|
||||
debridSource.authProcessing = false
|
||||
}
|
||||
|
||||
if enabledDebrids.count == 1 {
|
||||
selectedDebridSource = debridSource
|
||||
}
|
||||
}
|
||||
|
||||
showLoadingProgress = true
|
||||
// Set an API key if manually provided
|
||||
if let apiKey {
|
||||
debridSource.setApiKey(apiKey)
|
||||
enabledDebrids.append(debridSource)
|
||||
|
||||
guard let magnetLink = searchResult.magnetLink else {
|
||||
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
|
||||
print("RealDebrid error: Invalid magnet link")
|
||||
return
|
||||
}
|
||||
|
||||
// Processing has started
|
||||
debridSource.authProcessing = true
|
||||
|
||||
if let pollingSource = debridSource as? PollingDebridSource {
|
||||
do {
|
||||
let authUrl = try await pollingSource.getAuthUrl()
|
||||
|
||||
if validateAuthUrl(authUrl) {
|
||||
try await pollingSource.authTask?.value
|
||||
enabledDebrids.append(debridSource)
|
||||
} else {
|
||||
throw DebridError.AuthQuery(description: "The authentication URL was invalid")
|
||||
}
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
|
||||
|
||||
pollingSource.authTask?.cancel()
|
||||
}
|
||||
} else if let oauthSource = debridSource as? OAuthDebridSource {
|
||||
do {
|
||||
let tempAuthUrl = try oauthSource.getAuthUrl()
|
||||
selectedOAuthDebridSource = oauthSource
|
||||
|
||||
validateAuthUrl(tempAuthUrl, useAuthSession: true)
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) authentication error")
|
||||
}
|
||||
} else {
|
||||
// Let the user know that a traditional auth method doesn't exist
|
||||
showWebLoginAlert.toggle()
|
||||
|
||||
logManager?.error(
|
||||
"DebridManager: Auth: \(debridSource.id) does not have a login portal.",
|
||||
showToast: false
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get a truncated manual API key if it's being used
|
||||
func getManualAuthKey(_ debridSource: some DebridSource) async -> String? {
|
||||
if let debridToken = debridSource.manualToken {
|
||||
let splitString = debridToken.suffix(4)
|
||||
|
||||
if debridToken.count > 4 {
|
||||
return String(repeating: "*", count: debridToken.count - 4) + splitString
|
||||
} else {
|
||||
return String(splitString)
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function to validate and present an auth URL to the user
|
||||
@discardableResult private func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool {
|
||||
guard let url else {
|
||||
logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))")
|
||||
return false
|
||||
}
|
||||
|
||||
authUrl = url
|
||||
if useAuthSession {
|
||||
showAuthSession.toggle()
|
||||
} else {
|
||||
showWebView.toggle()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Currently handles Premiumize callback
|
||||
func handleAuthCallback(url: URL?, error: Error?) async {
|
||||
defer {
|
||||
if enabledDebrids.count == 1 {
|
||||
selectedDebridSource = selectedOAuthDebridSource
|
||||
}
|
||||
|
||||
selectedOAuthDebridSource?.authProcessing = false
|
||||
}
|
||||
|
||||
do {
|
||||
guard let oauthDebridSource = selectedOAuthDebridSource else {
|
||||
throw DebridError.AuthQuery(description: "OAuth source couldn't be found for callback. Aborting.")
|
||||
}
|
||||
|
||||
if let error {
|
||||
throw DebridError.AuthQuery(description: "OAuth callback Error: \(error)")
|
||||
}
|
||||
|
||||
if let callbackUrl = url {
|
||||
try oauthDebridSource.handleAuthCallback(url: callbackUrl)
|
||||
enabledDebrids.append(oauthDebridSource)
|
||||
} else {
|
||||
throw DebridError.AuthQuery(description: "The callback URL was invalid")
|
||||
}
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "Premiumize authentication error (callback)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logout UI functions
|
||||
|
||||
func logout(_ debridSource: some DebridSource) async {
|
||||
await debridSource.logout()
|
||||
|
||||
if selectedDebridSource?.id == debridSource.id {
|
||||
selectedDebridSource = nil
|
||||
}
|
||||
|
||||
enabledDebrids.removeAll { $0.id == debridSource.id }
|
||||
}
|
||||
|
||||
// MARK: - Debrid fetch UI linked functions
|
||||
|
||||
// Common function to delegate what debrid service to fetch from
|
||||
// Cloudinfo is used for any extra information provided by debrid cloud
|
||||
func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async {
|
||||
defer {
|
||||
logManager?.hideIndeterminateToast()
|
||||
|
||||
if !requiresUnrestrict {
|
||||
clearSelectedDebridItems()
|
||||
}
|
||||
|
||||
currentDebridTask = nil
|
||||
}
|
||||
|
||||
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||
self.currentDebridTask?.cancel()
|
||||
self.currentDebridTask = nil
|
||||
})
|
||||
|
||||
guard let debridSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Cleanup beforehand
|
||||
requiresUnrestrict = false
|
||||
|
||||
if let cloudInfo {
|
||||
downloadUrl = try await debridSource.checkUserDownloads(link: cloudInfo) ?? ""
|
||||
return
|
||||
}
|
||||
|
||||
if let magnet {
|
||||
let (restrictedFile, newIA) = try await debridSource.getRestrictedFile(
|
||||
magnet: magnet, ia: selectedDebridItem, iaFile: selectedDebridFile
|
||||
)
|
||||
|
||||
// Indicate that a link needs to be selected (batch)
|
||||
if let newIA {
|
||||
if newIA.files.isEmpty {
|
||||
throw DebridError.EmptyData
|
||||
}
|
||||
|
||||
selectedDebridItem = newIA
|
||||
requiresUnrestrict = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
guard let restrictedFile else {
|
||||
throw DebridError.FailedRequest(description: "No files found for your request")
|
||||
}
|
||||
|
||||
// Update the UI
|
||||
downloadUrl = try await debridSource.unrestrictFile(restrictedFile)
|
||||
} else {
|
||||
throw DebridError.FailedRequest(description: "Could not fetch your file from \(debridSource.id)'s cache or API")
|
||||
}
|
||||
|
||||
// Fetch one more time to add updated data into the RD cloud cache
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
switch error {
|
||||
case DebridError.IsCaching:
|
||||
showDeleteAlert.toggle()
|
||||
default:
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) download error", cancelString: "Download cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unrestrictDownload() async {
|
||||
defer {
|
||||
logManager?.hideIndeterminateToast()
|
||||
requiresUnrestrict = false
|
||||
clearSelectedDebridItems()
|
||||
currentDebridTask = nil
|
||||
}
|
||||
|
||||
logManager?.updateIndeterminateToast("Loading content", cancelAction: {
|
||||
self.currentDebridTask?.cancel()
|
||||
self.currentDebridTask = nil
|
||||
})
|
||||
|
||||
guard let debridFile = selectedDebridFile, let debridSource = selectedDebridSource else {
|
||||
logManager?.error("DebridManager: Could not unrestrict the selected debrid file.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
var fileIds: [Int] = []
|
||||
let downloadLink = try await debridSource.unrestrictFile(debridFile)
|
||||
|
||||
if let iaFile = selectedRealDebridFile {
|
||||
guard let iaBatchFromFile = selectedRealDebridItem?.batches[safe: iaFile.batchIndex] else {
|
||||
return
|
||||
}
|
||||
downloadUrl = downloadLink
|
||||
} catch {
|
||||
await sendDebridError(error, prefix: "\(debridSource.id) unrestrict error", cancelString: "Unrestrict cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
fileIds = iaBatchFromFile.files.map(\.id)
|
||||
}
|
||||
// Wrapper to handle cloud fetching
|
||||
func fetchDebridCloud(bypassTTL: Bool = false) async {
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
// If there's an existing torrent, check for a download link. Otherwise check for an unrestrict link
|
||||
let existingTorrents = try await realDebrid.userTorrents().filter { $0.hash == selectedRealDebridItem?.hash }
|
||||
if bypassTTL || Date().timeIntervalSince1970 > selectedSource.cloudTTL {
|
||||
do {
|
||||
// Populates the inner downloads and magnet arrays
|
||||
try await selectedSource.getUserDownloads()
|
||||
try await selectedSource.getUserMagnets()
|
||||
|
||||
// If the links match from a user's downloads, no need to re-run a download
|
||||
if let existingTorrent = existingTorrents[safe: 0],
|
||||
let torrentLink = existingTorrent.links[safe: selectedRealDebridFile?.batchFileIndex ?? 0]
|
||||
{
|
||||
let existingLinks = try await realDebrid.userDownloads().filter { $0.link == torrentLink }
|
||||
if let existingLink = existingLinks[safe: 0]?.download {
|
||||
realDebridDownloadUrl = existingLink
|
||||
} else {
|
||||
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
|
||||
|
||||
realDebridDownloadUrl = downloadLink
|
||||
}
|
||||
|
||||
} else {
|
||||
// Add a magnet after all the cache checks fail
|
||||
selectedRealDebridID = try await realDebrid.addMagnet(magnetLink: magnetLink)
|
||||
|
||||
if let realDebridId = selectedRealDebridID {
|
||||
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
|
||||
|
||||
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: selectedRealDebridFile?.batchFileIndex ?? 0)
|
||||
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
|
||||
|
||||
realDebridDownloadUrl = downloadLink
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
|
||||
// Update the TTL to 5 minutes from now
|
||||
selectedSource.cloudTTL = Date().timeIntervalSince1970 + 300
|
||||
} catch {
|
||||
let error = error as NSError
|
||||
if error.code != -999 {
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) cloud fetch error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCloudDownload(_ download: DebridCloudDownload) async {
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await selectedSource.deleteUserDownload(downloadId: download.id)
|
||||
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
switch error {
|
||||
case RealDebridError.EmptyTorrents:
|
||||
showDeleteAlert.toggle()
|
||||
case DebridError.NotImplemented:
|
||||
let message = "Download deletion for \(selectedSource.id) is not implemented. Please delete from the service's website."
|
||||
|
||||
notImplementedMessage = message
|
||||
showNotImplementedAlert.toggle()
|
||||
logManager?.error(
|
||||
"DebridManager: \(message)",
|
||||
showToast: false
|
||||
)
|
||||
default:
|
||||
let error = error as NSError
|
||||
|
||||
switch error.code {
|
||||
case -999:
|
||||
toastModel?.updateToastDescription("Download cancelled", newToastType: .info)
|
||||
default:
|
||||
toastModel?.updateToastDescription("RealDebrid download error: \(error)")
|
||||
}
|
||||
|
||||
await deleteRdTorrent()
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) download delete error")
|
||||
}
|
||||
|
||||
showLoadingProgress = false
|
||||
|
||||
print("RealDebrid download error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteRdTorrent() async {
|
||||
if let realDebridId = selectedRealDebridID {
|
||||
try? await realDebrid.deleteTorrent(debridID: realDebridId)
|
||||
func deleteUserMagnet(_ cloudMagnet: DebridCloudMagnet) async {
|
||||
guard let selectedSource = selectedDebridSource else {
|
||||
return
|
||||
}
|
||||
|
||||
selectedRealDebridID = nil
|
||||
do {
|
||||
try await selectedSource.deleteUserMagnet(cloudMagnetId: cloudMagnet.id)
|
||||
|
||||
await fetchDebridCloud(bypassTTL: true)
|
||||
} catch {
|
||||
switch error {
|
||||
case DebridError.NotImplemented:
|
||||
let message = "Magnet deletion for \(selectedSource.id) is not implemented. Please use the service's website."
|
||||
|
||||
notImplementedMessage = message
|
||||
showNotImplementedAlert.toggle()
|
||||
logManager?.error(
|
||||
"DebridManager: \(message)",
|
||||
showToast: false
|
||||
)
|
||||
default:
|
||||
await sendDebridError(error, prefix: "\(selectedSource.id) magnet delete error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,16 +7,9 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
enum ViewTab {
|
||||
case search
|
||||
case sources
|
||||
case settings
|
||||
case library
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class NavigationViewModel: ObservableObject {
|
||||
var toastModel: ToastViewModel?
|
||||
var logManager: LoggingManager?
|
||||
|
||||
// Used between SearchResultsView and MagnetChoiceView
|
||||
enum ChoiceSheetType: Identifiable {
|
||||
|
|
@ -24,21 +17,68 @@ class NavigationViewModel: ObservableObject {
|
|||
hashValue
|
||||
}
|
||||
|
||||
case magnet
|
||||
case action
|
||||
case batch
|
||||
case activity
|
||||
}
|
||||
|
||||
@Published var isEditingSearch: Bool = false
|
||||
@Published var isSearching: Bool = false
|
||||
enum ViewTab {
|
||||
case search
|
||||
case plugins
|
||||
case settings
|
||||
case library
|
||||
}
|
||||
|
||||
@Published var selectedSearchResult: SearchResult?
|
||||
enum LibraryPickerSegment {
|
||||
case bookmarks
|
||||
case history
|
||||
case debridCloud
|
||||
}
|
||||
|
||||
enum PluginPickerSegment {
|
||||
case sources
|
||||
case actions
|
||||
}
|
||||
|
||||
@Published var selectedMagnet: Magnet?
|
||||
@Published var selectedHistoryInfo: HistoryEntryJson?
|
||||
@Published var resultFromCloud: Bool = false
|
||||
|
||||
// For giving information in magnet choice sheet
|
||||
@Published var selectedTitle: String?
|
||||
@Published var selectedBatchTitle: String?
|
||||
@Published var selectedTitle: String = ""
|
||||
@Published var selectedBatchTitle: String = ""
|
||||
|
||||
@Published var hideNavigationBar = false
|
||||
// For filters
|
||||
@Published var enabledFilters: Set<FilterType> = []
|
||||
@Published var currentSortFilter: SortFilter?
|
||||
@Published var currentSortOrder: SortOrder = .forward
|
||||
|
||||
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||
switch currentSortFilter {
|
||||
case .leechers:
|
||||
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentSortOrder == .forward ? lhsLeechers > rhsLeechers : lhsLeechers < rhsLeechers
|
||||
case .seeders:
|
||||
guard let lhsSeeders = lhs.seeders, let rhsSeeders = rhs.seeders else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentSortOrder == .forward ? lhsSeeders > rhsSeeders : lhsSeeders < rhsSeeders
|
||||
case .size:
|
||||
guard let lhsSize = lhs.rawSize(), let rhsSize = rhs.rawSize() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentSortOrder == .forward ? lhsSize > rhsSize : lhsSize < rhsSize
|
||||
case .none:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Published var kodiExpanded: Bool = false
|
||||
|
||||
@Published var currentChoiceSheet: ChoiceSheetType?
|
||||
var activityItems: [Any] = []
|
||||
|
|
@ -47,98 +87,38 @@ class NavigationViewModel: ObservableObject {
|
|||
@Published var showLocalActivitySheet = false
|
||||
|
||||
@Published var selectedTab: ViewTab = .search
|
||||
@Published var showSearchProgress: Bool = false
|
||||
|
||||
// Used between SourceListView and SourceSettingsView
|
||||
@Published var showSourceSettings: Bool = false
|
||||
var selectedSource: Source?
|
||||
// Used between service views and editor views in Settings
|
||||
@Published var selectedPluginList: PluginList?
|
||||
@Published var selectedKodiServer: KodiServer?
|
||||
|
||||
@Published var showSourceListEditor: Bool = false
|
||||
var selectedSourceList: SourceList?
|
||||
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
|
||||
@Published var pluginPickerSelection: PluginPickerSegment = .sources
|
||||
|
||||
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
|
||||
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
|
||||
@Published var searchPrompt: String = "Search"
|
||||
@Published var lastSearchPromptIndex: Int = -1
|
||||
private let searchBarTextArray: [String] = [
|
||||
"What's on your mind?",
|
||||
"Discover something interesting",
|
||||
"Find an engaging show",
|
||||
"Feeling adventurous?",
|
||||
"Look for something new",
|
||||
"The classics are a good idea"
|
||||
]
|
||||
|
||||
public func runDebridAction(urlString: String, _ action: DefaultDebridActionType? = nil) {
|
||||
let selectedAction = action ?? defaultDebridAction
|
||||
|
||||
switch selectedAction {
|
||||
case .none:
|
||||
currentChoiceSheet = .magnet
|
||||
case .outplayer:
|
||||
if let downloadUrl = URL(string: "outplayer://\(urlString)") {
|
||||
UIApplication.shared.open(downloadUrl)
|
||||
func getSearchPrompt() {
|
||||
if UserDefaults.standard.bool(forKey: "Behavior.UsesRandomSearchText") {
|
||||
let num = Int.random(in: 0 ..< searchBarTextArray.count - 1)
|
||||
if num == lastSearchPromptIndex {
|
||||
lastSearchPromptIndex = num + 1
|
||||
searchPrompt = searchBarTextArray[safe: num + 1] ?? "Search"
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not create an Outplayer URL")
|
||||
}
|
||||
case .vlc:
|
||||
if let downloadUrl = URL(string: "vlc://\(urlString)") {
|
||||
UIApplication.shared.open(downloadUrl)
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not create a VLC URL")
|
||||
}
|
||||
case .infuse:
|
||||
if let downloadUrl = URL(string: "infuse://x-callback-url/play?url=\(urlString)") {
|
||||
UIApplication.shared.open(downloadUrl)
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not create a Infuse URL")
|
||||
}
|
||||
case .shareDownload:
|
||||
if let downloadUrl = URL(string: urlString), currentChoiceSheet == nil {
|
||||
activityItems = [downloadUrl]
|
||||
currentChoiceSheet = .activity
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not create object for sharing")
|
||||
lastSearchPromptIndex = num
|
||||
searchPrompt = searchBarTextArray[safe: num] ?? "Search"
|
||||
}
|
||||
} else {
|
||||
lastSearchPromptIndex = -1
|
||||
searchPrompt = "Search"
|
||||
}
|
||||
}
|
||||
|
||||
public func runMagnetAction(magnetString: String?, _ action: DefaultMagnetActionType? = nil) {
|
||||
let selectedAction = action ?? defaultMagnetAction
|
||||
|
||||
guard let magnetLink = magnetString else {
|
||||
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
|
||||
print("Magnet action error: The magnet link is invalid.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch selectedAction {
|
||||
case .none:
|
||||
currentChoiceSheet = .magnet
|
||||
case .webtor:
|
||||
if let url = URL(string: "https://webtor.io/#/show?magnet=\(magnetLink)") {
|
||||
UIApplication.shared.open(url)
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not create a WebTor URL")
|
||||
}
|
||||
case .shareMagnet:
|
||||
if let magnetUrl = URL(string: magnetLink),
|
||||
currentChoiceSheet == nil
|
||||
{
|
||||
activityItems = [magnetUrl]
|
||||
currentChoiceSheet = .activity
|
||||
} else {
|
||||
toastModel?.updateToastDescription("Could not create object for sharing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func addToHistory(name: String?, source: String?, url: String?, subName: String? = nil) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
// The timeStamp and date are nil because the create function will make them automatically
|
||||
PersistenceController.shared.createHistory(
|
||||
entryJson: HistoryEntryJson(
|
||||
name: name ?? "",
|
||||
subName: subName,
|
||||
url: url ?? "",
|
||||
timeStamp: nil,
|
||||
source: source
|
||||
),
|
||||
date: nil
|
||||
)
|
||||
|
||||
PersistenceController.shared.save(backgroundContext)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,426 +0,0 @@
|
|||
//
|
||||
// SourceManager.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/25/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public class SourceManager: ObservableObject {
|
||||
var toastModel: ToastViewModel?
|
||||
|
||||
@Published var availableSources: [SourceJson] = []
|
||||
|
||||
var urlErrorAlertText = ""
|
||||
@Published var showUrlErrorAlert = false
|
||||
|
||||
@MainActor
|
||||
public func fetchSourcesFromUrl() async {
|
||||
let sourceListRequest = SourceList.fetchRequest()
|
||||
do {
|
||||
let sourceLists = try PersistenceController.shared.backgroundContext.fetch(sourceListRequest)
|
||||
var tempAvailableSources: [SourceJson] = []
|
||||
|
||||
for sourceList in sourceLists {
|
||||
guard let url = URL(string: sourceList.urlString) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Always get the up-to-date source list
|
||||
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
let sourceResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
|
||||
|
||||
for var source in sourceResponse.sources {
|
||||
// If there is a minVersion, check and see if the source is valid
|
||||
if checkAppVersion(minVersion: source.minVersion) {
|
||||
source.author = sourceList.author
|
||||
source.listId = sourceList.id
|
||||
|
||||
tempAvailableSources.append(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
availableSources = tempAvailableSources
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchUpdatedSources(installedSources: FetchedResults<Source>) -> [SourceJson] {
|
||||
var updatedSources: [SourceJson] = []
|
||||
|
||||
for source in installedSources {
|
||||
if let availableSource = availableSources.first(where: {
|
||||
source.listId == $0.listId && source.name == $0.name && source.author == $0.author
|
||||
}),
|
||||
availableSource.version > source.version
|
||||
{
|
||||
updatedSources.append(availableSource)
|
||||
}
|
||||
}
|
||||
|
||||
return updatedSources
|
||||
}
|
||||
|
||||
// Checks if the current app version is supported by the source
|
||||
func checkAppVersion(minVersion: String?) -> Bool {
|
||||
// If there's no min version, assume that every version is supported
|
||||
guard let minVersion else {
|
||||
return true
|
||||
}
|
||||
|
||||
return Application.shared.appVersion >= minVersion
|
||||
}
|
||||
|
||||
// Fetches sources using the background context
|
||||
public func fetchInstalledSources() -> [Source] {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
|
||||
return sources.compactMap { $0 }
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
// If there's no base URL and it isn't dynamic, return before any transactions occur
|
||||
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
|
||||
|
||||
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
|
||||
await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
|
||||
|
||||
print("Not adding this source because base URL parameters are malformed")
|
||||
return
|
||||
}
|
||||
|
||||
// If a source exists, don't add the new one unless upserting
|
||||
let existingSourceRequest = Source.fetchRequest()
|
||||
existingSourceRequest.predicate = NSPredicate(format: "name == %@", sourceJson.name)
|
||||
existingSourceRequest.fetchLimit = 1
|
||||
|
||||
if let existingSource = try? backgroundContext.fetch(existingSourceRequest).first {
|
||||
if doUpsert {
|
||||
PersistenceController.shared.delete(existingSource, context: backgroundContext)
|
||||
} else {
|
||||
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let newSource = Source(context: backgroundContext)
|
||||
newSource.id = UUID()
|
||||
newSource.name = sourceJson.name
|
||||
newSource.version = sourceJson.version
|
||||
newSource.dynamicBaseUrl = dynamicBaseUrl
|
||||
newSource.baseUrl = sourceJson.baseUrl
|
||||
newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls
|
||||
newSource.author = sourceJson.author ?? "Unknown"
|
||||
newSource.listId = sourceJson.listId
|
||||
newSource.trackers = sourceJson.trackers
|
||||
|
||||
if let sourceApiJson = sourceJson.api {
|
||||
addSourceApi(newSource: newSource, apiJson: sourceApiJson)
|
||||
}
|
||||
|
||||
if let jsonParserJson = sourceJson.jsonParser {
|
||||
addJsonParser(newSource: newSource, jsonParserJson: jsonParserJson)
|
||||
}
|
||||
|
||||
// Adds an RSS parser if present
|
||||
if let rssParserJson = sourceJson.rssParser {
|
||||
addRssParser(newSource: newSource, rssParserJson: rssParserJson)
|
||||
}
|
||||
|
||||
// Adds an HTML parser if present
|
||||
if let htmlParserJson = sourceJson.htmlParser {
|
||||
addHtmlParser(newSource: newSource, htmlParserJson: htmlParserJson)
|
||||
}
|
||||
|
||||
// Add an API condition as well
|
||||
if newSource.jsonParser != nil {
|
||||
newSource.preferredParser = Int16(SourcePreferredParser.siteApi.rawValue)
|
||||
} else if newSource.rssParser != nil {
|
||||
newSource.preferredParser = Int16(SourcePreferredParser.rss.rawValue)
|
||||
} else {
|
||||
newSource.preferredParser = Int16(SourcePreferredParser.scraping.rawValue)
|
||||
}
|
||||
|
||||
newSource.enabled = true
|
||||
|
||||
do {
|
||||
try backgroundContext.save()
|
||||
} catch {
|
||||
await toastModel?.updateToastDescription(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceApi = SourceApi(context: backgroundContext)
|
||||
newSourceApi.apiUrl = apiJson.apiUrl
|
||||
|
||||
if let clientIdJson = apiJson.clientId {
|
||||
let newClientId = SourceApiClientId(context: backgroundContext)
|
||||
newClientId.query = clientIdJson.query
|
||||
newClientId.urlString = clientIdJson.url
|
||||
newClientId.dynamic = clientIdJson.dynamic ?? false
|
||||
newClientId.value = clientIdJson.value
|
||||
newClientId.responseType = clientIdJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
|
||||
newClientId.expiryLength = clientIdJson.expiryLength ?? 0
|
||||
newClientId.timeStamp = Date()
|
||||
|
||||
newSourceApi.clientId = newClientId
|
||||
}
|
||||
|
||||
if let clientSecretJson = apiJson.clientSecret {
|
||||
let newClientSecret = SourceApiClientSecret(context: backgroundContext)
|
||||
newClientSecret.query = clientSecretJson.query
|
||||
newClientSecret.urlString = clientSecretJson.url
|
||||
newClientSecret.dynamic = clientSecretJson.dynamic ?? false
|
||||
newClientSecret.value = clientSecretJson.value
|
||||
newClientSecret.responseType = clientSecretJson.responseType?.rawValue ?? ApiCredentialResponseType.json.rawValue
|
||||
newClientSecret.expiryLength = clientSecretJson.expiryLength ?? 0
|
||||
newClientSecret.timeStamp = Date()
|
||||
|
||||
newSourceApi.clientSecret = newClientSecret
|
||||
}
|
||||
|
||||
newSource.api = newSourceApi
|
||||
}
|
||||
|
||||
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
|
||||
newSourceJsonParser.searchUrl = jsonParserJson.searchUrl
|
||||
newSourceJsonParser.results = jsonParserJson.results
|
||||
newSourceJsonParser.subResults = jsonParserJson.subResults
|
||||
|
||||
// Tune these complex queries to the final JSON parser format
|
||||
if let magnetLinkJson = jsonParserJson.magnetLink {
|
||||
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||
newSourceMagnetLink.query = magnetLinkJson.query
|
||||
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
|
||||
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
|
||||
|
||||
newSourceJsonParser.magnetLink = newSourceMagnetLink
|
||||
}
|
||||
|
||||
if let magnetHashJson = jsonParserJson.magnetHash {
|
||||
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
|
||||
newSourceMagnetHash.query = magnetHashJson.query
|
||||
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
|
||||
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
|
||||
|
||||
newSourceJsonParser.magnetHash = newSourceMagnetHash
|
||||
}
|
||||
|
||||
if let titleJson = jsonParserJson.title {
|
||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||
newSourceTitle.query = titleJson.query
|
||||
newSourceTitle.attribute = titleJson.attribute ?? "text"
|
||||
newSourceTitle.discriminator = titleJson.discriminator
|
||||
|
||||
newSourceJsonParser.title = newSourceTitle
|
||||
}
|
||||
|
||||
if let sizeJson = jsonParserJson.size {
|
||||
let newSourceSize = SourceSize(context: backgroundContext)
|
||||
newSourceSize.query = sizeJson.query
|
||||
newSourceSize.attribute = sizeJson.attribute ?? "text"
|
||||
newSourceSize.discriminator = sizeJson.discriminator
|
||||
|
||||
newSourceJsonParser.size = newSourceSize
|
||||
}
|
||||
|
||||
if let seedLeechJson = jsonParserJson.sl {
|
||||
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
|
||||
newSourceSeedLeech.seeders = seedLeechJson.seeders
|
||||
newSourceSeedLeech.leechers = seedLeechJson.leechers
|
||||
newSourceSeedLeech.combined = seedLeechJson.combined
|
||||
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
|
||||
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
|
||||
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
|
||||
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
|
||||
|
||||
newSourceJsonParser.seedLeech = newSourceSeedLeech
|
||||
}
|
||||
|
||||
newSource.jsonParser = newSourceJsonParser
|
||||
}
|
||||
|
||||
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceRssParser = SourceRssParser(context: backgroundContext)
|
||||
newSourceRssParser.rssUrl = rssParserJson.rssUrl
|
||||
newSourceRssParser.searchUrl = rssParserJson.searchUrl
|
||||
newSourceRssParser.items = rssParserJson.items
|
||||
|
||||
if let magnetLinkJson = rssParserJson.magnetLink {
|
||||
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||
newSourceMagnetLink.query = magnetLinkJson.query
|
||||
newSourceMagnetLink.attribute = magnetLinkJson.attribute ?? "text"
|
||||
newSourceMagnetLink.discriminator = magnetLinkJson.discriminator
|
||||
|
||||
newSourceRssParser.magnetLink = newSourceMagnetLink
|
||||
}
|
||||
|
||||
if let magnetHashJson = rssParserJson.magnetHash {
|
||||
let newSourceMagnetHash = SourceMagnetHash(context: backgroundContext)
|
||||
newSourceMagnetHash.query = magnetHashJson.query
|
||||
newSourceMagnetHash.attribute = magnetHashJson.attribute ?? "text"
|
||||
newSourceMagnetHash.discriminator = magnetHashJson.discriminator
|
||||
|
||||
newSourceRssParser.magnetHash = newSourceMagnetHash
|
||||
}
|
||||
|
||||
if let titleJson = rssParserJson.title {
|
||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||
newSourceTitle.query = titleJson.query
|
||||
newSourceTitle.attribute = titleJson.attribute ?? "text"
|
||||
newSourceTitle.discriminator = titleJson.discriminator
|
||||
|
||||
newSourceRssParser.title = newSourceTitle
|
||||
}
|
||||
|
||||
if let sizeJson = rssParserJson.size {
|
||||
let newSourceSize = SourceSize(context: backgroundContext)
|
||||
newSourceSize.query = sizeJson.query
|
||||
newSourceSize.attribute = sizeJson.attribute ?? "text"
|
||||
newSourceSize.discriminator = sizeJson.discriminator
|
||||
|
||||
newSourceRssParser.size = newSourceSize
|
||||
}
|
||||
|
||||
if let seedLeechJson = rssParserJson.sl {
|
||||
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
|
||||
newSourceSeedLeech.seeders = seedLeechJson.seeders
|
||||
newSourceSeedLeech.leechers = seedLeechJson.leechers
|
||||
newSourceSeedLeech.combined = seedLeechJson.combined
|
||||
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
|
||||
newSourceSeedLeech.discriminator = seedLeechJson.discriminator
|
||||
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
|
||||
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
|
||||
|
||||
newSourceRssParser.seedLeech = newSourceSeedLeech
|
||||
}
|
||||
|
||||
newSource.rssParser = newSourceRssParser
|
||||
}
|
||||
|
||||
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
|
||||
newSourceHtmlParser.searchUrl = htmlParserJson.searchUrl
|
||||
newSourceHtmlParser.rows = htmlParserJson.rows
|
||||
|
||||
// Adds a title complex query if present
|
||||
if let titleJson = htmlParserJson.title {
|
||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||
newSourceTitle.query = titleJson.query
|
||||
newSourceTitle.attribute = titleJson.attribute ?? "text"
|
||||
newSourceTitle.regex = titleJson.regex
|
||||
|
||||
newSourceHtmlParser.title = newSourceTitle
|
||||
}
|
||||
|
||||
// Adds a size complex query if present
|
||||
if let sizeJson = htmlParserJson.size {
|
||||
let newSourceSize = SourceSize(context: backgroundContext)
|
||||
newSourceSize.query = sizeJson.query
|
||||
newSourceSize.attribute = sizeJson.attribute ?? "text"
|
||||
newSourceSize.regex = sizeJson.regex
|
||||
|
||||
newSourceHtmlParser.size = newSourceSize
|
||||
}
|
||||
|
||||
if let seedLeechJson = htmlParserJson.sl {
|
||||
let newSourceSeedLeech = SourceSeedLeech(context: backgroundContext)
|
||||
newSourceSeedLeech.seeders = seedLeechJson.seeders
|
||||
newSourceSeedLeech.leechers = seedLeechJson.leechers
|
||||
newSourceSeedLeech.combined = seedLeechJson.combined
|
||||
newSourceSeedLeech.attribute = seedLeechJson.attribute ?? "text"
|
||||
newSourceSeedLeech.seederRegex = seedLeechJson.seederRegex
|
||||
newSourceSeedLeech.leecherRegex = seedLeechJson.leecherRegex
|
||||
|
||||
newSourceHtmlParser.seedLeech = newSourceSeedLeech
|
||||
}
|
||||
|
||||
// Adds a magnet complex query and its unique properties
|
||||
let newSourceMagnet = SourceMagnetLink(context: backgroundContext)
|
||||
newSourceMagnet.externalLinkQuery = htmlParserJson.magnet.externalLinkQuery
|
||||
newSourceMagnet.query = htmlParserJson.magnet.query
|
||||
newSourceMagnet.attribute = htmlParserJson.magnet.attribute
|
||||
newSourceMagnet.regex = htmlParserJson.magnet.regex
|
||||
|
||||
newSourceHtmlParser.magnetLink = newSourceMagnet
|
||||
|
||||
newSource.htmlParser = newSourceHtmlParser
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func addSourceList(sourceUrl: String, existingSourceList: SourceList?) async -> Bool {
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
if sourceUrl.isEmpty || URL(string: sourceUrl) == nil {
|
||||
urlErrorAlertText = "The provided source list is invalid. Please check if the URL is formatted properly."
|
||||
showUrlErrorAlert.toggle()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: sourceUrl)!))
|
||||
let rawResponse = try JSONDecoder().decode(SourceListJson.self, from: data)
|
||||
|
||||
if let existingSourceList {
|
||||
existingSourceList.urlString = sourceUrl
|
||||
existingSourceList.name = rawResponse.name
|
||||
existingSourceList.author = rawResponse.author
|
||||
|
||||
try PersistenceController.shared.container.viewContext.save()
|
||||
} else {
|
||||
let sourceListRequest = SourceList.fetchRequest()
|
||||
let urlPredicate = NSPredicate(format: "urlString == %@", sourceUrl)
|
||||
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
|
||||
sourceListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
|
||||
sourceListRequest.fetchLimit = 1
|
||||
|
||||
if (try? backgroundContext.fetch(sourceListRequest).first) != nil {
|
||||
urlErrorAlertText = "An existing source with this information was found. Please try editing the source list instead."
|
||||
showUrlErrorAlert.toggle()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let newSourceUrl = SourceList(context: backgroundContext)
|
||||
newSourceUrl.id = UUID()
|
||||
newSourceUrl.urlString = sourceUrl
|
||||
newSourceUrl.name = rawResponse.name
|
||||
newSourceUrl.author = rawResponse.author
|
||||
|
||||
try backgroundContext.save()
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
print(error)
|
||||
urlErrorAlertText = error.localizedDescription
|
||||
showUrlErrorAlert.toggle()
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
//
|
||||
// ToastViewModel.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/19/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class ToastViewModel: ObservableObject {
|
||||
enum ToastType: Identifiable {
|
||||
var id: Int {
|
||||
hashValue
|
||||
}
|
||||
|
||||
case info
|
||||
case error
|
||||
}
|
||||
|
||||
// Toast variables
|
||||
@Published var toastDescription: String? = nil {
|
||||
didSet {
|
||||
Task {
|
||||
try? await Task.sleep(seconds: 0.1)
|
||||
showToast = true
|
||||
|
||||
try? await Task.sleep(seconds: 5)
|
||||
|
||||
showToast = false
|
||||
toastType = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var showToast: Bool = false
|
||||
|
||||
public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) {
|
||||
if let newToastType {
|
||||
toastType = newToastType
|
||||
}
|
||||
|
||||
toastDescription = description
|
||||
}
|
||||
|
||||
// Default the toast type to error since the majority of toasts are errors
|
||||
@Published var toastType: ToastType = .error
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ struct AboutView: View {
|
|||
ListRowTextView(leftText: "Version", rightText: Application.shared.appVersion)
|
||||
ListRowTextView(leftText: "Build number", rightText: Application.shared.appBuild)
|
||||
ListRowTextView(leftText: "Build type", rightText: Application.shared.buildType)
|
||||
|
||||
if let commitHash = Bundle.main.commitHash {
|
||||
ListRowTextView(leftText: "Commit", rightText: commitHash)
|
||||
}
|
||||
|
||||
ListRowLinkView(text: "Discord server", link: "https://discord.gg/sYQxnuD7Fj")
|
||||
ListRowLinkView(text: "GitHub repository", link: "https://github.com/bdashore3/Ferrite")
|
||||
} header: {
|
||||
|
|
@ -26,7 +31,7 @@ struct AboutView: View {
|
|||
|
||||
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
|
||||
.textCase(.none)
|
||||
.foregroundColor(.label)
|
||||
.foregroundColor(.init(uiColor: .label))
|
||||
.font(.body)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 20)
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
//
|
||||
// BatchChoiceView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/24/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BatchChoiceView: View {
|
||||
@EnvironmentObject var debridManager: DebridManager
|
||||
@EnvironmentObject var scrapingModel: ScrapingViewModel
|
||||
@EnvironmentObject var navModel: NavigationViewModel
|
||||
|
||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||
|
||||
var body: some View {
|
||||
NavView {
|
||||
List {
|
||||
ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in
|
||||
Button(file.name) {
|
||||
debridManager.selectedRealDebridFile = file
|
||||
|
||||
if let searchResult = navModel.selectedSearchResult {
|
||||
debridManager.currentDebridTask = Task {
|
||||
await debridManager.fetchRdDownload(searchResult: searchResult)
|
||||
|
||||
if !debridManager.realDebridDownloadUrl.isEmpty {
|
||||
// The download may complete before this sheet dismisses
|
||||
try? await Task.sleep(seconds: 1)
|
||||
navModel.selectedBatchTitle = file.name
|
||||
navModel.addToHistory(name: searchResult.title, source: searchResult.source, url: debridManager.realDebridDownloadUrl, subName: file.name)
|
||||
navModel.runDebridAction(urlString: debridManager.realDebridDownloadUrl)
|
||||
}
|
||||
|
||||
debridManager.selectedRealDebridFile = nil
|
||||
debridManager.selectedRealDebridItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
navModel.currentChoiceSheet = nil
|
||||
}
|
||||
.backport.tint(.primary)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("Select a file")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
navModel.currentChoiceSheet = nil
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(seconds: 1)
|
||||
debridManager.selectedRealDebridItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BatchChoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
BatchChoiceView()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
//
|
||||
// AlertButton.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/8/22.
|
||||
//
|
||||
// Universal alert button for dynamic alert views
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AlertButton: Identifiable {
|
||||
enum Role {
|
||||
case destructive
|
||||
case cancel
|
||||
}
|
||||
|
||||
let id: UUID
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
let role: Role?
|
||||
|
||||
// Used for all buttons
|
||||
init(_ label: String, role: Role? = nil, action: @escaping () -> Void) {
|
||||
id = UUID()
|
||||
self.label = label
|
||||
self.action = action
|
||||
self.role = role
|
||||
}
|
||||
|
||||
// Used for buttons with no action
|
||||
init(_ label: String = "Cancel", role: Role? = nil) {
|
||||
id = UUID()
|
||||
self.label = label
|
||||
action = {}
|
||||
self.role = role
|
||||
}
|
||||
|
||||
func toActionButton() -> Alert.Button {
|
||||
if let role {
|
||||
switch role {
|
||||
case .cancel:
|
||||
return .cancel(Text(label))
|
||||
case .destructive:
|
||||
return .destructive(Text(label), action: action)
|
||||
}
|
||||
} else {
|
||||
return .default(Text(label), action: action)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
@ViewBuilder
|
||||
func toButtonView() -> some View {
|
||||
Button(label, role: toButtonRole(role), action: action)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func toButtonRole(_ role: Role?) -> ButtonRole? {
|
||||
if let role {
|
||||
switch role {
|
||||
case .destructive:
|
||||
return .destructive
|
||||
case .cancel:
|
||||
return .cancel
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
//
|
||||
// Backport.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/29/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct Backport<Content> {
|
||||
public let content: Content
|
||||
|
||||
public init(_ content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
var backport: Backport<Self> { Backport(self) }
|
||||
}
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func alert(isPresented: Binding<Bool>, title: String, message: String?, buttons: [AlertButton]) -> some View {
|
||||
if #available(iOS 15, *) {
|
||||
content
|
||||
.alert(
|
||||
title,
|
||||
isPresented: isPresented,
|
||||
actions: {
|
||||
ForEach(buttons) { button in
|
||||
button.toButtonView()
|
||||
}
|
||||
},
|
||||
message: {
|
||||
if let message {
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
Color.clear
|
||||
.alert(isPresented: isPresented) {
|
||||
if let primaryButton = buttons[safe: 0],
|
||||
let secondaryButton = buttons[safe: 1]
|
||||
{
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
message: message.map { Text($0) } ?? nil,
|
||||
primaryButton: primaryButton.toActionButton(),
|
||||
secondaryButton: secondaryButton.toActionButton()
|
||||
)
|
||||
} else {
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
message: message.map { Text($0) } ?? nil,
|
||||
dismissButton: buttons[0].toActionButton()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func confirmationDialog(isPresented: Binding<Bool>, title: String, message: String?, buttons: [AlertButton]) -> some View {
|
||||
if #available(iOS 15, *) {
|
||||
content
|
||||
.confirmationDialog(
|
||||
title,
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(buttons) { button in
|
||||
button.toButtonView()
|
||||
}
|
||||
} message: {
|
||||
if let message {
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.actionSheet(isPresented: isPresented) {
|
||||
ActionSheet(
|
||||
title: Text(title),
|
||||
message: message.map { Text($0) } ?? nil,
|
||||
buttons: [buttons.map { $0.toActionButton() }, [.cancel()]].flatMap { $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func tint(_ color: Color) -> some View {
|
||||
if #available(iOS 15, *) {
|
||||
content
|
||||
.tint(color)
|
||||
} else {
|
||||
content
|
||||
.accentColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// DynamicFetchRequest.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/6/22.
|
||||
//
|
||||
// Used for FetchRequests with a dynamic predicate
|
||||
// iOS 14 compatible view
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
struct DynamicFetchRequest<T: NSManagedObject, Content: View>: View {
|
||||
@FetchRequest var fetchRequest: FetchedResults<T>
|
||||
|
||||
let content: (FetchedResults<T>) -> Content
|
||||
|
||||
var body: some View {
|
||||
content(fetchRequest)
|
||||
}
|
||||
|
||||
init(predicate: NSPredicate?,
|
||||
@ViewBuilder content: @escaping (FetchedResults<T>) -> Content)
|
||||
{
|
||||
_fetchRequest = FetchRequest<T>(sortDescriptors: [], predicate: predicate)
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ struct EmptyInstructionView: View {
|
|||
.padding(.horizontal, 50)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondaryLabel)
|
||||
.foregroundColor(.init(uiColor: .secondaryLabel))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
|
|
|||
62
Ferrite/Views/CommonViews/HybridSecureField.swift
Normal file
62
Ferrite/Views/CommonViews/HybridSecureField.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// HybridSecureField.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 3/4/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HybridSecureField: View {
|
||||
enum Field: Hashable {
|
||||
case plain
|
||||
case secure
|
||||
}
|
||||
|
||||
@Binding var text: String
|
||||
var onCommit: () -> Void = {}
|
||||
|
||||
@State private var showPassword = false
|
||||
@FocusState private var focusedField: Field?
|
||||
private var isFieldDisabled: Bool = false
|
||||
|
||||
init(text: Binding<String>, onCommit: (() -> Void)? = nil, showPassword: Bool = false) {
|
||||
_text = text
|
||||
if let onCommit {
|
||||
self.onCommit = onCommit
|
||||
}
|
||||
self.showPassword = showPassword
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Group {
|
||||
if showPassword {
|
||||
TextField("Password", text: $text, onCommit: onCommit)
|
||||
.focused($focusedField, equals: .plain)
|
||||
} else {
|
||||
SecureField("Password", text: $text, onCommit: onCommit)
|
||||
.focused($focusedField, equals: .secure)
|
||||
}
|
||||
}
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.disabledAppearance(isFieldDisabled)
|
||||
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
focusedField = showPassword ? .plain : .secure
|
||||
} label: {
|
||||
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HybridSecureField {
|
||||
func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
|
||||
modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
|
||||
}
|
||||
}
|
||||
|
|
@ -21,13 +21,12 @@ struct IndeterminateProgressView: View {
|
|||
.foregroundColor(Color.accentColor)
|
||||
.frame(width: reader.size.width * 0.26, height: 6)
|
||||
.clipShape(Capsule())
|
||||
|
||||
.offset(x: -reader.size.width * 0.6, y: 0)
|
||||
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
|
||||
.animation(.default.repeatForever().speed(0.5), value: self.offset)
|
||||
.offset(x: reader.size.width * 1.2 * offset, y: 0)
|
||||
.animation(.default.repeatForever().speed(0.5), value: offset)
|
||||
.onAppear {
|
||||
withAnimation {
|
||||
self.offset = 1
|
||||
offset = 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// InlineHeader.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/5/22.
|
||||
//
|
||||
// For iOS 15's weird defaults regarding sectioned list padding
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct InlineHeader: View {
|
||||
let title: String
|
||||
|
||||
init(_ title: String) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16, *) {
|
||||
Text(title)
|
||||
} else if #available(iOS 15, *) {
|
||||
Text(title)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 0))
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Ferrite/Views/CommonViews/LibraryHeaderView.swift
Normal file
15
Ferrite/Views/CommonViews/LibraryHeaderView.swift
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// LibraryHeaderView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/12/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LibraryHeaderView: View {
|
||||
@EnvironmentObject var debridManager: DebridManager
|
||||
|
||||
@Binding var selectedSegment: LibraryPickerSegment
|
||||
var body: some View {}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ struct ListRowLinkView: View {
|
|||
Image(systemName: "arrow.up.forward.app.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.trailing, -5)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ struct ListRowButtonView: View {
|
|||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, -5)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,10 +68,10 @@ struct ListRowTextView: View {
|
|||
|
||||
if let rightText {
|
||||
Text(rightText)
|
||||
} else {
|
||||
Image(systemName: rightSymbol!)
|
||||
.foregroundColor(.gray)
|
||||
} else if let rightSymbol {
|
||||
Image(systemName: rightSymbol)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, -5)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
//
|
||||
// ConditionalContextMenu.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/3/22.
|
||||
//
|
||||
// Used as a workaround for iOS 15 not updating context views with conditional variables
|
||||
// A stateful ID is required for the contextMenu to update itself.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ConditionalContextMenu<InternalContent: View, ID: Hashable>: ViewModifier {
|
||||
let internalContent: () -> InternalContent
|
||||
let id: ID
|
||||
|
||||
init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) {
|
||||
self.internalContent = internalContent
|
||||
self.id = id
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.contextMenu {
|
||||
internalContent()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
Color.clear
|
||||
.contextMenu {
|
||||
internalContent()
|
||||
}
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
//
|
||||
// ConditionalId.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 9/4/22.
|
||||
//
|
||||
// Applies an ID below iOS 16
|
||||
// This is due to ID workarounds making iOS 16 apps crash
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ConditionalId<ID: Hashable>: ViewModifier {
|
||||
let id: ID
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.id(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct DisableInteraction: ViewModifier {
|
||||
struct DisableInteractionModifier: ViewModifier {
|
||||
let disabled: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct DisabledAppearance: ViewModifier {
|
||||
struct DisabledAppearanceModifier: ViewModifier {
|
||||
let disabled: Bool
|
||||
let dimmedOpacity: Double?
|
||||
let animation: Animation?
|
||||
|
|
|
|||
|
|
@ -5,24 +5,18 @@
|
|||
// Created by Brian Dashore on 9/4/22.
|
||||
//
|
||||
// Removes the top padding on unsectioned lists
|
||||
// If a list is sectioned, see InlineHeader
|
||||
//
|
||||
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
struct InlinedListModifier: ViewModifier {
|
||||
let inset: CGFloat
|
||||
|
||||
struct InlinedList: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.introspectCollectionView { collectionView in
|
||||
collectionView.contentInset.top = -20
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.introspectTableView { tableView in
|
||||
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 20))
|
||||
}
|
||||
}
|
||||
content
|
||||
.introspect(.list, on: .iOS(.v16, .v17, .v18)) { collectionView in
|
||||
collectionView.contentInset.top = inset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
//
|
||||
// NavView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 7/4/22.
|
||||
// Contributed by Mantton
|
||||
//
|
||||
// A wrapper that switches between NavigationStack and the legacy NavigationView
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NavView<Content: View>: View {
|
||||
let content: () -> Content
|
||||
init(@ViewBuilder _ content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16, *) {
|
||||
NavigationStack {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
NavigationView {
|
||||
content()
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Ferrite/Views/CommonViews/SearchableContent.swift
Normal file
37
Ferrite/Views/CommonViews/SearchableContent.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// SearchableContent.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/11/23.
|
||||
//
|
||||
// View to link animations together with searchbar
|
||||
// Passes through geometry proxy and last height vars for any comparison
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SearchableContent<Content: View>: View {
|
||||
@Binding var searching: Bool
|
||||
|
||||
@State private var lastHeight: CGFloat = 0.0
|
||||
|
||||
@ViewBuilder var content: (Bool) -> Content
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geom in
|
||||
// Return if the height has changed as a closure variable for child transactions
|
||||
content(geom.size.height != lastHeight)
|
||||
.backport.onAppear {
|
||||
lastHeight = geom.size.height
|
||||
}
|
||||
.onChange(of: geom.size.height) { newHeight in
|
||||
lastHeight = newHeight
|
||||
}
|
||||
.transaction {
|
||||
if geom.size.height != lastHeight, searching {
|
||||
$0.animation = .default.speed(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Ferrite/Views/CommonViews/SectionHeaderView.swift
Normal file
20
Ferrite/Views/CommonViews/SectionHeaderView.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// SectionHeaderView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/15/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SectionHeaderView: View {
|
||||
var body: some View {
|
||||
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
}
|
||||
}
|
||||
|
||||
struct SectionHeaderView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SectionHeaderView()
|
||||
}
|
||||
}
|
||||
28
Ferrite/Views/CommonViews/Tag.swift
Normal file
28
Ferrite/Views/CommonViews/Tag.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// Tag.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/7/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Tag: View {
|
||||
let name: String
|
||||
let color: Color?
|
||||
var horizontalPadding: CGFloat = 7
|
||||
var verticalPadding: CGFloat = 4
|
||||
|
||||
var body: some View {
|
||||
Text(name.capitalizingFirstLetter())
|
||||
.font(.caption)
|
||||
.opacity(0.8)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.foregroundColor(color.map { $0 } ?? .init(uiColor: .tertiaryLabel))
|
||||
.opacity(0.3)
|
||||
)
|
||||
}
|
||||
}
|
||||
75
Ferrite/Views/CommonViews/TestHostingView.swift
Normal file
75
Ferrite/Views/CommonViews/TestHostingView.swift
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
// TestHostingView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 2/13/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TestHostingView: View {
|
||||
@State private var textName = "First"
|
||||
@State private var secondTextName = "First"
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
Menu {
|
||||
Picker("", selection: $textName) {
|
||||
Text("First").tag("First")
|
||||
Text("Second").tag("Second")
|
||||
Text("Third").tag("Third")
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 2) {
|
||||
Text(textName)
|
||||
.opacity(0.6)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.tertiaryLabel)
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 7)
|
||||
.font(.caption, weight: .bold)
|
||||
.background(Capsule().foregroundColor(.secondarySystemFill))
|
||||
}
|
||||
.id(textName)
|
||||
.transaction {
|
||||
$0.animation = .none
|
||||
}
|
||||
|
||||
Menu {
|
||||
Picker("", selection: $secondTextName) {
|
||||
Text("First").tag("First")
|
||||
Text("Second").tag("Second")
|
||||
Text("Third").tag("Third")
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 2) {
|
||||
Text(secondTextName)
|
||||
.opacity(0.6)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.tertiaryLabel)
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 7)
|
||||
.font(.caption, weight: .bold)
|
||||
.background(Capsule().foregroundColor(.secondarySystemFill))
|
||||
}
|
||||
.id(secondTextName)
|
||||
.transaction {
|
||||
$0.animation = .none
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TestHostingView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TestHostingView()
|
||||
}
|
||||
}
|
||||
41
Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift
Normal file
41
Ferrite/Views/ComponentViews/Debrid/DebridLabelView.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// DebridLabelView.swift
|
||||
// Ferrite
|
||||
//
|
||||
// Created by Brian Dashore on 11/27/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DebridLabelView: View {
|
||||
@Store var debridSource: DebridSource
|
||||
|
||||
@State var cloudLinks: [String] = []
|
||||
@State var tagColor: Color = .red
|
||||
var magnet: Magnet?
|
||||
|
||||
var body: some View {
|
||||
Tag(
|
||||
name: debridSource.abbreviation,
|
||||
color: getTagColor(),
|
||||
horizontalPadding: 5,
|
||||
verticalPadding: 3
|
||||
)
|
||||
}
|
||||
|
||||
func getTagColor() -> Color {
|
||||
if let magnet, cloudLinks.isEmpty {
|
||||
guard let match = debridSource.IAValues.first(where: { magnet.hash == $0.magnet.hash }) else {
|
||||
return .red
|
||||
}
|
||||
|
||||
return match.files.count > 1 ? .orange : .green
|
||||
} else if cloudLinks.count == 1 {
|
||||
return .green
|
||||
} else if cloudLinks.count > 1 {
|
||||
return .orange
|
||||
} else {
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue