Compare commits
118 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 |
139 changed files with 5170 additions and 3415 deletions
8
.github/workflows/nightly.yml
vendored
8
.github/workflows/nightly.yml
vendored
|
|
@ -6,13 +6,13 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Xcode
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
with:
|
with:
|
||||||
xcode-version: latest
|
xcode-version: latest-stable
|
||||||
- name: Get commit SHA
|
- name: Get commit SHA
|
||||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
|
cp -r build/Ferrite.xcarchive/Products/Applications/Ferrite.app Payload
|
||||||
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
|
zip -r Ferrite-iOS_nightly-${{ env.sha_short }}.ipa Payload
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
name: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
||||||
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
path: Ferrite-iOS_nightly-${{ env.sha_short }}.ipa
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -7,13 +7,13 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Xcode
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
with:
|
with:
|
||||||
xcode-version: latest
|
xcode-version: latest-stable
|
||||||
- name: Build
|
- name: Build
|
||||||
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
run: xcodebuild -scheme Ferrite -configuration Release archive -archivePath build/Ferrite.xcarchive CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
|
||||||
env:
|
env:
|
||||||
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
|
zip -j Ferrite-iOS_v${{ env.app_version }}.ipa.zip Ferrite-iOS_v${{ env.app_version }}.ipa
|
||||||
- name: Upload release
|
- name: Upload release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip
|
files: Ferrite-iOS_v${{ env.app_version }}.ipa.zip
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
5.7
|
5.8
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
|
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */; };
|
||||||
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
|
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C5293424A200ECA142 /* DebridLabelView.swift */; };
|
||||||
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
|
0C0755C8293425B500ECA142 /* DebridManagerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0755C7293425B500ECA142 /* DebridManagerModels.swift */; };
|
||||||
|
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6032C1A859B00808A46 /* FormDataBody.swift */; };
|
||||||
|
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */; };
|
||||||
|
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */; };
|
||||||
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
|
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */; };
|
||||||
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
|
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */; };
|
||||||
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
|
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */; };
|
||||||
|
|
@ -20,15 +23,12 @@
|
||||||
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; };
|
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */; };
|
||||||
0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; };
|
0C1A3E5629C9488C00DA9730 /* CodableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */; };
|
||||||
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
|
0C2886D22960AC2800D6FC16 /* DebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */; };
|
||||||
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */; };
|
0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */; };
|
||||||
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; };
|
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2D9652299316CC00A504B6 /* Tag.swift */; };
|
||||||
0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C2DD4CE29A6D47400293FC3 /* SwiftUIX */; };
|
|
||||||
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; };
|
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */; };
|
||||||
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; };
|
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */; };
|
||||||
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; };
|
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB522890D19D002BD219 /* AboutView.swift */; };
|
||||||
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; };
|
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; };
|
||||||
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */; };
|
|
||||||
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */; };
|
|
||||||
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */; };
|
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD43E29B6968D006429DB /* KodiEditorView.swift */; };
|
||||||
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */; };
|
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */; };
|
||||||
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */; };
|
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */; };
|
||||||
|
|
@ -39,10 +39,8 @@
|
||||||
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
|
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
|
||||||
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; };
|
0C422E7E293542EA00486D65 /* PremiumizeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */; };
|
||||||
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
|
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C422E7F293542F300486D65 /* PremiumizeModels.swift */; };
|
||||||
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5952932F2D5008057A0 /* DebridPickerView.swift */; };
|
|
||||||
0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; };
|
0C42B5982932F6DD008057A0 /* Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C42B5972932F6DD008057A0 /* Set.swift */; };
|
||||||
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.swift */; };
|
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C445C61293F9A0B0060744D /* Bundle.swift */; };
|
||||||
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = 0C448BE829A135F100F4E266 /* Introspect-Static */; };
|
|
||||||
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; };
|
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2A728D4DDDC007711AE /* Application.swift */; };
|
||||||
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; };
|
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AC28D51C63007711AE /* BackupManager.swift */; };
|
||||||
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; };
|
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44E2AE28D52E8A007711AE /* BackupsView.swift */; };
|
||||||
|
|
@ -56,10 +54,7 @@
|
||||||
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; };
|
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; };
|
||||||
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
|
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; };
|
||||||
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; };
|
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; };
|
||||||
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */; };
|
|
||||||
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */; };
|
|
||||||
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
|
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; };
|
||||||
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */; };
|
|
||||||
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
|
0C64A4B4288903680079976D /* Base32 in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B3288903680079976D /* Base32 */; };
|
||||||
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
|
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
|
||||||
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; };
|
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */; };
|
||||||
|
|
@ -71,9 +66,11 @@
|
||||||
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
|
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
|
||||||
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; };
|
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */; };
|
||||||
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
|
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6C7C9C29315292002DF910 /* AllDebridModels.swift */; };
|
||||||
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; };
|
0C7075E429D374C50093DB2D /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E329D374C50093DB2D /* Color.swift */; };
|
||||||
|
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7075E529D3845D0093DB2D /* ShareSheet.swift */; };
|
||||||
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; };
|
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; };
|
||||||
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
|
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
|
||||||
|
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 0C748ED929D9256D0049B8BE /* Yams */; };
|
||||||
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; };
|
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; };
|
||||||
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; };
|
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */; };
|
||||||
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; };
|
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */; };
|
||||||
|
|
@ -83,6 +80,7 @@
|
||||||
0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; };
|
0C794B6D289EFA2E00DD1CC8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C794B6C289EFA2E00DD1CC8 /* LaunchScreen.storyboard */; };
|
||||||
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; };
|
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC052899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift */; };
|
||||||
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; };
|
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C79DC062899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift */; };
|
||||||
|
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */; };
|
||||||
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; };
|
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C128528DAA3CD00381CD1 /* URL.swift */; };
|
||||||
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; };
|
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7D11FD28AA03FE00ED92DB /* View.swift */; };
|
||||||
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; };
|
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7ED14028D61BBA009E29AD /* BackupModels.swift */; };
|
||||||
|
|
@ -92,7 +90,20 @@
|
||||||
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; };
|
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */; };
|
||||||
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */; };
|
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */; };
|
||||||
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */; };
|
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */; };
|
||||||
|
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */; };
|
||||||
|
0C84FCE329E4B42600B0DFE4 /* IAFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */; };
|
||||||
|
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */; };
|
||||||
|
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
|
||||||
|
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */; };
|
||||||
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; };
|
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C871BDE29994D9D005279AC /* FilterLabelView.swift */; };
|
||||||
|
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E482C188808003B17B5 /* TorBoxWrapper.swift */; };
|
||||||
|
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */; };
|
||||||
|
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8AE2472C0FFB6600701675 /* Store.swift */; };
|
||||||
|
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
|
||||||
|
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
|
||||||
|
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
|
||||||
|
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */; };
|
||||||
|
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */; };
|
||||||
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */; };
|
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */; };
|
||||||
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; };
|
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05456288EE58200850554 /* SettingsPluginListView.swift */; };
|
||||||
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; };
|
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA05458288EE9E600850554 /* PluginManager.swift */; };
|
||||||
|
|
@ -100,7 +111,6 @@
|
||||||
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
|
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BB288903F000DE2211 /* SettingsView.swift */; };
|
||||||
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
|
0CA148D7288903F000DE2211 /* LoginWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BC288903F000DE2211 /* LoginWebView.swift */; };
|
||||||
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */; };
|
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148BD288903F000DE2211 /* ActionChoiceView.swift */; };
|
||||||
0CA148DB288903F000DE2211 /* NavView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C1288903F000DE2211 /* NavView.swift */; };
|
|
||||||
0CA148DC288903F000DE2211 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C2288903F000DE2211 /* Assets.xcassets */; };
|
0CA148DC288903F000DE2211 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C2288903F000DE2211 /* Assets.xcassets */; };
|
||||||
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; };
|
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */; };
|
||||||
0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; };
|
0CA148DF288903F000DE2211 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CA148C6288903F000DE2211 /* Preview Assets.xcassets */; };
|
||||||
|
|
@ -122,28 +132,35 @@
|
||||||
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
|
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
|
||||||
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; };
|
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA429F728C5098D000D0610 /* DateFormatter.swift */; };
|
||||||
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
|
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
|
||||||
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAF9318296399190050812A /* PremiumizeCloudView.swift */; };
|
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */; };
|
||||||
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */; };
|
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB0AB5E29BD2A200015422C /* KodiServerView.swift */; };
|
||||||
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
|
|
||||||
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
|
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516428C5A5D700DCA721 /* InlinedList.swift */; };
|
||||||
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516928C5B4A600DCA721 /* InlineHeader.swift */; };
|
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */; };
|
||||||
|
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB725332C123E760047FC0B /* CloudMagnetView.swift */; };
|
||||||
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; };
|
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBAB83528D12ED500AC903E /* DisableInteraction.swift */; };
|
||||||
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
|
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
|
||||||
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
|
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
|
||||||
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; };
|
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC7704288DE7F40054BE44 /* PersistenceController.swift */; };
|
||||||
|
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */; };
|
||||||
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; };
|
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; };
|
||||||
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; };
|
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; };
|
||||||
|
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */; };
|
||||||
|
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; };
|
||||||
|
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */; };
|
||||||
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
|
0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; };
|
||||||
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
|
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */; };
|
||||||
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */; };
|
|
||||||
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */; };
|
0CD5F1FD299C083B00476DDB /* PluginPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */; };
|
||||||
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; };
|
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD72E16293D9928001A7EA4 /* Array.swift */; };
|
||||||
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; };
|
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDCB91728C662640098B513 /* EmptyInstructionView.swift */; };
|
||||||
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; };
|
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */ = {isa = PBXBuildFile; productRef = 0CDDDE042935235E006810B1 /* BetterSafariView */; };
|
||||||
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
|
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
|
||||||
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE66B3928E640D200F69346 /* Backport.swift */; };
|
|
||||||
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; };
|
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */; };
|
||||||
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; };
|
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */; };
|
||||||
|
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */; };
|
||||||
|
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */; };
|
||||||
|
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */; };
|
||||||
|
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */; };
|
||||||
|
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -152,6 +169,9 @@
|
||||||
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0C03EB70296F619900162E9A /* PluginList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginList+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
|
0C0755C5293424A200ECA142 /* DebridLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridLabelView.swift; sourceTree = "<group>"; };
|
||||||
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
|
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridManagerModels.swift; sourceTree = "<group>"; };
|
||||||
|
0C07C6032C1A859B00808A46 /* FormDataBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormDataBody.swift; sourceTree = "<group>"; };
|
||||||
|
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudModels.swift; sourceTree = "<group>"; };
|
||||||
|
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffCloudWrapper.swift; sourceTree = "<group>"; };
|
||||||
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = "<group>"; };
|
0C0974AF29CCAAAF006DE7A3 /* OperatingSystemVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystemVersion.swift; sourceTree = "<group>"; };
|
||||||
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
|
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceModels.swift; sourceTree = "<group>"; };
|
||||||
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
|
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginAggregateView.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -160,14 +180,12 @@
|
||||||
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
|
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
|
||||||
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = "<group>"; };
|
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableWrapper.swift; sourceTree = "<group>"; };
|
||||||
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
|
0C2886D12960AC2800D6FC16 /* DebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridCloudView.swift; sourceTree = "<group>"; };
|
||||||
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealDebridCloudView.swift; sourceTree = "<group>"; };
|
0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortFilterView.swift; sourceTree = "<group>"; };
|
||||||
0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
0C2D9652299316CC00A504B6 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||||
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceJsonParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
0C32FB522890D19D002BD219 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||||
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
|
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
|
||||||
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = "<group>"; };
|
|
||||||
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
|
|
||||||
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiEditorView.swift; sourceTree = "<group>"; };
|
0C3DD43E29B6968D006429DB /* KodiEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiEditorView.swift; sourceTree = "<group>"; };
|
||||||
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataClass.swift"; sourceTree = "<group>"; };
|
0C3DD44029B6ACD9006429DB /* KodiServer+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0C3DD44129B6ACD9006429DB /* KodiServer+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KodiServer+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -179,7 +197,6 @@
|
||||||
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
|
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
|
||||||
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; };
|
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeWrapper.swift; sourceTree = "<group>"; };
|
||||||
0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = "<group>"; };
|
0C422E7F293542F300486D65 /* PremiumizeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeModels.swift; sourceTree = "<group>"; };
|
||||||
0C42B5952932F2D5008057A0 /* DebridPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridPickerView.swift; sourceTree = "<group>"; };
|
|
||||||
0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = "<group>"; };
|
0C42B5972932F6DD008057A0 /* Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Set.swift; sourceTree = "<group>"; };
|
||||||
0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
|
0C445C61293F9A0B0060744D /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
|
||||||
0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
0C44E2A728D4DDDC007711AE /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -194,10 +211,7 @@
|
||||||
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
|
0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; };
|
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = "<group>"; };
|
||||||
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppearHandler.swift; sourceTree = "<group>"; };
|
|
||||||
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = "<group>"; };
|
|
||||||
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
|
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = "<group>"; };
|
||||||
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridCloudView.swift; sourceTree = "<group>"; };
|
|
||||||
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; };
|
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiWrapper.swift; sourceTree = "<group>"; };
|
||||||
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; };
|
0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKodiView.swift; sourceTree = "<group>"; };
|
||||||
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; };
|
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiModels.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -207,7 +221,8 @@
|
||||||
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
|
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
|
||||||
0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = "<group>"; };
|
0C6C7C9A2931521B002DF910 /* AllDebridWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridWrapper.swift; sourceTree = "<group>"; };
|
||||||
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = "<group>"; };
|
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDebridModels.swift; sourceTree = "<group>"; };
|
||||||
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = "<group>"; };
|
0C7075E329D374C50093DB2D /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
|
||||||
|
0C7075E529D3845D0093DB2D /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
|
||||||
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = "<group>"; };
|
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = "<group>"; };
|
||||||
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
|
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
|
||||||
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -227,7 +242,20 @@
|
||||||
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Source+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceHtmlParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
|
0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceFilterView.swift; sourceTree = "<group>"; };
|
||||||
|
0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAFilterView.swift; sourceTree = "<group>"; };
|
||||||
|
0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedDebridFilterView.swift; sourceTree = "<group>"; };
|
||||||
|
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.swift; sourceTree = "<group>"; };
|
||||||
|
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterAmountLabelView.swift; sourceTree = "<group>"; };
|
||||||
0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = "<group>"; };
|
0C871BDE29994D9D005279AC /* FilterLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterLabelView.swift; sourceTree = "<group>"; };
|
||||||
|
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxWrapper.swift; sourceTree = "<group>"; };
|
||||||
|
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorBoxModels.swift; sourceTree = "<group>"; };
|
||||||
|
0C8AE2472C0FFB6600701675 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
||||||
|
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoView.swift; sourceTree = "<group>"; };
|
||||||
|
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsBaseUrlView.swift; sourceTree = "<group>"; };
|
||||||
|
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsApiView.swift; sourceTree = "<group>"; };
|
||||||
|
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsMethodView.swift; sourceTree = "<group>"; };
|
||||||
|
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDebridLinkView.swift; sourceTree = "<group>"; };
|
||||||
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionPickerView.swift; sourceTree = "<group>"; };
|
0C95D8D728A55B03005E22B3 /* DefaultActionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultActionPickerView.swift; sourceTree = "<group>"; };
|
||||||
0CA05456288EE58200850554 /* SettingsPluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPluginListView.swift; sourceTree = "<group>"; };
|
0CA05456288EE58200850554 /* SettingsPluginListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPluginListView.swift; sourceTree = "<group>"; };
|
||||||
0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
|
0CA05458288EE9E600850554 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -235,7 +263,6 @@
|
||||||
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
0CA148BB288903F000DE2211 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; };
|
0CA148BC288903F000DE2211 /* LoginWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginWebView.swift; sourceTree = "<group>"; };
|
||||||
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionChoiceView.swift; sourceTree = "<group>"; };
|
0CA148BD288903F000DE2211 /* ActionChoiceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionChoiceView.swift; sourceTree = "<group>"; };
|
||||||
0CA148C1288903F000DE2211 /* NavView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavView.swift; sourceTree = "<group>"; };
|
|
||||||
0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
0CA148C2288903F000DE2211 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = "<group>"; };
|
0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrapingViewModel.swift; sourceTree = "<group>"; };
|
||||||
0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
0CA148C6288903F000DE2211 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||||
|
|
@ -257,28 +284,35 @@
|
||||||
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
|
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
|
||||||
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
|
0CA429F728C5098D000D0610 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
|
||||||
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0CAF9318296399190050812A /* PremiumizeCloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumizeCloudView.swift; sourceTree = "<group>"; };
|
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
|
||||||
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = "<group>"; };
|
0CB0AB5E29BD2A200015422C /* KodiServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KodiServerView.swift; sourceTree = "<group>"; };
|
||||||
0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = "<group>"; };
|
|
||||||
0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = "<group>"; };
|
0CB6516428C5A5D700DCA721 /* InlinedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinedList.swift; sourceTree = "<group>"; };
|
||||||
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineHeader.swift; sourceTree = "<group>"; };
|
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDownloadView.swift; sourceTree = "<group>"; };
|
||||||
|
0CB725332C123E760047FC0B /* CloudMagnetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMagnetView.swift; sourceTree = "<group>"; };
|
||||||
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = "<group>"; };
|
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableInteraction.swift; sourceTree = "<group>"; };
|
||||||
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
|
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
|
||||||
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
|
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
|
||||||
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
0CBC7704288DE7F40054BE44 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
||||||
|
0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedSearchable.swift; sourceTree = "<group>"; };
|
||||||
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; };
|
0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerriteKeychain.swift; sourceTree = "<group>"; };
|
||||||
|
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoMetaView.swift; sourceTree = "<group>"; };
|
||||||
|
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoAboutView.swift; sourceTree = "<group>"; };
|
||||||
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
|
0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = "<group>"; };
|
||||||
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; };
|
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisabledAppearance.swift; sourceTree = "<group>"; };
|
||||||
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScopeBar.swift; sourceTree = "<group>"; };
|
|
||||||
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = "<group>"; };
|
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = "<group>"; };
|
||||||
0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
|
0CD72E16293D9928001A7EA4 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
|
||||||
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; };
|
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyInstructionView.swift; sourceTree = "<group>"; };
|
||||||
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
|
0CE1C4172981E8D700418F20 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = "<group>"; };
|
||||||
0CE66B3928E640D200F69346 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
|
|
||||||
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = "<group>"; };
|
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFilterHeaderView.swift; sourceTree = "<group>"; };
|
||||||
0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = "<group>"; };
|
0CEC8AB1299B3B57007BFE8F /* LibraryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryPickerView.swift; sourceTree = "<group>"; };
|
||||||
|
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
|
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRequest+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
|
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debrid.swift; sourceTree = "<group>"; };
|
||||||
|
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebridModels.swift; sourceTree = "<group>"; };
|
||||||
|
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -287,13 +321,13 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */,
|
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */,
|
||||||
0C448BE929A135F100F4E266 /* Introspect-Static in Frameworks */,
|
|
||||||
0C64A4B4288903680079976D /* Base32 in Frameworks */,
|
0C64A4B4288903680079976D /* Base32 in Frameworks */,
|
||||||
0C2DD4CF29A6D47400293FC3 /* SwiftUIX in Frameworks */,
|
|
||||||
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
|
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */,
|
||||||
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
|
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */,
|
||||||
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
|
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */,
|
||||||
|
0C748EDA29D9256D0049B8BE /* Yams in Frameworks */,
|
||||||
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
|
0CDDDE052935235E006810B1 /* BetterSafariView in Frameworks */,
|
||||||
|
0C7B4A002CB051550048FA28 /* SwiftUIIntrospect in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -312,6 +346,7 @@
|
||||||
0C0755C32934244500ECA142 /* ComponentViews */ = {
|
0C0755C32934244500ECA142 /* ComponentViews */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0C84FCDB29E4B3F400B0DFE4 /* Filters */,
|
||||||
0C3E00D4296F560800ECECB2 /* Plugin */,
|
0C3E00D4296F560800ECECB2 /* Plugin */,
|
||||||
0C0755C42934245800ECA142 /* Debrid */,
|
0C0755C42934245800ECA142 /* Debrid */,
|
||||||
0CA3B23528C265FD00616D3A /* Library */,
|
0CA3B23528C265FD00616D3A /* Library */,
|
||||||
|
|
@ -324,7 +359,6 @@
|
||||||
0C0755C42934245800ECA142 /* Debrid */ = {
|
0C0755C42934245800ECA142 /* Debrid */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0C42B5952932F2D5008057A0 /* DebridPickerView.swift */,
|
|
||||||
0C0755C5293424A200ECA142 /* DebridLabelView.swift */,
|
0C0755C5293424A200ECA142 /* DebridLabelView.swift */,
|
||||||
);
|
);
|
||||||
path = Debrid;
|
path = Debrid;
|
||||||
|
|
@ -357,6 +391,8 @@
|
||||||
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
|
0C84F47B2895BFED0074B7C9 /* Source+CoreDataProperties.swift */,
|
||||||
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
|
0C84F47C2895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift */,
|
||||||
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
|
0C84F47D2895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift */,
|
||||||
|
0CEE11AF2C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift */,
|
||||||
|
0CEE11B02C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift */,
|
||||||
);
|
);
|
||||||
path = Classes;
|
path = Classes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -368,6 +404,8 @@
|
||||||
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
|
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
|
||||||
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
|
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
|
||||||
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
|
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
|
||||||
|
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */,
|
||||||
|
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */,
|
||||||
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
|
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
|
||||||
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
|
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
|
||||||
0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
|
0C0167DB29293FA900B65783 /* RealDebridModels.swift */,
|
||||||
|
|
@ -376,6 +414,8 @@
|
||||||
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
|
0C3E00D7296F5B9A00ECECB2 /* PluginModels.swift */,
|
||||||
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
|
0C6771F929B3D1AE005D38D2 /* KodiModels.swift */,
|
||||||
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
|
0C1A3E5129C8A7F500DA9730 /* SettingsModels.swift */,
|
||||||
|
0C890E4A2C188FA7003B17B5 /* TorBoxModels.swift */,
|
||||||
|
0C07C6052C1B2F7600808A46 /* OffCloudModels.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -383,9 +423,8 @@
|
||||||
0C2886D52960C4F800D6FC16 /* Cloud */ = {
|
0C2886D52960C4F800D6FC16 /* Cloud */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0C2886D62960C50900D6FC16 /* RealDebridCloudView.swift */,
|
0CB725312C123E6F0047FC0B /* CloudDownloadView.swift */,
|
||||||
0CAF9318296399190050812A /* PremiumizeCloudView.swift */,
|
0CB725332C123E760047FC0B /* CloudMagnetView.swift */,
|
||||||
0C5FCB04296744F300849E87 /* AllDebridCloudView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Cloud;
|
path = Cloud;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -412,11 +451,13 @@
|
||||||
0C3E00D4296F560800ECECB2 /* Plugin */ = {
|
0C3E00D4296F560800ECECB2 /* Plugin */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0CD4030829DA01A3008D9F03 /* Info */,
|
||||||
0C44E2AA28D4E09B007711AE /* Buttons */,
|
0C44E2AA28D4E09B007711AE /* Buttons */,
|
||||||
0C794B65289DAC9F00DD1CC8 /* Source */,
|
0C794B65289DAC9F00DD1CC8 /* Source */,
|
||||||
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */,
|
0C0D50E6288DFF850035ECC8 /* PluginAggregateView.swift */,
|
||||||
0C5005512992B6750064606A /* PluginTagsView.swift */,
|
0C5005512992B6750064606A /* PluginTagsView.swift */,
|
||||||
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */,
|
0CD5F1FC299C083B00476DDB /* PluginPickerView.swift */,
|
||||||
|
0C8DC35129CE287E008A83AD /* PluginInfoView.swift */,
|
||||||
);
|
);
|
||||||
path = Plugin;
|
path = Plugin;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -426,6 +467,9 @@
|
||||||
children = (
|
children = (
|
||||||
0C44E2A728D4DDDC007711AE /* Application.swift */,
|
0C44E2A728D4DDDC007711AE /* Application.swift */,
|
||||||
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
|
0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */,
|
||||||
|
0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */,
|
||||||
|
0C8AE2472C0FFB6600701675 /* Store.swift */,
|
||||||
|
0C07C6032C1A859B00808A46 /* FormDataBody.swift */,
|
||||||
);
|
);
|
||||||
path = Utils;
|
path = Utils;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -433,13 +477,9 @@
|
||||||
0C44E2A928D4DFC4007711AE /* Modifiers */ = {
|
0C44E2A928D4DFC4007711AE /* Modifiers */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */,
|
|
||||||
0CB6516228C5A57300DCA721 /* ConditionalId.swift */,
|
|
||||||
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
|
0CD5E78828CD932B001BF684 /* DisabledAppearance.swift */,
|
||||||
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
|
0CBAB83528D12ED500AC903E /* DisableInteraction.swift */,
|
||||||
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
|
0CB6516428C5A5D700DCA721 /* InlinedList.swift */,
|
||||||
0CD5F1FA299BEFBE00476DDB /* CustomScopeBar.swift */,
|
|
||||||
0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */,
|
|
||||||
);
|
);
|
||||||
path = Modifiers;
|
path = Modifiers;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -459,6 +499,7 @@
|
||||||
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
|
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
|
||||||
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */,
|
0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */,
|
||||||
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */,
|
0CEC8AAD299B31B6007BFE8F /* SearchFilterHeaderView.swift */,
|
||||||
|
0CB0115A29D36D9E009AFEDE /* SearchResultsView.swift */,
|
||||||
);
|
);
|
||||||
path = SearchResult;
|
path = SearchResult;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -467,6 +508,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0CE1C4172981E8D700418F20 /* Plugin.swift */,
|
0CE1C4172981E8D700418F20 /* Plugin.swift */,
|
||||||
|
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */,
|
||||||
);
|
);
|
||||||
path = Protocols;
|
path = Protocols;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -475,10 +517,26 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
|
0C733286289C4C820058D1FE /* SourceSettingsView.swift */,
|
||||||
|
0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */,
|
||||||
|
0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */,
|
||||||
|
0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.swift */,
|
||||||
);
|
);
|
||||||
path = Source;
|
path = Source;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0C84FCDB29E4B3F400B0DFE4 /* Filters */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0C84FCE029E4B41D00B0DFE4 /* SourceFilterView.swift */,
|
||||||
|
0C84FCE229E4B42600B0DFE4 /* IAFilterView.swift */,
|
||||||
|
0C84FCE429E4B43200B0DFE4 /* SelectedDebridFilterView.swift */,
|
||||||
|
0C871BDE29994D9D005279AC /* FilterLabelView.swift */,
|
||||||
|
0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.swift */,
|
||||||
|
0C2B028E29E9E61E00DCF127 /* SortFilterView.swift */,
|
||||||
|
);
|
||||||
|
path = Filters;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
0CA0545C288F7CB200850554 /* Settings */ = {
|
0CA0545C288F7CB200850554 /* Settings */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -489,6 +547,7 @@
|
||||||
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
|
0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */,
|
||||||
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
|
0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */,
|
||||||
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
|
0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */,
|
||||||
|
0C93DED72CF80101009EA8D2 /* SettingsDebridLinkView.swift */,
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -517,14 +576,8 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0C44E2A928D4DFC4007711AE /* Modifiers */,
|
0C44E2A928D4DFC4007711AE /* Modifiers */,
|
||||||
0CE66B3928E640D200F69346 /* Backport.swift */,
|
|
||||||
0C391ECA28CAA44B009F1CA1 /* AlertButton.swift */,
|
|
||||||
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */,
|
|
||||||
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
|
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
|
||||||
0C871BDE29994D9D005279AC /* FilterLabelView.swift */,
|
|
||||||
0CA148C1288903F000DE2211 /* NavView.swift */,
|
|
||||||
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
|
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
|
||||||
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
|
|
||||||
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
|
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
|
||||||
0C2D9652299316CC00A504B6 /* Tag.swift */,
|
0C2D9652299316CC00A504B6 /* Tag.swift */,
|
||||||
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */,
|
0C6771FB29B3E0DB005D38D2 /* HybridSecureField.swift */,
|
||||||
|
|
@ -546,6 +599,7 @@
|
||||||
0CD72E16293D9928001A7EA4 /* Array.swift */,
|
0CD72E16293D9928001A7EA4 /* Array.swift */,
|
||||||
0C445C61293F9A0B0060744D /* Bundle.swift */,
|
0C445C61293F9A0B0060744D /* Bundle.swift */,
|
||||||
0CA148C9288903F000DE2211 /* Collection.swift */,
|
0CA148C9288903F000DE2211 /* Collection.swift */,
|
||||||
|
0C7075E329D374C50093DB2D /* Color.swift */,
|
||||||
0CA148CA288903F000DE2211 /* Data.swift */,
|
0CA148CA288903F000DE2211 /* Data.swift */,
|
||||||
0CA429F728C5098D000D0610 /* DateFormatter.swift */,
|
0CA429F728C5098D000D0610 /* DateFormatter.swift */,
|
||||||
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */,
|
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */,
|
||||||
|
|
@ -557,6 +611,7 @@
|
||||||
0C42B5972932F6DD008057A0 /* Set.swift */,
|
0C42B5972932F6DD008057A0 /* Set.swift */,
|
||||||
0C7C128528DAA3CD00381CD1 /* URL.swift */,
|
0C7C128528DAA3CD00381CD1 /* URL.swift */,
|
||||||
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */,
|
0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */,
|
||||||
|
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -596,7 +651,8 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0CA148CE288903F000DE2211 /* WebView.swift */,
|
0CA148CE288903F000DE2211 /* WebView.swift */,
|
||||||
0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */,
|
0C7075E529D3845D0093DB2D /* ShareSheet.swift */,
|
||||||
|
0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */,
|
||||||
);
|
);
|
||||||
path = RepresentableViews;
|
path = RepresentableViews;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -609,6 +665,8 @@
|
||||||
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
|
0C422E7D293542EA00486D65 /* PremiumizeWrapper.swift */,
|
||||||
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
|
0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */,
|
||||||
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
|
0C6771F329B3B4FD005D38D2 /* KodiWrapper.swift */,
|
||||||
|
0C890E482C188808003B17B5 /* TorBoxWrapper.swift */,
|
||||||
|
0C07C6072C1B2F8000808A46 /* OffCloudWrapper.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -653,6 +711,15 @@
|
||||||
path = DataManagement;
|
path = DataManagement;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0CD4030829DA01A3008D9F03 /* Info */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */,
|
||||||
|
0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */,
|
||||||
|
);
|
||||||
|
path = Info;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
|
@ -677,8 +744,8 @@
|
||||||
0C4CFC452897030D00AD9FAD /* Regex */,
|
0C4CFC452897030D00AD9FAD /* Regex */,
|
||||||
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
|
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */,
|
||||||
0CDDDE042935235E006810B1 /* BetterSafariView */,
|
0CDDDE042935235E006810B1 /* BetterSafariView */,
|
||||||
0C448BE829A135F100F4E266 /* Introspect-Static */,
|
0C748ED929D9256D0049B8BE /* Yams */,
|
||||||
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */,
|
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */,
|
||||||
);
|
);
|
||||||
productName = Torrenter;
|
productName = Torrenter;
|
||||||
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
|
productReference = 0CAF1C68286F5C0E00296F86 /* Ferrite.app */;
|
||||||
|
|
@ -692,7 +759,7 @@
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1400;
|
LastSwiftUpdateCheck = 1400;
|
||||||
LastUpgradeCheck = 1400;
|
LastUpgradeCheck = 1600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
0CAF1C67286F5C0E00296F86 = {
|
0CAF1C67286F5C0E00296F86 = {
|
||||||
CreatedOnToolsVersion = 14.0;
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
|
@ -715,8 +782,8 @@
|
||||||
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
|
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */,
|
||||||
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
|
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
|
||||||
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
|
0CDDDE032935235E006810B1 /* XCRemoteSwiftPackageReference "BetterSafariView" */,
|
||||||
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
|
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */,
|
||||||
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */,
|
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
|
productRefGroup = 0CAF1C69286F5C0E00296F86 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
|
|
@ -768,21 +835,24 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
0C07C6042C1A859B00808A46 /* FormDataBody.swift in Sources */,
|
||||||
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
|
0C7ED14328D65518009E29AD /* FileManager.swift in Sources */,
|
||||||
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
|
0C03EB71296F619900162E9A /* PluginList+CoreDataClass.swift in Sources */,
|
||||||
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
|
0C6771FA29B3D1AE005D38D2 /* KodiModels.swift in Sources */,
|
||||||
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
|
0C0D50E5288DFE7F0035ECC8 /* SourceModels.swift in Sources */,
|
||||||
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */,
|
0CB6516528C5A5D700DCA721 /* InlinedList.swift in Sources */,
|
||||||
|
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */,
|
||||||
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
|
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
|
||||||
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */,
|
|
||||||
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */,
|
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */,
|
||||||
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
|
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
|
||||||
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */,
|
0C2D9653299316CC00A504B6 /* Tag.swift in Sources */,
|
||||||
|
0C84FCE129E4B41D00B0DFE4 /* SourceFilterView.swift in Sources */,
|
||||||
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */,
|
0CD5E78928CD932B001BF684 /* DisabledAppearance.swift in Sources */,
|
||||||
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
|
|
||||||
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
|
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
|
||||||
|
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */,
|
||||||
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
|
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
|
||||||
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
|
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
|
||||||
|
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */,
|
||||||
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */,
|
0C50055A2992BA6A0064606A /* PluginTag+CoreDataClass.swift in Sources */,
|
||||||
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */,
|
0C1A3E5229C8A7F500DA9730 /* SettingsModels.swift in Sources */,
|
||||||
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
|
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
|
||||||
|
|
@ -790,40 +860,38 @@
|
||||||
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
|
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
|
||||||
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */,
|
0CA0545B288EEA4E00850554 /* PluginListEditorView.swift in Sources */,
|
||||||
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */,
|
0C3E00D8296F5B9A00ECECB2 /* PluginModels.swift in Sources */,
|
||||||
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */,
|
|
||||||
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
|
0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */,
|
||||||
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
|
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
|
||||||
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
|
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
|
||||||
0CE66B3A28E640D200F69346 /* Backport.swift in Sources */,
|
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */,
|
||||||
0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */,
|
|
||||||
0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */,
|
|
||||||
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */,
|
0C3DD44229B6ACD9006429DB /* KodiServer+CoreDataClass.swift in Sources */,
|
||||||
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
|
0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */,
|
||||||
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
|
0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */,
|
||||||
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
|
0CA148E9288903F000DE2211 /* MainView.swift in Sources */,
|
||||||
|
0C8AE2482C0FFB6600701675 /* Store.swift in Sources */,
|
||||||
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
|
0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */,
|
||||||
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
|
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
|
||||||
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
|
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
|
||||||
|
0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */,
|
||||||
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
|
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
|
||||||
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
|
|
||||||
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
|
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
|
||||||
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */,
|
0C0D50E7288DFF850035ECC8 /* PluginAggregateView.swift in Sources */,
|
||||||
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
|
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
|
||||||
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
|
0CD72E17293D9928001A7EA4 /* Array.swift in Sources */,
|
||||||
0CD5F1FB299BEFBE00476DDB /* CustomScopeBar.swift in Sources */,
|
|
||||||
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
|
0C44E2A828D4DDDC007711AE /* Application.swift in Sources */,
|
||||||
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */,
|
0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */,
|
||||||
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
|
0C7ED14128D61BBA009E29AD /* BackupModels.swift in Sources */,
|
||||||
0CAF9319296399190050812A /* PremiumizeCloudView.swift in Sources */,
|
0C84FCE529E4B43200B0DFE4 /* SelectedDebridFilterView.swift in Sources */,
|
||||||
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
|
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
|
||||||
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
|
0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */,
|
||||||
|
0CB725342C123E760047FC0B /* CloudMagnetView.swift in Sources */,
|
||||||
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
|
0C03EB72296F619900162E9A /* PluginList+CoreDataProperties.swift in Sources */,
|
||||||
0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */,
|
|
||||||
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
|
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
|
||||||
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
|
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
|
||||||
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
|
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
|
||||||
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
|
0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */,
|
||||||
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
|
0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */,
|
||||||
|
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */,
|
||||||
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */,
|
0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */,
|
||||||
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
|
0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */,
|
||||||
0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */,
|
0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */,
|
||||||
|
|
@ -832,7 +900,6 @@
|
||||||
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
|
0CA148DD288903F000DE2211 /* ScrapingViewModel.swift in Sources */,
|
||||||
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
|
0C445C62293F9A0B0060744D /* Bundle.swift in Sources */,
|
||||||
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
|
0C0755C6293424A200ECA142 /* DebridLabelView.swift in Sources */,
|
||||||
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
|
|
||||||
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */,
|
0CA148D8288903F000DE2211 /* ActionChoiceView.swift in Sources */,
|
||||||
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
|
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
|
||||||
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
|
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
|
||||||
|
|
@ -843,7 +910,6 @@
|
||||||
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
|
0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */,
|
||||||
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */,
|
0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */,
|
||||||
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
|
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
|
||||||
0C5FCB05296744F300849E87 /* AllDebridCloudView.swift in Sources */,
|
|
||||||
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */,
|
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */,
|
||||||
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */,
|
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */,
|
||||||
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
|
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
|
||||||
|
|
@ -854,39 +920,57 @@
|
||||||
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */,
|
0C0167DC29293FA900B65783 /* RealDebridModels.swift in Sources */,
|
||||||
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
|
0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */,
|
||||||
0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
|
0C42B5982932F6DD008057A0 /* Set.swift in Sources */,
|
||||||
|
0C2B028F29E9E61E00DCF127 /* SortFilterView.swift in Sources */,
|
||||||
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
|
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
|
||||||
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */,
|
0CA05459288EE9E600850554 /* PluginManager.swift in Sources */,
|
||||||
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
|
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
|
||||||
|
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */,
|
||||||
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */,
|
0C12D43D28CC332A000195BF /* HistoryButtonView.swift in Sources */,
|
||||||
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */,
|
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */,
|
||||||
|
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */,
|
||||||
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
|
0C10848B28BD9A38008F0BA6 /* SettingsAppVersionView.swift in Sources */,
|
||||||
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
|
0CA05457288EE58200850554 /* SettingsPluginListView.swift in Sources */,
|
||||||
|
0C07C6062C1B2F7600808A46 /* OffCloudModels.swift in Sources */,
|
||||||
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
|
0C78041D28BFB3EA001E8CA3 /* String.swift in Sources */,
|
||||||
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
|
0C31133C28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift in Sources */,
|
||||||
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
|
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */,
|
||||||
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
|
0C50055B2992BA6A0064606A /* PluginTag+CoreDataProperties.swift in Sources */,
|
||||||
|
0C7075E629D3845D0093DB2D /* ShareSheet.swift in Sources */,
|
||||||
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
|
0C871BDF29994D9D005279AC /* FilterLabelView.swift in Sources */,
|
||||||
|
0CEE11B12C1743E0004C8BB2 /* SourceRequest+CoreDataClass.swift in Sources */,
|
||||||
|
0CEE11B22C1743E0004C8BB2 /* SourceRequest+CoreDataProperties.swift in Sources */,
|
||||||
|
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */,
|
||||||
|
0C07C6082C1B2F8000808A46 /* OffCloudWrapper.swift in Sources */,
|
||||||
|
0CF1ABE22C0C3D2F009F6C26 /* DebridModels.swift in Sources */,
|
||||||
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
|
0C6771F429B3B4FD005D38D2 /* KodiWrapper.swift in Sources */,
|
||||||
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
|
0C3E00D0296F4DB200ECECB2 /* ActionModels.swift in Sources */,
|
||||||
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
|
0C44E2AD28D51C63007711AE /* BackupManager.swift in Sources */,
|
||||||
|
0C890E4B2C188FA7003B17B5 /* TorBoxModels.swift in Sources */,
|
||||||
|
0C7075E429D374C50093DB2D /* Color.swift in Sources */,
|
||||||
|
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
|
||||||
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
|
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
|
||||||
|
0C84FCE329E4B42600B0DFE4 /* IAFilterView.swift in Sources */,
|
||||||
|
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */,
|
||||||
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
|
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */,
|
||||||
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */,
|
0C0974B029CCAAAF006DE7A3 /* OperatingSystemVersion.swift in Sources */,
|
||||||
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
|
0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */,
|
||||||
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
|
0CA148D6288903F000DE2211 /* SettingsView.swift in Sources */,
|
||||||
0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */,
|
|
||||||
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
|
0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */,
|
||||||
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
|
0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */,
|
||||||
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
|
0CBAB83628D12ED500AC903E /* DisableInteraction.swift in Sources */,
|
||||||
|
0CB0115B29D36D9E009AFEDE /* SearchResultsView.swift in Sources */,
|
||||||
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
|
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */,
|
||||||
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
|
0CEC8AB2299B3B57007BFE8F /* LibraryPickerView.swift in Sources */,
|
||||||
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
|
0C6771F629B3B602005D38D2 /* SettingsKodiView.swift in Sources */,
|
||||||
|
0C890E492C188808003B17B5 /* TorBoxWrapper.swift in Sources */,
|
||||||
|
0CF1ABDC2C0C04B2009F6C26 /* Debrid.swift in Sources */,
|
||||||
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
|
0C84F4842895BFED0074B7C9 /* SourceHtmlParser+CoreDataClass.swift in Sources */,
|
||||||
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
|
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */,
|
||||||
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
|
0CEC8AAE299B31B6007BFE8F /* SearchFilterHeaderView.swift in Sources */,
|
||||||
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
|
0C84F4822895BFED0074B7C9 /* Source+CoreDataClass.swift in Sources */,
|
||||||
|
0C93DED82CF80101009EA8D2 /* SettingsDebridLinkView.swift in Sources */,
|
||||||
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
|
0C3DD43F29B6968D006429DB /* KodiEditorView.swift in Sources */,
|
||||||
0C391ECB28CAA44B009F1CA1 /* AlertButton.swift in Sources */,
|
0CB725322C123E6F0047FC0B /* CloudDownloadView.swift in Sources */,
|
||||||
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
|
0C3DD44329B6ACD9006429DB /* KodiServer+CoreDataProperties.swift in Sources */,
|
||||||
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
|
0C7C128628DAA3CD00381CD1 /* URL.swift in Sources */,
|
||||||
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
|
0C84F4852895BFED0074B7C9 /* SourceHtmlParser+CoreDataProperties.swift in Sources */,
|
||||||
|
|
@ -904,6 +988,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||||
|
|
@ -936,6 +1021,7 @@
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
|
@ -950,13 +1036,14 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
|
@ -964,6 +1051,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
|
||||||
|
|
@ -996,6 +1084,7 @@
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
|
@ -1004,12 +1093,13 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|
@ -1020,10 +1110,11 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8A74DBQ6S3;
|
DEVELOPMENT_TEAM = 8A74DBQ6S3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Ferrite/Info.plist;
|
INFOPLIST_FILE = Ferrite/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
|
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
|
||||||
|
|
@ -1034,12 +1125,12 @@
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.6.2;
|
MARKETING_VERSION = 0.7.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
|
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -1055,10 +1146,11 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 21;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Ferrite/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8A74DBQ6S3;
|
DEVELOPMENT_TEAM = 8A74DBQ6S3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Ferrite/Info.plist;
|
INFOPLIST_FILE = Ferrite/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
|
INFOPLIST_KEY_CFBundleDisplayName = Ferrite;
|
||||||
|
|
@ -1069,12 +1161,12 @@
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.6.2;
|
MARKETING_VERSION = 0.7.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
|
PRODUCT_BUNDLE_IDENTIFIER = me.kingbri.Ferrite;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -1108,22 +1200,6 @@
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/SwiftUIX/SwiftUIX";
|
|
||||||
requirement = {
|
|
||||||
branch = master;
|
|
||||||
kind = branch;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect/";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 0.2.2;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = {
|
0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/sindresorhus/Regex";
|
repositoryURL = "https://github.com/sindresorhus/Regex";
|
||||||
|
|
@ -1148,6 +1224,14 @@
|
||||||
kind = branch;
|
kind = branch;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/jpsim/Yams";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMinorVersion;
|
||||||
|
minimumVersion = 5.0.5;
|
||||||
|
};
|
||||||
|
};
|
||||||
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
|
0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON";
|
repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON";
|
||||||
|
|
@ -1156,6 +1240,14 @@
|
||||||
kind = branch;
|
kind = branch;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/siteline/swiftui-introspect";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.3.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
||||||
|
|
@ -1175,16 +1267,6 @@
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
0C2DD4CE29A6D47400293FC3 /* SwiftUIX */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 0C2DD4CD29A6D47400293FC3 /* XCRemoteSwiftPackageReference "SwiftUIX" */;
|
|
||||||
productName = SwiftUIX;
|
|
||||||
};
|
|
||||||
0C448BE829A135F100F4E266 /* Introspect-Static */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 0C448BE729A135F100F4E266 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
|
|
||||||
productName = "Introspect-Static";
|
|
||||||
};
|
|
||||||
0C4CFC452897030D00AD9FAD /* Regex */ = {
|
0C4CFC452897030D00AD9FAD /* Regex */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */;
|
package = 0C4CFC442897030D00AD9FAD /* XCRemoteSwiftPackageReference "Regex" */;
|
||||||
|
|
@ -1200,11 +1282,21 @@
|
||||||
package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */;
|
package = 0C64A4B5288903880079976D /* XCRemoteSwiftPackageReference "keychain-swift" */;
|
||||||
productName = KeychainSwift;
|
productName = KeychainSwift;
|
||||||
};
|
};
|
||||||
|
0C748ED929D9256D0049B8BE /* Yams */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 0C748ED829D9256D0049B8BE /* XCRemoteSwiftPackageReference "Yams" */;
|
||||||
|
productName = Yams;
|
||||||
|
};
|
||||||
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = {
|
0C7506D628B1AC9A008BEE38 /* SwiftyJSON */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
|
package = 0C7506D528B1AC9A008BEE38 /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
|
||||||
productName = SwiftyJSON;
|
productName = SwiftyJSON;
|
||||||
};
|
};
|
||||||
|
0C7B49FF2CB051550048FA28 /* SwiftUIIntrospect */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 0C7B49FE2CB051550048FA28 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
|
||||||
|
productName = SwiftUIIntrospect;
|
||||||
|
};
|
||||||
0CAF1C7A286F5C8600296F86 /* SwiftSoup */ = {
|
0CAF1C7A286F5C8600296F86 /* SwiftSoup */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
package = 0CAF1C79286F5C8600296F86 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1400"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
||||||
|
|
@ -6,43 +6,89 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import KeychainSwift
|
|
||||||
|
|
||||||
// TODO: Fix errors
|
class AllDebrid: PollingDebridSource, ObservableObject {
|
||||||
public class AllDebrid {
|
let id = "AllDebrid"
|
||||||
let jsonDecoder = JSONDecoder()
|
let abbreviation = "AD"
|
||||||
let keychain = KeychainSwift()
|
let website = "https://alldebrid.com"
|
||||||
|
let description: String? = "AllDebrid is a debrid service that is used for downloads and media playback. " +
|
||||||
let baseApiUrl = "https://api.alldebrid.com/v4"
|
"You must pay to access this service. \n\n" +
|
||||||
let appName = "Ferrite"
|
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||||
|
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||||
|
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||||
|
"This service does not inform if a magnet link is a batch before downloading."
|
||||||
|
|
||||||
|
let cachedStatus: [String] = ["Ready"]
|
||||||
var authTask: Task<Void, Error>?
|
var authTask: Task<Void, Error>?
|
||||||
|
|
||||||
|
@Published var authProcessing: Bool = false
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
getToken() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") {
|
||||||
|
return getToken()
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseApiUrl = "https://api.alldebrid.com/v4"
|
||||||
|
private let appName = "Ferrite"
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserDownloads()
|
||||||
|
try? await getUserMagnets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
// Fetches information for PIN auth
|
// Fetches information for PIN auth
|
||||||
public func getPinInfo() async throws -> PinResponse {
|
func getAuthUrl() async throws -> URL {
|
||||||
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
|
let url = try buildRequestURL(urlString: "\(baseApiUrl)/pin/get")
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
|
|
||||||
|
|
||||||
return rawResponse
|
// Validate the URL before doing anything else
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<PinResponse>.self, from: data).data
|
||||||
|
guard let userUrl = URL(string: rawResponse.userURL) else {
|
||||||
|
throw DebridError.AuthQuery(description: "The login URL is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the polling task separately
|
||||||
|
authTask = Task {
|
||||||
|
try await getApiKey(checkID: rawResponse.check, pin: rawResponse.pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userUrl
|
||||||
} catch {
|
} catch {
|
||||||
print("Couldn't get pin information!")
|
print("Couldn't get pin information!")
|
||||||
throw ADError.AuthQuery(description: error.localizedDescription)
|
throw DebridError.AuthQuery(description: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches API keys
|
// Fetches API keys
|
||||||
public func getApiKey(checkID: String, pin: String) async throws {
|
func getApiKey(checkID: String, pin: String) async throws {
|
||||||
let queryItems = [
|
let queryItems = [
|
||||||
URLQueryItem(name: "agent", value: appName),
|
URLQueryItem(name: "agent", value: appName),
|
||||||
URLQueryItem(name: "check", value: checkID),
|
URLQueryItem(name: "check", value: checkID),
|
||||||
URLQueryItem(name: "pin", value: pin)
|
URLQueryItem(name: "pin", value: pin)
|
||||||
]
|
]
|
||||||
|
|
||||||
let request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
|
let request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/pin/check", queryItems: queryItems))
|
||||||
|
|
||||||
// Timer to poll AD API for key
|
// Timer to poll AD API for key
|
||||||
authTask = Task {
|
authTask = Task {
|
||||||
|
|
@ -50,7 +96,7 @@ public class AllDebrid {
|
||||||
|
|
||||||
while count < 12 {
|
while count < 12 {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
throw ADError.AuthQuery(description: "Token request cancelled.")
|
throw DebridError.AuthQuery(description: "Token request cancelled.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
@ -60,7 +106,7 @@ public class AllDebrid {
|
||||||
|
|
||||||
// If there's an API key from the response, end the task successfully
|
// If there's an API key from the response, end the task successfully
|
||||||
if let apiKeyResponse = rawResponse {
|
if let apiKeyResponse = rawResponse {
|
||||||
keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
|
FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey")
|
||||||
|
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -69,7 +115,7 @@ public class AllDebrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw ADError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if case let .failure(error) = await authTask?.result {
|
if case let .failure(error) = await authTask?.result {
|
||||||
|
|
@ -77,15 +123,28 @@ public class AllDebrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears tokens. No endpoint to deregister a device
|
// Adds a manual API key instead of web auth
|
||||||
public func deleteTokens() {
|
func setApiKey(_ key: String) {
|
||||||
keychain.delete("AllDebrid.ApiKey")
|
FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey")
|
||||||
|
UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getToken() -> String? {
|
||||||
|
FerriteKeychain.shared.get("AllDebrid.ApiKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears tokens. No endpoint to deregister a device
|
||||||
|
func logout() {
|
||||||
|
FerriteKeychain.shared.delete("AllDebrid.ApiKey")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Common request
|
||||||
|
|
||||||
// Wrapper request function which matches the responses and returns data
|
// Wrapper request function which matches the responses and returns data
|
||||||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
guard let token = keychain.get("AllDebrid.ApiKey") else {
|
guard let token = getToken() else {
|
||||||
throw ADError.InvalidToken
|
throw DebridError.InvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
@ -93,23 +152,22 @@ public class AllDebrid {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let response = response as? HTTPURLResponse else {
|
guard let response = response as? HTTPURLResponse else {
|
||||||
throw ADError.FailedRequest(description: "No HTTP response given")
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
return data
|
return data
|
||||||
} else if response.statusCode == 401 {
|
} else if response.statusCode == 401 {
|
||||||
deleteTokens()
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
|
||||||
throw ADError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to AllDebrid in Settings.")
|
|
||||||
} else {
|
} else {
|
||||||
throw ADError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds a URL for further requests
|
// Builds a URL for further requests
|
||||||
private func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
func buildRequestURL(urlString: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||||
guard var components = URLComponents(string: urlString) else {
|
guard var components = URLComponents(string: urlString) else {
|
||||||
throw ADError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
|
|
@ -119,17 +177,104 @@ public class AllDebrid {
|
||||||
if let url = components.url {
|
if let url = components.url {
|
||||||
return url
|
return url
|
||||||
} else {
|
} else {
|
||||||
throw ADError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instant availability
|
||||||
|
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
let sendMagnets = magnets.filter { magnet in
|
||||||
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
|
IAValues.remove(at: IAIndex)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the user magnets to the latest version
|
||||||
|
try await getUserMagnets()
|
||||||
|
|
||||||
|
for cloudMagnet in cloudMagnets {
|
||||||
|
if cachedStatus.contains(cloudMagnet.status),
|
||||||
|
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||||
|
{
|
||||||
|
IAValues.append(
|
||||||
|
DebridIA(
|
||||||
|
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downloading
|
||||||
|
|
||||||
|
// Wrapper function to fetch a download link from the API
|
||||||
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
|
let selectedMagnetId: String
|
||||||
|
|
||||||
|
if let existingMagnet = cloudMagnets.first(where: {
|
||||||
|
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||||
|
}) {
|
||||||
|
selectedMagnetId = existingMagnet.id
|
||||||
|
} else {
|
||||||
|
let magnetId = try await addMagnet(magnet: magnet)
|
||||||
|
selectedMagnetId = String(magnetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawResponse = try await fetchMagnetStatus(
|
||||||
|
magnetId: selectedMagnetId,
|
||||||
|
selectedIndex: iaFile?.id ?? 0
|
||||||
|
)
|
||||||
|
guard let magnets = rawResponse.magnets[safe: 0] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batches require an unrestrict from the user
|
||||||
|
if magnets.links.count > 1, iaFile == nil {
|
||||||
|
var copiedIA = ia
|
||||||
|
|
||||||
|
copiedIA?.files = magnets.links.enumerated().compactMap { index, file in
|
||||||
|
DebridIAFile(
|
||||||
|
id: index,
|
||||||
|
name: file.filename,
|
||||||
|
streamUrlString: file.link
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (nil, copiedIA)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cloudMagnetFile = magnets.links[safe: iaFile?.id ?? 0] {
|
||||||
|
let restrictedFile = DebridIAFile(
|
||||||
|
id: 0,
|
||||||
|
name: cloudMagnetFile.filename,
|
||||||
|
streamUrlString: cloudMagnetFile.link
|
||||||
|
)
|
||||||
|
|
||||||
|
return (restrictedFile, nil)
|
||||||
|
} else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a magnet link to the user's AD account
|
// Adds a magnet link to the user's AD account
|
||||||
public func addMagnet(magnet: Magnet) async throws -> Int {
|
func addMagnet(magnet: Magnet) async throws -> Int {
|
||||||
guard let magnetLink = magnet.link else {
|
guard let magnetLink = magnet.link else {
|
||||||
throw ADError.FailedRequest(description: "The magnet link is invalid")
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/upload"))
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
|
@ -144,84 +289,108 @@ public class AllDebrid {
|
||||||
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
|
let rawResponse = try jsonDecoder.decode(ADResponse<AddMagnetResponse>.self, from: data).data
|
||||||
|
|
||||||
if let magnet = rawResponse.magnets[safe: 0] {
|
if let magnet = rawResponse.magnets[safe: 0] {
|
||||||
|
if !magnet.ready {
|
||||||
|
throw DebridError.IsCaching
|
||||||
|
}
|
||||||
|
|
||||||
return magnet.id
|
return magnet.id
|
||||||
} else {
|
} else {
|
||||||
throw ADError.InvalidResponse
|
throw DebridError.InvalidResponse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchMagnetStatus(magnetId: Int, selectedIndex: Int?) async throws -> String {
|
func fetchMagnetStatus(magnetId: String, selectedIndex: Int?) async throws -> MagnetStatusResponse {
|
||||||
let queryItems = [
|
let queryItems = [
|
||||||
URLQueryItem(name: "id", value: String(magnetId))
|
URLQueryItem(name: "id", value: magnetId)
|
||||||
]
|
]
|
||||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status", queryItems: queryItems))
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
||||||
|
|
||||||
// Better to fetch no link at all than the wrong link
|
return rawResponse
|
||||||
if let linkWrapper = rawResponse.magnets[safe: 0]?.links[safe: selectedIndex ?? -1] {
|
|
||||||
return linkWrapper.link
|
|
||||||
} else {
|
|
||||||
throw ADError.EmptyTorrents
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func userMagnets() async throws -> [MagnetStatusData] {
|
// Known as unlockLink in AD's API
|
||||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
|
||||||
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
|
||||||
|
|
||||||
if rawResponse.magnets.isEmpty {
|
|
||||||
throw ADError.EmptyData
|
|
||||||
} else {
|
|
||||||
return rawResponse.magnets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func deleteMagnet(magnetId: Int) async throws {
|
|
||||||
let queryItems = [
|
let queryItems = [
|
||||||
URLQueryItem(name: "id", value: String(magnetId))
|
URLQueryItem(name: "link", value: restrictedFile.streamUrlString)
|
||||||
]
|
]
|
||||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
|
||||||
|
|
||||||
try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: "unlockLink")
|
||||||
}
|
|
||||||
|
|
||||||
public func unlockLink(lockedLink: String) async throws -> String {
|
|
||||||
let queryItems = [
|
|
||||||
URLQueryItem(name: "link", value: lockedLink)
|
|
||||||
]
|
|
||||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/link/unlock", queryItems: queryItems))
|
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
|
||||||
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
|
let rawResponse = try jsonDecoder.decode(ADResponse<UnlockLinkResponse>.self, from: data).data
|
||||||
|
|
||||||
return rawResponse.link
|
return rawResponse.link
|
||||||
}
|
}
|
||||||
|
|
||||||
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] {
|
func saveLink(link: String) async throws {
|
||||||
let queryItems = magnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) }
|
let queryItems = [
|
||||||
var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems))
|
URLQueryItem(name: "links[]", value: link)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems))
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cloud methods
|
||||||
|
|
||||||
|
func getUserMagnets() async throws {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/status"))
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
let rawResponse = try jsonDecoder.decode(ADResponse<InstantAvailabilityResponse>.self, from: data).data
|
let rawResponse = try jsonDecoder.decode(ADResponse<MagnetStatusResponse>.self, from: data).data
|
||||||
|
|
||||||
let filteredMagnets = rawResponse.magnets.filter { $0.instant == true && $0.files != nil }
|
cloudMagnets = rawResponse.magnets.map { magnetResponse in
|
||||||
let availableHashes = filteredMagnets.map { magnetResp in
|
DebridCloudMagnet(
|
||||||
// Force unwrap is OK here since the filter caught any nil values
|
id: String(magnetResponse.id),
|
||||||
let files = magnetResp.files!.enumerated().map { index, magnetFile in
|
fileName: magnetResponse.filename,
|
||||||
IAFile(id: index, fileName: magnetFile.name)
|
status: magnetResponse.status,
|
||||||
}
|
hash: magnetResponse.hash,
|
||||||
|
links: magnetResponse.links.map(\.link)
|
||||||
return IA(
|
|
||||||
magnet: Magnet(hash: magnetResp.hash, link: magnetResp.magnet),
|
|
||||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
|
||||||
files: files
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return availableHashes
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||||
|
guard let cloudMagnetId else {
|
||||||
|
throw DebridError.FailedRequest(description: "The cloud magnetID \(String(describing: cloudMagnetId)) is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "id", value: cloudMagnetId)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/magnet/delete", queryItems: queryItems))
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserDownloads() async throws {
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links"))
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(ADResponse<SavedLinksResponse>.self, from: data).data
|
||||||
|
|
||||||
|
// The link is also the ID
|
||||||
|
cloudDownloads = rawResponse.links.map { link in
|
||||||
|
DebridCloudDownload(
|
||||||
|
id: link.link, fileName: link.filename, link: link.link
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not used
|
||||||
|
func checkUserDownloads(link: String) -> String? {
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
// The downloadId is actually the download link
|
||||||
|
func deleteUserDownload(downloadId: String) async throws {
|
||||||
|
let queryItems = [
|
||||||
|
URLQueryItem(name: "link", value: downloadId)
|
||||||
|
]
|
||||||
|
var request = try URLRequest(url: buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems))
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Github {
|
class Github {
|
||||||
public func fetchLatestRelease() async throws -> Release? {
|
func fetchLatestRelease() async throws -> Release? {
|
||||||
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases/latest")!
|
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases/latest")!
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
|
||||||
|
|
@ -17,8 +17,8 @@ public class Github {
|
||||||
return rawResponse
|
return rawResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchReleases() async throws -> [Release]? {
|
func fetchReleases() async throws -> [Release]? {
|
||||||
let url = URL(string: "https://api.github.com/repos/bdashore3/Ferrite/releases")!
|
let url = URL(string: "https://api.github.com/repos/Ferrite-iOS/Ferrite/releases")!
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Kodi {
|
class Kodi {
|
||||||
let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
// Used to add server to CoreData. Not part of API
|
// Used to add server to CoreData. Not part of API
|
||||||
public func addServer(urlString: String,
|
func addServer(urlString: String,
|
||||||
friendlyName: String?,
|
friendlyName: String?,
|
||||||
username: String?,
|
username: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
existingServer: KodiServer? = nil) throws
|
existingServer: KodiServer? = nil) throws
|
||||||
{
|
{
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ public class Kodi {
|
||||||
try backgroundContext.save()
|
try backgroundContext.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func ping(server: KodiServer) async throws {
|
func ping(server: KodiServer) async throws {
|
||||||
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
|
var request = URLRequest(url: URL(string: "\(server.urlString)/jsonrpc")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
@ -94,7 +94,7 @@ public class Kodi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sendVideoUrl(urlString: String, server: KodiServer) async throws {
|
func sendVideoUrl(urlString: String, server: KodiServer) async throws {
|
||||||
if URL(string: urlString) == nil {
|
if URL(string: urlString) == nil {
|
||||||
throw KodiError.InvalidPlaybackUrl
|
throw KodiError.InvalidPlaybackUrl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,17 +6,48 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import KeychainSwift
|
|
||||||
|
|
||||||
public class Premiumize {
|
class Premiumize: OAuthDebridSource, ObservableObject {
|
||||||
let jsonDecoder = JSONDecoder()
|
let id = "Premiumize"
|
||||||
let keychain = KeychainSwift()
|
let abbreviation = "PM"
|
||||||
|
let website = "https://premiumize.me"
|
||||||
|
let description: String? = "Premiumize is a debrid service that is used for downloads and media playback with seeding. " +
|
||||||
|
"You must pay to access the service."
|
||||||
|
|
||||||
let baseAuthUrl = "https://www.premiumize.me/authorize"
|
@Published var authProcessing: Bool = false
|
||||||
let baseApiUrl = "https://www.premiumize.me/api"
|
var isLoggedIn: Bool {
|
||||||
let clientId = "791565696"
|
getToken() != nil
|
||||||
|
}
|
||||||
|
|
||||||
public func buildAuthUrl() throws -> URL {
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
|
||||||
|
return getToken()
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseAuthUrl = "https://www.premiumize.me/authorize"
|
||||||
|
private let baseApiUrl = "https://www.premiumize.me/api"
|
||||||
|
private let clientId = "791565696"
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
func getAuthUrl() throws -> URL {
|
||||||
var urlComponents = URLComponents(string: baseAuthUrl)!
|
var urlComponents = URLComponents(string: baseAuthUrl)!
|
||||||
urlComponents.queryItems = [
|
urlComponents.queryItems = [
|
||||||
URLQueryItem(name: "client_id", value: clientId),
|
URLQueryItem(name: "client_id", value: clientId),
|
||||||
|
|
@ -27,59 +58,185 @@ public class Premiumize {
|
||||||
if let url = urlComponents.url {
|
if let url = urlComponents.url {
|
||||||
return url
|
return url
|
||||||
} else {
|
} else {
|
||||||
throw PMError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handleAuthCallback(url: URL) throws {
|
func handleAuthCallback(url: URL) throws {
|
||||||
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
let callbackComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
|
||||||
guard let callbackFragment = callbackComponents?.fragment else {
|
guard let callbackFragment = callbackComponents?.fragment else {
|
||||||
throw PMError.InvalidResponse
|
throw DebridError.InvalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
var fragmentComponents = URLComponents()
|
var fragmentComponents = URLComponents()
|
||||||
fragmentComponents.query = callbackFragment
|
fragmentComponents.query = callbackFragment
|
||||||
|
|
||||||
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
|
guard let accessToken = fragmentComponents.queryItems?.first(where: { $0.name == "access_token" })?.value else {
|
||||||
throw PMError.InvalidToken
|
throw DebridError.InvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
keychain.set(accessToken, forKey: "Premiumize.AccessToken")
|
FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a manual API key instead of web auth
|
||||||
|
func setApiKey(_ key: String) {
|
||||||
|
FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken")
|
||||||
|
UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getToken() -> String? {
|
||||||
|
FerriteKeychain.shared.get("Premiumize.AccessToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears tokens. No endpoint to deregister a device
|
// Clears tokens. No endpoint to deregister a device
|
||||||
public func deleteTokens() {
|
func logout() {
|
||||||
keychain.delete("Premiumize.AccessToken")
|
FerriteKeychain.shared.delete("Premiumize.AccessToken")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Common request
|
||||||
|
|
||||||
// Wrapper request function which matches the responses and returns data
|
// Wrapper request function which matches the responses and returns data
|
||||||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
guard let token = keychain.get("Premiumize.AccessToken") else {
|
guard let token = getToken() else {
|
||||||
throw PMError.InvalidToken
|
throw DebridError.InvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
// Use the API query parameter if a manual API key is present
|
||||||
|
if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") {
|
||||||
|
guard
|
||||||
|
let requestUrl = request.url,
|
||||||
|
var components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: false)
|
||||||
|
else {
|
||||||
|
throw DebridError.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiTokenItem = URLQueryItem(name: "apikey", value: token)
|
||||||
|
|
||||||
|
if components.queryItems == nil {
|
||||||
|
components.queryItems = [apiTokenItem]
|
||||||
|
} else {
|
||||||
|
components.queryItems?.append(apiTokenItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.url = components.url
|
||||||
|
} else {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let response = response as? HTTPURLResponse else {
|
guard let response = response as? HTTPURLResponse else {
|
||||||
throw PMError.FailedRequest(description: "No HTTP response given")
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
return data
|
return data
|
||||||
} else if response.statusCode == 401 {
|
} else if response.statusCode == 401 {
|
||||||
deleteTokens()
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
|
||||||
throw PMError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to Premiumize in Settings.")
|
|
||||||
} else {
|
} else {
|
||||||
throw PMError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instant availability
|
||||||
|
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
|
// Remove magnets that don't have an associated link for PM along with existing TTL logic
|
||||||
|
let sendMagnets = magnets.filter { magnet in
|
||||||
|
if magnet.link == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
|
IAValues.remove(at: IAIndex)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendMagnets.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableMagnets = try await divideCacheRequests(magnets: sendMagnets)
|
||||||
|
|
||||||
|
// Split DDL requests into chunks of 10
|
||||||
|
for chunk in availableMagnets.chunked(into: 10) {
|
||||||
|
let tempIA = try await divideDDLRequests(magnetChunk: chunk)
|
||||||
|
IAValues += tempIA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to divide and execute DDL endpoint requests in parallel
|
||||||
|
// Calls this for 10 requests at a time to not overwhelm API servers
|
||||||
|
func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [DebridIA] {
|
||||||
|
let tempIA = try await withThrowingTaskGroup(of: DebridIA.self) { group in
|
||||||
|
for magnet in magnetChunk {
|
||||||
|
group.addTask {
|
||||||
|
try await self.fetchDDL(magnet: magnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkedIA: [DebridIA] = []
|
||||||
|
for try await ia in group {
|
||||||
|
chunkedIA.append(ia)
|
||||||
|
}
|
||||||
|
return chunkedIA
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempIA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grabs DDL links
|
||||||
|
private func fetchDDL(magnet: Magnet) async throws -> DebridIA {
|
||||||
|
if magnet.hash == nil {
|
||||||
|
throw DebridError.EmptyData
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var bodyComponents = URLComponents()
|
||||||
|
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
|
||||||
|
|
||||||
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
|
||||||
|
let content = rawResponse.content ?? []
|
||||||
|
|
||||||
|
if !content.isEmpty {
|
||||||
|
let files = content.map { file in
|
||||||
|
DebridIAFile(
|
||||||
|
id: 0,
|
||||||
|
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
|
||||||
|
streamUrlString: file.link
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DebridIA(
|
||||||
|
magnet: magnet,
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: files
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw DebridError.EmptyData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to divide and execute cache endpoint requests in parallel
|
// Function to divide and execute cache endpoint requests in parallel
|
||||||
// Calls this for 100 hashes at a time due to API limits
|
// Calls this for 100 hashes at a time due to API limits
|
||||||
public func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
|
func divideCacheRequests(magnets: [Magnet]) async throws -> [Magnet] {
|
||||||
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
|
let availableMagnets = try await withThrowingTaskGroup(of: [Magnet].self) { group in
|
||||||
for chunk in magnets.chunked(into: 100) {
|
for chunk in magnets.chunked(into: 100) {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
|
|
@ -99,11 +256,11 @@ public class Premiumize {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent function for initial checking of the cache
|
// Parent function for initial checking of the cache
|
||||||
func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
|
private func checkCache(magnets: [Magnet]) async throws -> [Magnet] {
|
||||||
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
|
var urlComponents = URLComponents(string: "\(baseApiUrl)/cache/check")!
|
||||||
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
|
urlComponents.queryItems = magnets.map { URLQueryItem(name: "items[]", value: $0.hash) }
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
throw PMError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
|
|
@ -112,7 +269,7 @@ public class Premiumize {
|
||||||
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(CacheCheckResponse.self, from: data)
|
||||||
|
|
||||||
if rawResponse.response.isEmpty {
|
if rawResponse.response.isEmpty {
|
||||||
throw PMError.EmptyData
|
throw DebridError.EmptyData
|
||||||
} else {
|
} else {
|
||||||
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
|
let availableMagnets = magnets.enumerated().compactMap { index, magnet in
|
||||||
if rawResponse.response[safe: index] == true {
|
if rawResponse.response[safe: index] == true {
|
||||||
|
|
@ -126,65 +283,32 @@ public class Premiumize {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to divide and execute DDL endpoint requests in parallel
|
// MARK: - Downloading
|
||||||
// Calls this for 10 requests at a time to not overwhelm API servers
|
|
||||||
public func divideDDLRequests(magnetChunk: [Magnet]) async throws -> [IA] {
|
|
||||||
let tempIA = try await withThrowingTaskGroup(of: Premiumize.IA.self) { group in
|
|
||||||
for magnet in magnetChunk {
|
|
||||||
group.addTask {
|
|
||||||
try await self.fetchDDL(magnet: magnet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var chunkedIA: [Premiumize.IA] = []
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
for try await ia in group {
|
// Store the item in PM cloud for later use
|
||||||
chunkedIA.append(ia)
|
try await createTransfer(magnet: magnet)
|
||||||
}
|
|
||||||
return chunkedIA
|
|
||||||
}
|
|
||||||
|
|
||||||
return tempIA
|
if let iaFile {
|
||||||
}
|
return (iaFile, nil)
|
||||||
|
} else if let premiumizeItem = ia, let firstFile = premiumizeItem.files[safe: 0] {
|
||||||
// Grabs DDL links
|
return (firstFile, nil)
|
||||||
func fetchDDL(magnet: Magnet) async throws -> IA {
|
|
||||||
if magnet.hash == nil {
|
|
||||||
throw PMError.EmptyData
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/directdl")!)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
var bodyComponents = URLComponents()
|
|
||||||
bodyComponents.queryItems = [URLQueryItem(name: "src", value: magnet.link)]
|
|
||||||
|
|
||||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
|
||||||
let rawResponse = try jsonDecoder.decode(DDLResponse.self, from: data)
|
|
||||||
|
|
||||||
if !rawResponse.content.isEmpty {
|
|
||||||
let files = rawResponse.content.map { file in
|
|
||||||
IAFile(
|
|
||||||
name: file.path.split(separator: "/").last.flatMap { String($0) } ?? file.path,
|
|
||||||
streamUrlString: file.link
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return IA(
|
|
||||||
magnet: magnet,
|
|
||||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
|
||||||
files: files
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
throw PMError.EmptyData
|
throw DebridError.FailedRequest(description: "Could not fetch your file from the Premiumize API")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTransfer(magnet: Magnet) async throws {
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
|
guard let streamUrlString = restrictedFile.streamUrlString else {
|
||||||
|
throw DebridError.FailedRequest(description: "Could not get a streaming URL from the Premiumize API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamUrlString
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTransfer(magnet: Magnet) async throws {
|
||||||
guard let magnetLink = magnet.link else {
|
guard let magnetLink = magnet.link else {
|
||||||
throw PMError.FailedRequest(description: "The magnet link is invalid")
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/transfer/create")!)
|
||||||
|
|
@ -199,24 +323,29 @@ public class Premiumize {
|
||||||
try await performRequest(request: &request, requestName: #function)
|
try await performRequest(request: &request, requestName: #function)
|
||||||
}
|
}
|
||||||
|
|
||||||
func userItems() async throws -> [UserItem] {
|
// MARK: - Cloud methods
|
||||||
|
|
||||||
|
func getUserDownloads() async throws {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/listall")!)
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(AllItemsResponse.self, from: data)
|
||||||
|
|
||||||
if rawResponse.files.isEmpty {
|
if rawResponse.files.isEmpty {
|
||||||
throw PMError.EmptyData
|
throw DebridError.EmptyData
|
||||||
}
|
}
|
||||||
|
|
||||||
return rawResponse.files
|
// The "link" is the ID for Premiumize
|
||||||
|
cloudDownloads = rawResponse.files.map { file in
|
||||||
|
DebridCloudDownload(id: file.id, fileName: file.name, link: file.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
|
private func itemDetails(itemID: String) async throws -> ItemDetailsResponse {
|
||||||
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
|
var urlComponents = URLComponents(string: "\(baseApiUrl)/item/details")!
|
||||||
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
|
urlComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
throw PMError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
|
|
@ -227,16 +356,26 @@ public class Premiumize {
|
||||||
return rawResponse
|
return rawResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteItem(itemID: String) async throws {
|
func checkUserDownloads(link: String) async throws -> String? {
|
||||||
|
// Link is the cloud item ID
|
||||||
|
try await itemDetails(itemID: link).link
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserDownload(downloadId: String) async throws {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/item/delete")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
var bodyComponents = URLComponents()
|
var bodyComponents = URLComponents()
|
||||||
bodyComponents.queryItems = [URLQueryItem(name: "id", value: itemID)]
|
bodyComponents.queryItems = [URLQueryItem(name: "id", value: downloadId)]
|
||||||
|
|
||||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
try await performRequest(request: &request, requestName: #function)
|
try await performRequest(request: &request, requestName: #function)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No user magnets for Premiumize
|
||||||
|
func getUserMagnets() {}
|
||||||
|
|
||||||
|
func deleteUserMagnet(cloudMagnetId: String?) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,30 +6,69 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import KeychainSwift
|
|
||||||
|
|
||||||
public class RealDebrid {
|
class RealDebrid: PollingDebridSource, ObservableObject {
|
||||||
let jsonDecoder = JSONDecoder()
|
let id = "RealDebrid"
|
||||||
let keychain = KeychainSwift()
|
let abbreviation = "RD"
|
||||||
|
let website = "https://real-debrid.com"
|
||||||
let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
let description: String? = "RealDebrid is a debrid service that is used for downloads and media playback. " +
|
||||||
let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
"You must pay to access this service. \n\n" +
|
||||||
let openSourceClientId = "X245A4XAIBGVM"
|
"It is not recommended to use this service since media cache checks are not possible via the API. " +
|
||||||
|
"Ferrite's instant availability solely looks at a user's magnet library. \n\n" +
|
||||||
|
"If you must use this service, it is recommended to download search results manually using the context menu. \n\n" +
|
||||||
|
"This service does not inform if a magnet link is a batch before downloading."
|
||||||
|
|
||||||
|
let cachedStatus: [String] = ["downloaded"]
|
||||||
var authTask: Task<Void, Error>?
|
var authTask: Task<Void, Error>?
|
||||||
|
|
||||||
|
@Published var authProcessing: Bool = false
|
||||||
|
|
||||||
|
// Check the manual token since getTokens() is async
|
||||||
|
var isLoggedIn: Bool {
|
||||||
|
FerriteKeychain.shared.get("RealDebrid.AccessToken") != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualToken: String? {
|
||||||
|
if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") {
|
||||||
|
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var IAValues: [DebridIA] = []
|
||||||
|
@Published var cloudDownloads: [DebridCloudDownload] = []
|
||||||
|
@Published var cloudMagnets: [DebridCloudMagnet] = []
|
||||||
|
var cloudTTL: Double = 0.0
|
||||||
|
|
||||||
|
private let baseAuthUrl = "https://api.real-debrid.com/oauth/v2"
|
||||||
|
private let baseApiUrl = "https://api.real-debrid.com/rest/1.0"
|
||||||
|
private let openSourceClientId = "X245A4XAIBGVM"
|
||||||
|
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func setUserDefaultsValue(_ value: Any, forKey: String) {
|
private func setUserDefaultsValue(_ value: Any, forKey: String) {
|
||||||
UserDefaults.standard.set(value, forKey: forKey)
|
UserDefaults.standard.set(value, forKey: forKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func removeUserDefaultsValue(forKey: String) {
|
private func removeUserDefaultsValue(forKey: String) {
|
||||||
UserDefaults.standard.removeObject(forKey: forKey)
|
UserDefaults.standard.removeObject(forKey: forKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Populate user downloads and magnets
|
||||||
|
Task {
|
||||||
|
try? await getUserDownloads()
|
||||||
|
try? await getUserMagnets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
// Fetches the device code from RD
|
// Fetches the device code from RD
|
||||||
public func getVerificationInfo() async throws -> DeviceCodeResponse {
|
func getAuthUrl() async throws -> URL {
|
||||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
|
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
|
||||||
urlComponents.queryItems = [
|
urlComponents.queryItems = [
|
||||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||||
|
|
@ -37,23 +76,33 @@ public class RealDebrid {
|
||||||
]
|
]
|
||||||
|
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
throw RDError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// Validate the URL before doing anything else
|
||||||
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
|
||||||
return rawResponse
|
guard let directVerificationUrl = URL(string: rawResponse.directVerificationURL) else {
|
||||||
|
throw DebridError.AuthQuery(description: "The verification URL is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the polling task separately
|
||||||
|
authTask = Task {
|
||||||
|
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return directVerificationUrl
|
||||||
} catch {
|
} catch {
|
||||||
print("Couldn't get the new client creds!")
|
print("Couldn't get the new client creds!")
|
||||||
throw RDError.AuthQuery(description: error.localizedDescription)
|
throw DebridError.AuthQuery(description: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches the user's client ID and secret
|
// Fetches the user's client ID and secret
|
||||||
public func getDeviceCredentials(deviceCode: String) async throws {
|
func getDeviceCredentials(deviceCode: String) async throws {
|
||||||
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
|
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/credentials")!
|
||||||
urlComponents.queryItems = [
|
urlComponents.queryItems = [
|
||||||
URLQueryItem(name: "client_id", value: openSourceClientId),
|
URLQueryItem(name: "client_id", value: openSourceClientId),
|
||||||
|
|
@ -61,55 +110,49 @@ public class RealDebrid {
|
||||||
]
|
]
|
||||||
|
|
||||||
guard let url = urlComponents.url else {
|
guard let url = urlComponents.url else {
|
||||||
throw RDError.InvalidUrl
|
throw DebridError.InvalidUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
|
|
||||||
// Timer to poll RD API for credentials
|
// Timer to poll RD API for credentials
|
||||||
authTask = Task {
|
var count = 0
|
||||||
var count = 0
|
|
||||||
|
|
||||||
while count < 12 {
|
while count < 12 {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
throw RDError.AuthQuery(description: "Token request cancelled.")
|
throw DebridError.AuthQuery(description: "Token request cancelled.")
|
||||||
}
|
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
// We don't care if this fails
|
|
||||||
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
|
||||||
|
|
||||||
// If there's a client ID from the response, end the task successfully
|
|
||||||
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
|
||||||
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
|
|
||||||
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
|
||||||
|
|
||||||
try await getTokens(deviceCode: deviceCode)
|
|
||||||
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
try await Task.sleep(seconds: 5)
|
|
||||||
count += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw RDError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// We don't care if this fails
|
||||||
|
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
||||||
|
|
||||||
|
// If there's a client ID from the response, end the task successfully
|
||||||
|
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
||||||
|
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
|
||||||
|
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
||||||
|
|
||||||
|
try await getApiTokens(deviceCode: deviceCode)
|
||||||
|
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
try await Task.sleep(seconds: 5)
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if case let .failure(error) = await authTask?.result {
|
throw DebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all tokens for the user and store in keychain
|
// Fetch all tokens for the user and store in FerriteKeychain.shared
|
||||||
public func getTokens(deviceCode: String) async throws {
|
func getApiTokens(deviceCode: String) async throws {
|
||||||
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else {
|
||||||
throw RDError.EmptyData
|
throw DebridError.EmptyData
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else {
|
guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else {
|
||||||
throw RDError.EmptyData
|
throw DebridError.EmptyData
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
|
var request = URLRequest(url: URL(string: "\(baseAuthUrl)/token")!)
|
||||||
|
|
@ -130,20 +173,20 @@ public class RealDebrid {
|
||||||
|
|
||||||
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data)
|
||||||
|
|
||||||
keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken")
|
||||||
keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken")
|
||||||
|
|
||||||
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
|
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
|
||||||
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchToken() async -> String? {
|
func getToken() async -> String? {
|
||||||
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
let accessTokenStamp = UserDefaults.standard.double(forKey: "RealDebrid.AccessTokenStamp")
|
||||||
|
|
||||||
if Date().timeIntervalSince1970 > accessTokenStamp {
|
if Date().timeIntervalSince1970 > accessTokenStamp {
|
||||||
do {
|
do {
|
||||||
if let refreshToken = keychain.get("RealDebrid.RefreshToken") {
|
if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") {
|
||||||
try await getTokens(deviceCode: refreshToken)
|
try await getApiTokens(deviceCode: refreshToken)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
print(error)
|
||||||
|
|
@ -151,29 +194,43 @@ public class RealDebrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return keychain.get("RealDebrid.AccessToken")
|
return FerriteKeychain.shared.get("RealDebrid.AccessToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deleteTokens() async throws {
|
// Adds a manual API key instead of web auth
|
||||||
keychain.delete("RealDebrid.RefreshToken")
|
// Clear out existing refresh tokens and timestamps
|
||||||
keychain.delete("RealDebrid.ClientSecret")
|
func setApiKey(_ key: String) {
|
||||||
|
FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken")
|
||||||
|
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
||||||
|
FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp")
|
||||||
|
|
||||||
|
UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes tokens from device and RD's servers
|
||||||
|
func logout() async {
|
||||||
|
FerriteKeychain.shared.delete("RealDebrid.RefreshToken")
|
||||||
|
FerriteKeychain.shared.delete("RealDebrid.ClientSecret")
|
||||||
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
|
await removeUserDefaultsValue(forKey: "RealDebrid.ClientId")
|
||||||
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
|
await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp")
|
||||||
|
|
||||||
// Run the request, doesn't matter if it fails
|
// Run the request, doesn't matter if it fails
|
||||||
if let token = keychain.get("RealDebrid.AccessToken") {
|
if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!)
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
_ = try? await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
keychain.delete("RealDebrid.AccessToken")
|
FerriteKeychain.shared.delete("RealDebrid.AccessToken")
|
||||||
|
await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Common request
|
||||||
|
|
||||||
// Wrapper request function which matches the responses and returns data
|
// Wrapper request function which matches the responses and returns data
|
||||||
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
@discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data {
|
||||||
guard let token = await fetchToken() else {
|
guard let token = await getToken() else {
|
||||||
throw RDError.InvalidToken
|
throw DebridError.InvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
@ -181,99 +238,116 @@ public class RealDebrid {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let response = response as? HTTPURLResponse else {
|
guard let response = response as? HTTPURLResponse else {
|
||||||
throw RDError.FailedRequest(description: "No HTTP response given")
|
throw DebridError.FailedRequest(description: "No HTTP response given")
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.statusCode >= 200, response.statusCode <= 299 {
|
if response.statusCode >= 200, response.statusCode <= 299 {
|
||||||
return data
|
return data
|
||||||
} else if response.statusCode == 401 {
|
} else if response.statusCode == 401 {
|
||||||
try await deleteTokens()
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
|
||||||
throw RDError.FailedRequest(description: "The request \(requestName) failed because you were unauthorized. Please relogin to RealDebrid in Settings.")
|
|
||||||
} else {
|
} else {
|
||||||
throw RDError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
throw DebridError.FailedRequest(description: "The request \(requestName) failed with status code \(response.statusCode).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the magnet is streamable on RD
|
// MARK: - Instant availability
|
||||||
// Currently does not work for batch links
|
|
||||||
public func instantAvailability(magnets: [Magnet]) async throws -> [IA] {
|
|
||||||
var availableHashes: [RealDebrid.IA] = []
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/instantAvailability/\(magnets.compactMap(\.hash).joined(separator: "/"))")!)
|
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
// Post-API changes
|
||||||
|
// Use user magnets to check for IA instead
|
||||||
|
func instantAvailability(magnets: [Magnet]) async throws {
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
|
||||||
// Does not account for torrent packs at the moment
|
let sendMagnets = magnets.filter { magnet in
|
||||||
let rawResponseDict = try jsonDecoder.decode([String: InstantAvailabilityResponse].self, from: data)
|
if let IAIndex = IAValues.firstIndex(where: { $0.magnet.hash == magnet.hash }) {
|
||||||
|
if now > IAValues[IAIndex].expiryTimeStamp {
|
||||||
for (hash, response) in rawResponseDict {
|
IAValues.remove(at: IAIndex)
|
||||||
guard let data = response.data else {
|
return true
|
||||||
continue
|
} else {
|
||||||
}
|
return false
|
||||||
|
|
||||||
if data.rd.isEmpty {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this a batch
|
|
||||||
if data.rd.count > 1 || data.rd[0].count > 1 {
|
|
||||||
// Batch array
|
|
||||||
let batches = data.rd.map { fileDict in
|
|
||||||
let batchFiles: [RealDebrid.IABatchFile] = fileDict.map { key, value in
|
|
||||||
// Force unwrapped ID. Is safe because ID is guaranteed on a successful response
|
|
||||||
RealDebrid.IABatchFile(id: Int(key)!, fileName: value.filename)
|
|
||||||
}.sorted(by: { $0.id < $1.id })
|
|
||||||
|
|
||||||
return RealDebrid.IABatch(files: batchFiles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RD files array
|
|
||||||
// Possibly sort this in the future, but not sure how at the moment
|
|
||||||
var files: [RealDebrid.IAFile] = []
|
|
||||||
|
|
||||||
for index in batches.indices {
|
|
||||||
let batchFiles = batches[index].files
|
|
||||||
|
|
||||||
for batchFileIndex in batchFiles.indices {
|
|
||||||
let batchFile = batchFiles[batchFileIndex]
|
|
||||||
|
|
||||||
if !files.contains(where: { $0.name == batchFile.fileName }) {
|
|
||||||
files.append(
|
|
||||||
RealDebrid.IAFile(
|
|
||||||
name: batchFile.fileName,
|
|
||||||
batchIndex: index,
|
|
||||||
batchFileIndex: batchFileIndex
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTL: 5 minutes
|
|
||||||
availableHashes.append(
|
|
||||||
RealDebrid.IA(
|
|
||||||
magnet: Magnet(hash: hash, link: nil),
|
|
||||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
|
||||||
files: files,
|
|
||||||
batches: batches
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
availableHashes.append(
|
return true
|
||||||
RealDebrid.IA(
|
}
|
||||||
magnet: Magnet(hash: hash, link: nil),
|
}
|
||||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300
|
|
||||||
|
// Fetch the user magnets to the latest version
|
||||||
|
try await getUserMagnets()
|
||||||
|
|
||||||
|
for cloudMagnet in cloudMagnets {
|
||||||
|
if cachedStatus.contains(cloudMagnet.status),
|
||||||
|
sendMagnets.contains(where: { $0.hash == cloudMagnet.hash })
|
||||||
|
{
|
||||||
|
IAValues.append(
|
||||||
|
DebridIA(
|
||||||
|
magnet: Magnet(hash: cloudMagnet.hash, link: nil),
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
|
files: []
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return availableHashes
|
// MARK: - Downloading
|
||||||
|
|
||||||
|
// Wrapper function to fetch a download link from the API
|
||||||
|
func getRestrictedFile(magnet: Magnet, ia: DebridIA?, iaFile: DebridIAFile?) async throws -> (restrictedFile: DebridIAFile?, newIA: DebridIA?) {
|
||||||
|
var selectedMagnetId = ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Don't queue a new job if the magnet already exists in the user's library
|
||||||
|
if let existingCloudMagnet = cloudMagnets.first(where: {
|
||||||
|
$0.hash == magnet.hash && cachedStatus.contains($0.status)
|
||||||
|
}) {
|
||||||
|
selectedMagnetId = existingCloudMagnet.id
|
||||||
|
} else {
|
||||||
|
selectedMagnetId = try await addMagnet(magnet: magnet)
|
||||||
|
|
||||||
|
try await selectFiles(debridID: selectedMagnetId, fileIds: iaFile?.batchIds ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try await torrentInfo(debridID: selectedMagnetId)
|
||||||
|
let filteredFiles = response.files.filter { $0.selected == 1 }
|
||||||
|
|
||||||
|
// Need to return this to the user
|
||||||
|
if filteredFiles.count > 1, iaFile == nil {
|
||||||
|
var copiedIA = ia
|
||||||
|
|
||||||
|
copiedIA?.files = response.files.enumerated().compactMap { index, file in
|
||||||
|
DebridIAFile(
|
||||||
|
id: index,
|
||||||
|
name: file.path,
|
||||||
|
streamUrlString: response.links[safe: index]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (nil, copiedIA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealDebrid has 1 as the first ID for a file
|
||||||
|
let selectedFileId = iaFile?.id ?? 1
|
||||||
|
let linkIndex = filteredFiles.firstIndex(where: { $0.id == selectedFileId })
|
||||||
|
|
||||||
|
guard let cloudMagnetLink = response.links[safe: linkIndex ?? -1] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
let restrictedFile = DebridIAFile(id: 0, name: response.filename, streamUrlString: cloudMagnetLink)
|
||||||
|
return (restrictedFile, nil)
|
||||||
|
} catch {
|
||||||
|
if case DebridError.EmptyUserMagnets = error, !selectedMagnetId.isEmpty {
|
||||||
|
try? await deleteUserMagnet(cloudMagnetId: selectedMagnetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-raise the error to the calling function
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a magnet link to the user's RD account
|
// Adds a magnet link to the user's RD account
|
||||||
public func addMagnet(magnet: Magnet) async throws -> String {
|
func addMagnet(magnet: Magnet) async throws -> String {
|
||||||
guard let magnetLink = magnet.link else {
|
guard let magnetLink = magnet.link else {
|
||||||
throw RDError.FailedRequest(description: "The magnet link is invalid")
|
throw DebridError.FailedRequest(description: "The magnet link is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/addMagnet")!)
|
||||||
|
|
@ -292,7 +366,7 @@ public class RealDebrid {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queues the magnet link for downloading
|
// Queues the magnet link for downloading
|
||||||
public func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
func selectFiles(debridID: String, fileIds: [Int]) async throws {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/selectFiles/\(debridID)")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
@ -312,48 +386,31 @@ public class RealDebrid {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the info of a torrent from a given ID
|
// Gets the info of a torrent from a given ID
|
||||||
public func torrentInfo(debridID: String, selectedIndex: Int?) async throws -> String {
|
func torrentInfo(debridID: String) async throws -> TorrentInfoResponse {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/info/\(debridID)")!)
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
|
let rawResponse = try jsonDecoder.decode(TorrentInfoResponse.self, from: data)
|
||||||
|
|
||||||
// Let the user know if a torrent is downloading
|
// Let the user know if a magnet is downloading
|
||||||
if let torrentLink = rawResponse.links[safe: selectedIndex ?? -1], rawResponse.status == "downloaded" {
|
switch rawResponse.status {
|
||||||
return torrentLink
|
case "downloaded":
|
||||||
} else if rawResponse.status == "downloading" || rawResponse.status == "queued" {
|
return rawResponse
|
||||||
throw RDError.EmptyTorrents
|
case "downloading", "queued":
|
||||||
} else {
|
throw DebridError.IsCaching
|
||||||
throw RDError.EmptyData
|
default:
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the user's torrent library
|
|
||||||
public func userTorrents() async throws -> [UserTorrentsResponse] {
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
|
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
|
||||||
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
|
|
||||||
|
|
||||||
return rawResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes a torrent download from RD
|
|
||||||
public func deleteTorrent(debridID: String) async throws {
|
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!)
|
|
||||||
request.httpMethod = "DELETE"
|
|
||||||
|
|
||||||
try await performRequest(request: &request, requestName: #function)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downloads link from selectFiles for playback
|
// Downloads link from selectFiles for playback
|
||||||
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
|
func unrestrictFile(_ restrictedFile: DebridIAFile) async throws -> String {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
var bodyComponents = URLComponents()
|
var bodyComponents = URLComponents()
|
||||||
bodyComponents.queryItems = [URLQueryItem(name: "link", value: debridDownloadLink)]
|
bodyComponents.queryItems = [URLQueryItem(name: "link", value: restrictedFile.streamUrlString)]
|
||||||
|
|
||||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||||
|
|
||||||
|
|
@ -363,18 +420,66 @@ public class RealDebrid {
|
||||||
return rawResponse.download
|
return rawResponse.download
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cloud methods
|
||||||
|
|
||||||
|
// Gets the user's cloud magnet library
|
||||||
|
func getUserMagnets() async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents")!)
|
||||||
|
|
||||||
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
|
let rawResponse = try jsonDecoder.decode([UserTorrentsResponse].self, from: data)
|
||||||
|
cloudMagnets = rawResponse.map { response in
|
||||||
|
DebridCloudMagnet(
|
||||||
|
id: response.id,
|
||||||
|
fileName: response.filename,
|
||||||
|
status: response.status,
|
||||||
|
hash: response.hash,
|
||||||
|
links: [response.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes a magnet download from RD
|
||||||
|
func deleteUserMagnet(cloudMagnetId: String?) async throws {
|
||||||
|
let deleteId: String
|
||||||
|
|
||||||
|
if let cloudMagnetId {
|
||||||
|
deleteId = cloudMagnetId
|
||||||
|
} else {
|
||||||
|
// Refresh the user magnet list
|
||||||
|
// The first file is the currently caching one
|
||||||
|
let _ = try await getUserMagnets()
|
||||||
|
guard let firstCloudMagnet = cloudMagnets[safe: -1] else {
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteId = firstCloudMagnet.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(deleteId)")!)
|
||||||
|
request.httpMethod = "DELETE"
|
||||||
|
|
||||||
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
}
|
||||||
|
|
||||||
// Gets the user's downloads
|
// Gets the user's downloads
|
||||||
public func userDownloads() async throws -> [UserDownloadsResponse] {
|
func getUserDownloads() async throws {
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads")!)
|
||||||
|
|
||||||
let data = try await performRequest(request: &request, requestName: #function)
|
let data = try await performRequest(request: &request, requestName: #function)
|
||||||
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
|
let rawResponse = try jsonDecoder.decode([UserDownloadsResponse].self, from: data)
|
||||||
|
cloudDownloads = rawResponse.map { response in
|
||||||
return rawResponse
|
DebridCloudDownload(id: response.id, fileName: response.filename, link: response.download)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deleteDownload(debridID: String) async throws {
|
// Not used
|
||||||
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(debridID)")!)
|
func checkUserDownloads(link: String) -> String? {
|
||||||
|
link
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUserDownload(downloadId: String) async throws {
|
||||||
|
var request = URLRequest(url: URL(string: "\(baseApiUrl)/downloads/delete/\(downloadId)")!)
|
||||||
request.httpMethod = "DELETE"
|
request.httpMethod = "DELETE"
|
||||||
|
|
||||||
try await performRequest(request: &request, requestName: #function)
|
try await performRequest(request: &request, requestName: #function)
|
||||||
|
|
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ public extension Action {
|
||||||
@NSManaged var name: String
|
@NSManaged var name: String
|
||||||
@NSManaged var deeplink: String?
|
@NSManaged var deeplink: String?
|
||||||
@NSManaged var version: Int16
|
@NSManaged var version: Int16
|
||||||
|
@NSManaged var about: String?
|
||||||
|
@NSManaged var website: String?
|
||||||
@NSManaged var requires: [String]
|
@NSManaged var requires: [String]
|
||||||
@NSManaged var author: String
|
@NSManaged var author: String
|
||||||
@NSManaged var enabled: Bool
|
@NSManaged var enabled: Bool
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,4 @@ import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@objc(Bookmark)
|
@objc(Bookmark)
|
||||||
public class Bookmark: NSManagedObject {}
|
class Bookmark: NSManagedObject {}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import CoreData
|
import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension Bookmark {
|
extension Bookmark {
|
||||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
|
@nonobjc class func fetchRequest() -> NSFetchRequest<Bookmark> {
|
||||||
NSFetchRequest<Bookmark>(entityName: "Bookmark")
|
NSFetchRequest<Bookmark>(entityName: "Bookmark")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ public extension Source {
|
||||||
}
|
}
|
||||||
|
|
||||||
@NSManaged var id: UUID
|
@NSManaged var id: UUID
|
||||||
@NSManaged var baseUrl: String?
|
@NSManaged var about: String?
|
||||||
|
@NSManaged var website: String?
|
||||||
|
@NSManaged var dynamicWebsite: Bool
|
||||||
@NSManaged var fallbackUrls: [String]?
|
@NSManaged var fallbackUrls: [String]?
|
||||||
@NSManaged var dynamicBaseUrl: Bool
|
|
||||||
@NSManaged var enabled: Bool
|
@NSManaged var enabled: Bool
|
||||||
@NSManaged var name: String
|
@NSManaged var name: String
|
||||||
@NSManaged var author: String
|
@NSManaged var author: String
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ public extension SourceHtmlParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
@NSManaged var rows: String
|
@NSManaged var rows: String
|
||||||
@NSManaged var searchUrl: String
|
@NSManaged var searchUrl: String?
|
||||||
|
@NSManaged var request: SourceRequest?
|
||||||
@NSManaged var magnetHash: SourceMagnetHash?
|
@NSManaged var magnetHash: SourceMagnetHash?
|
||||||
@NSManaged var magnetLink: SourceMagnetLink?
|
@NSManaged var magnetLink: SourceMagnetLink?
|
||||||
@NSManaged var parentSource: Source?
|
@NSManaged var parentSource: Source?
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ public extension SourceJsonParser {
|
||||||
@NSManaged var results: String?
|
@NSManaged var results: String?
|
||||||
@NSManaged var subResults: String?
|
@NSManaged var subResults: String?
|
||||||
@NSManaged var searchUrl: String
|
@NSManaged var searchUrl: String
|
||||||
|
@NSManaged var request: SourceRequest?
|
||||||
@NSManaged var magnetHash: SourceMagnetHash?
|
@NSManaged var magnetHash: SourceMagnetHash?
|
||||||
@NSManaged var magnetLink: SourceMagnetLink?
|
@NSManaged var magnetLink: SourceMagnetLink?
|
||||||
@NSManaged var parentSource: Source?
|
@NSManaged var parentSource: Source?
|
||||||
|
|
|
||||||
|
|
@ -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,6 +17,7 @@ public extension SourceRssParser {
|
||||||
@NSManaged var items: String
|
@NSManaged var items: String
|
||||||
@NSManaged var rssUrl: String?
|
@NSManaged var rssUrl: String?
|
||||||
@NSManaged var searchUrl: String
|
@NSManaged var searchUrl: String
|
||||||
|
@NSManaged var request: SourceRequest?
|
||||||
@NSManaged var magnetHash: SourceMagnetHash?
|
@NSManaged var magnetHash: SourceMagnetHash?
|
||||||
@NSManaged var magnetLink: SourceMagnetLink?
|
@NSManaged var magnetLink: SourceMagnetLink?
|
||||||
@NSManaged var parentSource: Source?
|
@NSManaged var parentSource: Source?
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Action" representedClassName="Action" syncable="YES">
|
<entity name="Action" representedClassName="Action" syncable="YES">
|
||||||
|
<attribute name="about" optional="YES" attributeType="String"/>
|
||||||
<attribute name="author" attributeType="String" defaultValueString=""/>
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="deeplink" optional="YES" attributeType="String"/>
|
<attribute name="deeplink" optional="YES" attributeType="String"/>
|
||||||
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
|
@ -9,6 +10,7 @@
|
||||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
<attribute name="requires" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="website" optional="YES" attributeType="String"/>
|
||||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/>
|
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PluginTag" inverseName="parentAction" inverseEntity="PluginTag"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
|
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
|
||||||
|
|
@ -53,9 +55,9 @@
|
||||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="tags" inverseEntity="Source"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Source" representedClassName="Source" syncable="YES">
|
<entity name="Source" representedClassName="Source" syncable="YES">
|
||||||
|
<attribute name="about" optional="YES" attributeType="String"/>
|
||||||
<attribute name="author" attributeType="String" defaultValueString=""/>
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="baseUrl" optional="YES" attributeType="String"/>
|
<attribute name="dynamicWebsite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="dynamicBaseUrl" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
<attribute name="fallbackUrls" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
|
@ -64,6 +66,7 @@
|
||||||
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="preferredParser" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
<attribute name="trackers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/>
|
||||||
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="version" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="website" optional="YES" attributeType="String"/>
|
||||||
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
|
<relationship name="api" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceApi" inverseName="parentSource" inverseEntity="SourceApi"/>
|
||||||
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
|
<relationship name="htmlParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceHtmlParser" inverseName="parentSource" inverseEntity="SourceHtmlParser"/>
|
||||||
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
|
<relationship name="jsonParser" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceJsonParser" inverseName="parentSource" inverseEntity="SourceJsonParser"/>
|
||||||
|
|
@ -99,10 +102,11 @@
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
|
<entity name="SourceHtmlParser" representedClassName="SourceHtmlParser" syncable="YES">
|
||||||
<attribute name="rows" attributeType="String" defaultValueString=""/>
|
<attribute name="rows" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="searchUrl" attributeType="String" defaultValueString=""/>
|
<attribute name="searchUrl" optional="YES" attributeType="String"/>
|
||||||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
|
||||||
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentHtmlParser" inverseEntity="SourceMagnetLink"/>
|
||||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="htmlParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentHtmlParser" inverseEntity="SourceRequest"/>
|
||||||
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
|
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentHtmlParser" inverseEntity="SourceSeedLeech"/>
|
||||||
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
|
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentHtmlParser" inverseEntity="SourceSize"/>
|
||||||
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
|
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentHtmlParser" inverseEntity="SourceSubName"/>
|
||||||
|
|
@ -115,6 +119,7 @@
|
||||||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentJsonParser" inverseEntity="SourceMagnetHash"/>
|
||||||
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentJsonParser" inverseEntity="SourceMagnetLink"/>
|
||||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="jsonParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentJsonParser" inverseEntity="SourceRequest"/>
|
||||||
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
|
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentJsonParser" inverseEntity="SourceSeedLeech"/>
|
||||||
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
|
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentJsonParser" inverseEntity="SourceSize"/>
|
||||||
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
|
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentJsonParser" inverseEntity="SourceSubName"/>
|
||||||
|
|
@ -131,6 +136,14 @@
|
||||||
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="magnetLink" inverseEntity="SourceJsonParser"/>
|
||||||
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="magnetLink" inverseEntity="SourceRssParser"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="SourceRequest" representedClassName="SourceRequest" syncable="YES">
|
||||||
|
<attribute name="body" optional="YES" attributeType="String" valueTransformerName="NSSecureUnarchiveFromData"/>
|
||||||
|
<attribute name="headers" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String: String]"/>
|
||||||
|
<attribute name="method" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="parentHtmlParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceHtmlParser" inverseName="request" inverseEntity="SourceHtmlParser"/>
|
||||||
|
<relationship name="parentJsonParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceJsonParser" inverseName="request" inverseEntity="SourceJsonParser"/>
|
||||||
|
<relationship name="parentRssParser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRssParser" inverseName="request" inverseEntity="SourceRssParser"/>
|
||||||
|
</entity>
|
||||||
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
|
<entity name="SourceRssParser" representedClassName="SourceRssParser" syncable="YES">
|
||||||
<attribute name="items" attributeType="String" defaultValueString=""/>
|
<attribute name="items" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="rssUrl" optional="YES" attributeType="String"/>
|
<attribute name="rssUrl" optional="YES" attributeType="String"/>
|
||||||
|
|
@ -138,6 +151,7 @@
|
||||||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
|
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentRssParser" inverseEntity="SourceMagnetHash"/>
|
||||||
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
|
<relationship name="magnetLink" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetLink" inverseName="parentRssParser" inverseEntity="SourceMagnetLink"/>
|
||||||
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
|
<relationship name="parentSource" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="rssParser" inverseEntity="Source"/>
|
||||||
|
<relationship name="request" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceRequest" inverseName="parentRssParser" inverseEntity="SourceRequest"/>
|
||||||
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
|
<relationship name="seedLeech" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSeedLeech" inverseName="parentRssParser" inverseEntity="SourceSeedLeech"/>
|
||||||
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
|
<relationship name="size" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceSize" inverseName="parentRssParser" inverseEntity="SourceSize"/>
|
||||||
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>
|
<relationship name="subName" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SourceSubName" inverseName="parentRssParser" inverseEntity="SourceSubName"/>
|
||||||
|
|
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,6 @@ import Foundation
|
||||||
|
|
||||||
extension OperatingSystemVersion {
|
extension OperatingSystemVersion {
|
||||||
func toString() -> String {
|
func toString() -> String {
|
||||||
return "\(self.majorVersion).\(self.minorVersion).\(self.patchVersion)"
|
"\(majorVersion).\(minorVersion).\(patchVersion)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Array.swift
|
// Set.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 11/26/22.
|
// Created by Brian Dashore on 11/26/22.
|
||||||
|
|
|
||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,10 @@
|
||||||
// Created by Brian Dashore on 2/16/23.
|
// Created by Brian Dashore on 2/16/23.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import UIKit
|
||||||
|
|
||||||
extension UIDevice {
|
extension UIDevice {
|
||||||
var hasNotch: Bool {
|
var hasNotch: Bool {
|
||||||
if #available(iOS 11.0, *) {
|
UIApplication.shared.currentUIWindow?.safeAreaInsets.bottom ?? 0 > 0
|
||||||
let keyWindow = UIApplication.shared.windows.filter(\.isKeyWindow).first
|
|
||||||
return keyWindow?.safeAreaInsets.bottom ?? 0 > 0
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,20 @@
|
||||||
// Created by Brian Dashore on 8/15/22.
|
// Created by Brian Dashore on 8/15/22.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Introspect
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
// Modifies properties of a view. Works the same way as a ViewModifier
|
||||||
|
// From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10
|
||||||
|
func modifyViewProp(_ body: (inout Self) -> Void) -> Self {
|
||||||
|
var result = self
|
||||||
|
body(&result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Modifiers
|
// MARK: Modifiers
|
||||||
|
|
||||||
func conditionalContextMenu(id: some Hashable,
|
|
||||||
@ViewBuilder _ internalContent: @escaping () -> some View) -> some View
|
|
||||||
{
|
|
||||||
modifier(ConditionalContextMenuModifier(internalContent, id: id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func conditionalId(_ id: some Hashable) -> some View {
|
|
||||||
modifier(ConditionalIdModifier(id: id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
|
func disabledAppearance(_ disabled: Bool, dimmedOpacity: Double? = nil, animation: Animation? = nil) -> some View {
|
||||||
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
|
modifier(DisabledAppearanceModifier(disabled: disabled, dimmedOpacity: dimmedOpacity, animation: animation))
|
||||||
}
|
}
|
||||||
|
|
@ -32,12 +30,4 @@ extension View {
|
||||||
func inlinedList(inset: CGFloat) -> some View {
|
func inlinedList(inset: CGFloat) -> some View {
|
||||||
modifier(InlinedListModifier(inset: inset))
|
modifier(InlinedListModifier(inset: inset))
|
||||||
}
|
}
|
||||||
|
|
||||||
func viewDidAppear(_ callback: @escaping () -> Void) -> some View {
|
|
||||||
modifier(ViewDidAppearModifier(callback: callback))
|
|
||||||
}
|
|
||||||
|
|
||||||
func customScopeBar(_ content: @escaping () -> some View) -> some View {
|
|
||||||
modifier(CustomScopeBarModifier(scopeBarContent: content()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ struct FerriteApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
MainView()
|
MainView()
|
||||||
.backport.onAppear {
|
.onAppear {
|
||||||
scrapingModel.logManager = logManager
|
scrapingModel.logManager = logManager
|
||||||
debridManager.logManager = logManager
|
debridManager.logManager = logManager
|
||||||
pluginManager.logManager = logManager
|
pluginManager.logManager = logManager
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<string>Ferrite</string>
|
<string>Ferrite</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>ferrite://</string>
|
<string>ferrite</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
|
|
||||||
|
|
@ -7,43 +7,55 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct ActionJson: Codable, Hashable, PluginJson {
|
struct ActionJson: Codable, Hashable, PluginJson {
|
||||||
public let name: String
|
let name: String
|
||||||
public let version: Int16
|
let version: Int16
|
||||||
let minVersion: String?
|
let minVersion: String?
|
||||||
|
let about: String?
|
||||||
|
let website: String?
|
||||||
let requires: [ActionRequirement]
|
let requires: [ActionRequirement]
|
||||||
let deeplink: [DeeplinkActionJson]?
|
let deeplink: [DeeplinkActionJson]?
|
||||||
public let author: String?
|
let author: String?
|
||||||
public let listId: UUID?
|
let listId: UUID?
|
||||||
public let tags: [PluginTagJson]?
|
let listName: String?
|
||||||
|
let tags: [PluginTagJson]?
|
||||||
|
|
||||||
public init(name: String,
|
init(name: String,
|
||||||
version: Int16,
|
version: Int16,
|
||||||
minVersion: String?,
|
minVersion: String?,
|
||||||
requires: [ActionRequirement],
|
about: String?,
|
||||||
deeplink: [DeeplinkActionJson]?,
|
website: String?,
|
||||||
author: String?,
|
requires: [ActionRequirement],
|
||||||
listId: UUID?,
|
deeplink: [DeeplinkActionJson]?,
|
||||||
tags: [PluginTagJson]?)
|
author: String?,
|
||||||
|
listId: UUID?,
|
||||||
|
listName: String?,
|
||||||
|
tags: [PluginTagJson]?)
|
||||||
{
|
{
|
||||||
self.name = name
|
self.name = name
|
||||||
self.version = version
|
self.version = version
|
||||||
self.minVersion = minVersion
|
self.minVersion = minVersion
|
||||||
|
self.about = about
|
||||||
|
self.website = website
|
||||||
self.requires = requires
|
self.requires = requires
|
||||||
self.deeplink = deeplink
|
self.deeplink = deeplink
|
||||||
self.author = author
|
self.author = author
|
||||||
self.listId = listId
|
self.listId = listId
|
||||||
|
self.listName = listName
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
name = try container.decode(String.self, forKey: .name)
|
name = try container.decode(String.self, forKey: .name)
|
||||||
version = try container.decode(Int16.self, forKey: .version)
|
version = try container.decode(Int16.self, forKey: .version)
|
||||||
minVersion = try container.decodeIfPresent(String.self, forKey: .minVersion)
|
minVersion = try container.decodeIfPresent(String.self, forKey: .minVersion)
|
||||||
|
about = try container.decodeIfPresent(String.self, forKey: .about)
|
||||||
|
website = try container.decodeIfPresent(String.self, forKey: .website)
|
||||||
requires = try container.decode([ActionRequirement].self, forKey: .requires)
|
requires = try container.decode([ActionRequirement].self, forKey: .requires)
|
||||||
author = try container.decodeIfPresent(String.self, forKey: .author)
|
author = try container.decodeIfPresent(String.self, forKey: .author)
|
||||||
listId = try container.decodeIfPresent(UUID.self, forKey: .listId)
|
listId = nil
|
||||||
|
listName = nil
|
||||||
tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags)
|
tags = try container.decodeIfPresent([PluginTagJson].self, forKey: .tags)
|
||||||
|
|
||||||
if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) {
|
if let deeplinkString = try? container.decode(String.self, forKey: .deeplink) {
|
||||||
|
|
@ -56,7 +68,7 @@ public struct ActionJson: Codable, Hashable, PluginJson {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct DeeplinkActionJson: Codable, Hashable {
|
struct DeeplinkActionJson: Codable, Hashable {
|
||||||
let os: [String]
|
let os: [String]
|
||||||
let scheme: String
|
let scheme: String
|
||||||
|
|
||||||
|
|
@ -65,7 +77,7 @@ public struct DeeplinkActionJson: Codable, Hashable {
|
||||||
self.scheme = scheme
|
self.scheme = scheme
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
if let os = try? container.decode(String.self, forKey: .os) {
|
if let os = try? container.decode(String.self, forKey: .os) {
|
||||||
|
|
@ -80,7 +92,7 @@ public struct DeeplinkActionJson: Codable, Hashable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension ActionJson {
|
extension ActionJson {
|
||||||
// Fetches all tags without optional requirement
|
// Fetches all tags without optional requirement
|
||||||
// Avoids the need for extra tag additions in DB
|
// Avoids the need for extra tag additions in DB
|
||||||
func getTags() -> [PluginTagJson] {
|
func getTags() -> [PluginTagJson] {
|
||||||
|
|
@ -88,7 +100,7 @@ public extension ActionJson {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ActionRequirement: String, Codable {
|
enum ActionRequirement: String, Codable {
|
||||||
case magnet
|
case magnet
|
||||||
case debrid
|
case debrid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension AllDebrid {
|
extension AllDebrid {
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
// TODO: Hybridize debrid errors in one structure
|
|
||||||
enum ADError: Error {
|
|
||||||
case InvalidUrl
|
|
||||||
case InvalidPostBody
|
|
||||||
case InvalidResponse
|
|
||||||
case InvalidToken
|
|
||||||
case EmptyData
|
|
||||||
case EmptyTorrents
|
|
||||||
case FailedRequest(description: String)
|
|
||||||
case AuthQuery(description: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic AllDebrid response
|
// MARK: - Generic AllDebrid response
|
||||||
|
|
||||||
// Uses a generic parametr for whatever underlying response is present
|
// Uses a generic parametr for whatever underlying response is present
|
||||||
|
|
@ -67,7 +53,7 @@ public extension AllDebrid {
|
||||||
|
|
||||||
// MARK: - AddMagnetData
|
// MARK: - AddMagnetData
|
||||||
|
|
||||||
internal struct AddMagnetData: Codable {
|
struct AddMagnetData: Codable {
|
||||||
let magnet, hash, name, filenameOriginal: String
|
let magnet, hash, name, filenameOriginal: String
|
||||||
let size: Int
|
let size: Int
|
||||||
let ready: Bool
|
let ready: Bool
|
||||||
|
|
@ -85,7 +71,7 @@ public extension AllDebrid {
|
||||||
struct MagnetStatusResponse: Codable {
|
struct MagnetStatusResponse: Codable {
|
||||||
let magnets: [MagnetStatusData]
|
let magnets: [MagnetStatusData]
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
|
if let data = try? container.decode(MagnetStatusData.self, forKey: .magnets) {
|
||||||
|
|
@ -117,7 +103,7 @@ public extension AllDebrid {
|
||||||
// MARK: - MagnetStatusLink
|
// MARK: - MagnetStatusLink
|
||||||
|
|
||||||
// Abridged for required parameters
|
// Abridged for required parameters
|
||||||
internal struct MagnetStatusLink: Codable {
|
struct MagnetStatusLink: Codable {
|
||||||
let link: String
|
let link: String
|
||||||
let filename: String
|
let filename: String
|
||||||
let size: Int
|
let size: Int
|
||||||
|
|
@ -130,6 +116,19 @@ public extension AllDebrid {
|
||||||
let link: String
|
let link: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - SavedLinksResponse
|
||||||
|
|
||||||
|
struct SavedLinksResponse: Codable {
|
||||||
|
let links: [SavedLink]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SavedLink: Codable, Hashable {
|
||||||
|
let link: String
|
||||||
|
let date: Int
|
||||||
|
let filename: String
|
||||||
|
let size: Int
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - InstantAvailabilityResponse
|
// MARK: - InstantAvailabilityResponse
|
||||||
|
|
||||||
struct InstantAvailabilityResponse: Codable {
|
struct InstantAvailabilityResponse: Codable {
|
||||||
|
|
@ -138,7 +137,7 @@ public extension AllDebrid {
|
||||||
|
|
||||||
// MARK: - IAMagnetResponse
|
// MARK: - IAMagnetResponse
|
||||||
|
|
||||||
internal struct InstantAvailabilityMagnet: Codable {
|
struct InstantAvailabilityMagnet: Codable {
|
||||||
let magnet, hash: String
|
let magnet, hash: String
|
||||||
let instant: Bool
|
let instant: Bool
|
||||||
let files: [InstantAvailabilityFile]?
|
let files: [InstantAvailabilityFile]?
|
||||||
|
|
@ -146,24 +145,11 @@ public extension AllDebrid {
|
||||||
|
|
||||||
// MARK: - IAFileResponse
|
// MARK: - IAFileResponse
|
||||||
|
|
||||||
internal struct InstantAvailabilityFile: Codable {
|
struct InstantAvailabilityFile: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name = "n"
|
case name = "n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - InstantAvailablity client side structures
|
|
||||||
|
|
||||||
struct IA: Codable, Hashable {
|
|
||||||
let magnet: Magnet
|
|
||||||
let expiryTimeStamp: Double
|
|
||||||
var files: [IAFile]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IAFile: Codable, Hashable {
|
|
||||||
let id: Int
|
|
||||||
let fileName: String
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// Version is optional until v1 is phased out
|
// Version is optional until v1 is phased out
|
||||||
public struct Backup: Codable {
|
struct Backup: Codable {
|
||||||
let version: Int?
|
let version: Int?
|
||||||
var bookmarks: [BookmarkJson]?
|
var bookmarks: [BookmarkJson]?
|
||||||
var history: [HistoryJson]?
|
var history: [HistoryJson]?
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@ import Foundation
|
||||||
|
|
||||||
// MARK: - Universal IA enum (IA = InstantAvailability)
|
// MARK: - Universal IA enum (IA = InstantAvailability)
|
||||||
|
|
||||||
public enum IAStatus: Codable, Hashable, Sendable {
|
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||||
case full
|
case full = "Cached"
|
||||||
case partial
|
case partial = "Batch"
|
||||||
case none
|
case none = "Uncached"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enum for debrid differentiation. 0 is nil
|
// MARK: - Enum for debrid differentiation. 0 is nil
|
||||||
|
|
||||||
public enum DebridType: Int, Codable, Hashable, CaseIterable {
|
enum DebridType: Int, Codable, Hashable, CaseIterable {
|
||||||
case realDebrid = 1
|
case realDebrid = 1
|
||||||
case allDebrid = 2
|
case allDebrid = 2
|
||||||
case premiumize = 3
|
case premiumize = 3
|
||||||
|
|
@ -47,7 +47,7 @@ public enum DebridType: Int, Codable, Hashable, CaseIterable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper struct for magnet links to contain both the link and hash for easy access
|
// Wrapper struct for magnet links to contain both the link and hash for easy access
|
||||||
public struct Magnet: Codable, Hashable, Sendable {
|
struct Magnet: Codable, Hashable, Sendable {
|
||||||
var hash: String?
|
var hash: String?
|
||||||
var link: String?
|
var link: String?
|
||||||
|
|
||||||
|
|
@ -56,11 +56,13 @@ public struct Magnet: Codable, Hashable, Sendable {
|
||||||
self.hash = parseHash(hash)
|
self.hash = parseHash(hash)
|
||||||
self.link = generateLink(hash: hash, title: title, trackers: trackers)
|
self.link = generateLink(hash: hash, title: title, trackers: trackers)
|
||||||
} else if let link, hash == nil {
|
} else if let link, hash == nil {
|
||||||
|
let (link, hash) = parseLink(link)
|
||||||
|
|
||||||
self.link = link
|
self.link = link
|
||||||
self.hash = parseHash(extractHash(link: link))
|
self.hash = hash
|
||||||
} else {
|
} else {
|
||||||
self.hash = parseHash(hash)
|
self.hash = parseHash(hash)
|
||||||
self.link = link
|
self.link = parseLink(link).link
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,4 +109,36 @@ public struct Magnet: Codable, Hashable, Sendable {
|
||||||
return String(magnetHash).lowercased()
|
return String(magnetHash).lowercased()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseLink(_ link: String?, withHash: Bool = false) -> (link: String?, hash: String?) {
|
||||||
|
let separator = "magnet:?xt=urn:btih:"
|
||||||
|
|
||||||
|
// Remove percent encoding from the link and ensure it's a magnet
|
||||||
|
guard let decodedLink = link?.removingPercentEncoding, decodedLink.contains(separator) else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolate the magnet link if it's bundled with another protocol
|
||||||
|
let isolatedLink: String?
|
||||||
|
if decodedLink.starts(with: separator) {
|
||||||
|
isolatedLink = decodedLink
|
||||||
|
} else {
|
||||||
|
let splitLink = decodedLink.components(separatedBy: separator)
|
||||||
|
isolatedLink = splitLink.last.map { separator + $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let isolatedLink else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hash can be extracted, decrypt it if necessary and return the revised link + hash
|
||||||
|
if let originalHash = extractHash(link: isolatedLink),
|
||||||
|
let parsedHash = parseHash(originalHash)
|
||||||
|
{
|
||||||
|
let replacedLink = isolatedLink.replacingOccurrences(of: originalHash, with: parsedHash)
|
||||||
|
return (replacedLink, parsedHash)
|
||||||
|
} else {
|
||||||
|
return (decodedLink, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension Github {
|
extension Github {
|
||||||
struct Release: Codable, Hashable, Sendable {
|
struct Release: Codable, Hashable, Sendable {
|
||||||
let htmlUrl: String
|
let htmlUrl: String
|
||||||
let tagName: String
|
let tagName: String
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct PluginListJson: Codable {
|
struct PluginListJson: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
let author: String
|
let author: String
|
||||||
var sources: [SourceJson]?
|
var sources: [SourceJson]?
|
||||||
|
|
@ -16,8 +16,8 @@ public struct PluginListJson: Codable {
|
||||||
|
|
||||||
// Color: Hex value
|
// Color: Hex value
|
||||||
public struct PluginTagJson: Codable, Hashable, Sendable {
|
public struct PluginTagJson: Codable, Hashable, Sendable {
|
||||||
public let name: String
|
let name: String
|
||||||
public let colorHex: String?
|
let colorHex: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
|
|
@ -29,6 +29,7 @@ extension PluginManager {
|
||||||
enum PluginManagerError: Error {
|
enum PluginManagerError: Error {
|
||||||
case ListAddition(description: String)
|
case ListAddition(description: String)
|
||||||
case ActionAddition(description: String)
|
case ActionAddition(description: String)
|
||||||
|
case PluginFetch(description: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AvailablePlugins {
|
struct AvailablePlugins {
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension Premiumize {
|
extension Premiumize {
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
// TODO: Hybridize debrid errors in one structure
|
|
||||||
enum PMError: Error {
|
|
||||||
case InvalidUrl
|
|
||||||
case InvalidPostBody
|
|
||||||
case InvalidResponse
|
|
||||||
case InvalidToken
|
|
||||||
case EmptyData
|
|
||||||
case EmptyTorrents
|
|
||||||
case FailedRequest(description: String)
|
|
||||||
case AuthQuery(description: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CacheCheckResponse
|
// MARK: - CacheCheckResponse
|
||||||
|
|
||||||
struct CacheCheckResponse: Codable {
|
struct CacheCheckResponse: Codable {
|
||||||
|
|
@ -33,8 +19,7 @@ public extension Premiumize {
|
||||||
|
|
||||||
struct DDLResponse: Codable {
|
struct DDLResponse: Codable {
|
||||||
let status: String
|
let status: String
|
||||||
let content: [DDLData]
|
let content: [DDLData]?
|
||||||
let location: String
|
|
||||||
let filename: String
|
let filename: String
|
||||||
let filesize: Int
|
let filesize: Int
|
||||||
}
|
}
|
||||||
|
|
@ -45,27 +30,12 @@ public extension Premiumize {
|
||||||
let path: String
|
let path: String
|
||||||
let size: Int
|
let size: Int
|
||||||
let link: String
|
let link: String
|
||||||
let streamLink: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case path, size, link
|
case path, size, link
|
||||||
case streamLink = "stream_link"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - InstantAvailability client side structures
|
|
||||||
|
|
||||||
struct IA: Codable, Hashable {
|
|
||||||
let magnet: Magnet
|
|
||||||
let expiryTimeStamp: Double
|
|
||||||
let files: [IAFile]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IAFile: Codable, Hashable {
|
|
||||||
let name: String
|
|
||||||
let streamUrlString: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AllItemsResponse (listall endpoint)
|
// MARK: - AllItemsResponse (listall endpoint)
|
||||||
|
|
||||||
struct AllItemsResponse: Codable {
|
struct AllItemsResponse: Codable {
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension RealDebrid {
|
extension RealDebrid {
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
// TODO: Hybridize debrid errors in one structure
|
|
||||||
enum RDError: Error {
|
|
||||||
case InvalidUrl
|
|
||||||
case InvalidPostBody
|
|
||||||
case InvalidResponse
|
|
||||||
case InvalidToken
|
|
||||||
case EmptyData
|
|
||||||
case EmptyTorrents
|
|
||||||
case FailedRequest(description: String)
|
|
||||||
case AuthQuery(description: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - device code endpoint
|
// MARK: - device code endpoint
|
||||||
|
|
||||||
struct DeviceCodeResponse: Codable, Sendable {
|
struct DeviceCodeResponse: Codable, Sendable {
|
||||||
|
|
@ -72,7 +58,7 @@ public extension RealDebrid {
|
||||||
struct InstantAvailabilityResponse: Codable, Sendable {
|
struct InstantAvailabilityResponse: Codable, Sendable {
|
||||||
var data: InstantAvailabilityData?
|
var data: InstantAvailabilityData?
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
|
|
||||||
if let data = try? container.decode(InstantAvailabilityData.self) {
|
if let data = try? container.decode(InstantAvailabilityData.self) {
|
||||||
|
|
@ -81,23 +67,16 @@ public extension RealDebrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal struct InstantAvailabilityData: Codable, Sendable {
|
struct InstantAvailabilityData: Codable, Sendable {
|
||||||
var rd: [[String: InstantAvailabilityInfo]]
|
var rd: [[String: InstantAvailabilityInfo]]
|
||||||
}
|
}
|
||||||
|
|
||||||
internal struct InstantAvailabilityInfo: Codable, Sendable {
|
struct InstantAvailabilityInfo: Codable, Sendable {
|
||||||
var filename: String
|
var filename: String
|
||||||
var filesize: Int
|
var filesize: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Instant Availability client side structures
|
// MARK: - Instant Availability batch structures (used for client-side conversion)
|
||||||
|
|
||||||
struct IA: Codable, Hashable, Sendable {
|
|
||||||
let magnet: Magnet
|
|
||||||
let expiryTimeStamp: Double
|
|
||||||
var files: [IAFile] = []
|
|
||||||
var batches: [IABatch] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IABatch: Codable, Hashable, Sendable {
|
struct IABatch: Codable, Hashable, Sendable {
|
||||||
let files: [IABatchFile]
|
let files: [IABatchFile]
|
||||||
|
|
@ -108,12 +87,6 @@ public extension RealDebrid {
|
||||||
let fileName: String
|
let fileName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IAFile: Codable, Hashable, Sendable {
|
|
||||||
let name: String
|
|
||||||
let batchIndex: Int
|
|
||||||
let batchFileIndex: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - addMagnet endpoint
|
// MARK: - addMagnet endpoint
|
||||||
|
|
||||||
struct AddMagnetResponse: Codable, Sendable {
|
struct AddMagnetResponse: Codable, Sendable {
|
||||||
|
|
@ -123,7 +96,7 @@ public extension RealDebrid {
|
||||||
|
|
||||||
// MARK: - torrentInfo endpoint
|
// MARK: - torrentInfo endpoint
|
||||||
|
|
||||||
internal struct TorrentInfoResponse: Codable, Sendable {
|
struct TorrentInfoResponse: Codable, Sendable {
|
||||||
let id, filename, originalFilename, hash: String
|
let id, filename, originalFilename, hash: String
|
||||||
let bytes, originalBytes: Int
|
let bytes, originalBytes: Int
|
||||||
let host: String
|
let host: String
|
||||||
|
|
@ -144,7 +117,7 @@ public extension RealDebrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal struct TorrentInfoFile: Codable, Sendable {
|
struct TorrentInfoFile: Codable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let path: String
|
let path: String
|
||||||
let bytes, selected: Int
|
let bytes, selected: Int
|
||||||
|
|
@ -163,7 +136,7 @@ public extension RealDebrid {
|
||||||
|
|
||||||
// MARK: - unrestrictLink endpoint
|
// MARK: - unrestrictLink endpoint
|
||||||
|
|
||||||
internal struct UnrestrictLinkResponse: Codable, Sendable {
|
struct UnrestrictLinkResponse: Codable, Sendable {
|
||||||
let id, filename: String
|
let id, filename: String
|
||||||
let mimeType: String?
|
let mimeType: String?
|
||||||
let filesize: Int
|
let filesize: Int
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,47 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// A raw search result structure displayed on the UI
|
// A raw search result structure displayed on the UI
|
||||||
public struct SearchResult: Codable, Hashable, Sendable {
|
struct SearchResult: Codable, Hashable, Sendable {
|
||||||
let title: String?
|
let title: String?
|
||||||
let source: String
|
let source: String
|
||||||
let size: String?
|
let size: String?
|
||||||
let magnet: Magnet
|
let magnet: Magnet
|
||||||
let seeders: String?
|
let seeders: String?
|
||||||
let leechers: String?
|
let leechers: String?
|
||||||
|
|
||||||
|
// Converts size to a double
|
||||||
|
func rawSize() -> Double? {
|
||||||
|
guard let size else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let splitSize = size.split(separator: " ")
|
||||||
|
|
||||||
|
guard
|
||||||
|
let bytesString = splitSize.first,
|
||||||
|
let multipliedBytes = Double(bytesString),
|
||||||
|
let units = splitSize.last
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch units.lowercased() {
|
||||||
|
case "gb":
|
||||||
|
return multipliedBytes * 1e9
|
||||||
|
case "gib":
|
||||||
|
return multipliedBytes * pow(1024, 3)
|
||||||
|
case "mb":
|
||||||
|
return multipliedBytes * 1e6
|
||||||
|
case "mib":
|
||||||
|
return multipliedBytes * pow(1024, 2)
|
||||||
|
case "kb":
|
||||||
|
return multipliedBytes * 1e3
|
||||||
|
case "kib":
|
||||||
|
return multipliedBytes * 1024
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ScrapingViewModel {
|
extension ScrapingViewModel {
|
||||||
|
|
|
||||||
|
|
@ -7,49 +7,51 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
enum ApiCredentialResponseType: String, Codable, Hashable, Sendable {
|
||||||
case json
|
case json
|
||||||
case text
|
case text
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
||||||
public let name: String
|
let name: String
|
||||||
public let version: Int16
|
let version: Int16
|
||||||
let minVersion: String?
|
let minVersion: String?
|
||||||
let baseUrl: String?
|
let about: String?
|
||||||
|
let website: String?
|
||||||
|
let dynamicWebsite: Bool?
|
||||||
let fallbackUrls: [String]?
|
let fallbackUrls: [String]?
|
||||||
let dynamicBaseUrl: Bool?
|
|
||||||
let trackers: [String]?
|
let trackers: [String]?
|
||||||
let api: SourceApiJson?
|
let api: SourceApiJson?
|
||||||
let jsonParser: SourceJsonParserJson?
|
let jsonParser: SourceJsonParserJson?
|
||||||
let rssParser: SourceRssParserJson?
|
let rssParser: SourceRssParserJson?
|
||||||
let htmlParser: SourceHtmlParserJson?
|
let htmlParser: SourceHtmlParserJson?
|
||||||
public let author: String?
|
let author: String?
|
||||||
public let listId: UUID?
|
let listId: UUID?
|
||||||
public let tags: [PluginTagJson]?
|
let listName: String?
|
||||||
|
let tags: [PluginTagJson]?
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension SourceJson {
|
extension SourceJson {
|
||||||
// Fetches all tags without optional requirement
|
// Fetches all tags without optional requirement
|
||||||
func getTags() -> [PluginTagJson] {
|
func getTags() -> [PluginTagJson] {
|
||||||
tags ?? []
|
tags ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
enum SourcePreferredParser: Int16, CaseIterable, Sendable {
|
||||||
// case none = 0
|
// case none = 0
|
||||||
case scraping = 1
|
case scraping = 1
|
||||||
case rss = 2
|
case rss = 2
|
||||||
case siteApi = 3
|
case siteApi = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceApiJson: Codable, Hashable, Sendable {
|
struct SourceApiJson: Codable, Hashable, Sendable {
|
||||||
let apiUrl: String?
|
let apiUrl: String?
|
||||||
let clientId: SourceApiCredentialJson?
|
let clientId: SourceApiCredentialJson?
|
||||||
let clientSecret: SourceApiCredentialJson?
|
let clientSecret: SourceApiCredentialJson?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||||
let query: String?
|
let query: String?
|
||||||
let value: String?
|
let value: String?
|
||||||
let dynamic: Bool?
|
let dynamic: Bool?
|
||||||
|
|
@ -58,55 +60,58 @@ public struct SourceApiCredentialJson: Codable, Hashable, Sendable {
|
||||||
let expiryLength: Double?
|
let expiryLength: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||||
let searchUrl: String
|
let searchUrl: String
|
||||||
|
let request: SourceRequestJson?
|
||||||
let results: String?
|
let results: String?
|
||||||
let subResults: String?
|
let subResults: String?
|
||||||
|
let title: SourceComplexQueryJson
|
||||||
let magnetHash: SourceComplexQueryJson?
|
let magnetHash: SourceComplexQueryJson?
|
||||||
let magnetLink: SourceComplexQueryJson?
|
let magnetLink: SourceComplexQueryJson?
|
||||||
let subName: SourceComplexQueryJson?
|
let subName: SourceComplexQueryJson?
|
||||||
let title: SourceComplexQueryJson?
|
|
||||||
let size: SourceComplexQueryJson?
|
let size: SourceComplexQueryJson?
|
||||||
let sl: SourceSLJson?
|
let sl: SourceSLJson?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceRssParserJson: Codable, Hashable, Sendable {
|
struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||||
let rssUrl: String?
|
let rssUrl: String?
|
||||||
let searchUrl: String
|
let searchUrl: String
|
||||||
|
let request: SourceRequestJson?
|
||||||
let items: String
|
let items: String
|
||||||
|
let title: SourceComplexQueryJson
|
||||||
let magnetHash: SourceComplexQueryJson?
|
let magnetHash: SourceComplexQueryJson?
|
||||||
let magnetLink: SourceComplexQueryJson?
|
let magnetLink: SourceComplexQueryJson?
|
||||||
let subName: SourceComplexQueryJson?
|
let subName: SourceComplexQueryJson?
|
||||||
let title: SourceComplexQueryJson?
|
|
||||||
let size: SourceComplexQueryJson?
|
let size: SourceComplexQueryJson?
|
||||||
let sl: SourceSLJson?
|
let sl: SourceSLJson?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||||
let searchUrl: String
|
let searchUrl: String?
|
||||||
|
let request: SourceRequestJson?
|
||||||
let rows: String
|
let rows: String
|
||||||
|
let title: SourceComplexQueryJson
|
||||||
let magnet: SourceMagnetJson
|
let magnet: SourceMagnetJson
|
||||||
let subName: SourceComplexQueryJson?
|
let subName: SourceComplexQueryJson?
|
||||||
let title: SourceComplexQueryJson?
|
|
||||||
let size: SourceComplexQueryJson?
|
let size: SourceComplexQueryJson?
|
||||||
let sl: SourceSLJson?
|
let sl: SourceSLJson?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceComplexQueryJson: Codable, Hashable, Sendable {
|
struct SourceComplexQueryJson: Codable, Hashable, Sendable {
|
||||||
let query: String
|
let query: String
|
||||||
let discriminator: String?
|
let discriminator: String?
|
||||||
let attribute: String?
|
let attribute: String?
|
||||||
let regex: String?
|
let regex: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceMagnetJson: Codable, Hashable, Sendable {
|
struct SourceMagnetJson: Codable, Hashable, Sendable {
|
||||||
let query: String
|
let query: String
|
||||||
let attribute: String
|
let attribute: String
|
||||||
let regex: String?
|
let regex: String?
|
||||||
let externalLinkQuery: String?
|
let externalLinkQuery: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SourceSLJson: Codable, Hashable, Sendable {
|
struct SourceSLJson: Codable, Hashable, Sendable {
|
||||||
let seeders: String?
|
let seeders: String?
|
||||||
let leechers: String?
|
let leechers: String?
|
||||||
let combined: String?
|
let combined: String?
|
||||||
|
|
@ -115,3 +120,9 @@ public struct SourceSLJson: Codable, Hashable, Sendable {
|
||||||
let seederRegex: String?
|
let seederRegex: String?
|
||||||
let leecherRegex: String?
|
let leecherRegex: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SourceRequestJson: Codable, Hashable, Sendable {
|
||||||
|
let method: String?
|
||||||
|
let headers: [String: String]?
|
||||||
|
let body: String?
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,14 @@
|
||||||
import CoreData
|
import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public protocol Plugin: ObservableObject, NSManagedObject {
|
protocol Plugin: ObservableObject, NSManagedObject {
|
||||||
var id: UUID { get set }
|
var id: UUID { get set }
|
||||||
var listId: UUID? { get set }
|
var listId: UUID? { get set }
|
||||||
var name: String { get set }
|
var name: String { get set }
|
||||||
var version: Int16 { get set }
|
var version: Int16 { get set }
|
||||||
var author: String { get set }
|
var author: String { get set }
|
||||||
|
var about: String? { get set }
|
||||||
|
var website: String? { get set }
|
||||||
var enabled: Bool { get set }
|
var enabled: Bool { get set }
|
||||||
var tags: NSOrderedSet? { get set }
|
var tags: NSOrderedSet? { get set }
|
||||||
func getTags() -> [PluginTagJson]
|
func getTags() -> [PluginTagJson]
|
||||||
|
|
@ -25,11 +27,12 @@ extension Plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol PluginJson: Hashable {
|
protocol PluginJson: Hashable {
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
var version: Int16 { get }
|
var version: Int16 { get }
|
||||||
var author: String? { get }
|
var author: String? { get }
|
||||||
var listId: UUID? { get }
|
var listId: UUID? { get }
|
||||||
|
var listName: String? { get }
|
||||||
var tags: [PluginTagJson]? { get }
|
var tags: [PluginTagJson]? { get }
|
||||||
func getTags() -> [PluginTagJson]
|
func getTags() -> [PluginTagJson]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Application {
|
class Application {
|
||||||
static let shared = Application()
|
static let shared = Application()
|
||||||
|
|
||||||
// OS name for Plugins to read. Lowercase for ease of use
|
// OS name for Plugins to read. Lowercase for ease of use
|
||||||
|
|
|
||||||
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,9 +7,9 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class BackupManager: ObservableObject {
|
class BackupManager: ObservableObject {
|
||||||
// Constant variable for backup versions
|
// Constant variable for backup versions
|
||||||
let latestBackupVersion: Int = 2
|
private let latestBackupVersion: Int = 2
|
||||||
|
|
||||||
var logManager: LoggingManager?
|
var logManager: LoggingManager?
|
||||||
|
|
||||||
|
|
@ -21,17 +21,17 @@ public class BackupManager: ObservableObject {
|
||||||
@Published var selectedBackupUrl: URL?
|
@Published var selectedBackupUrl: URL?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateRestoreCompletedMessage(newString: String) {
|
private func updateRestoreCompletedMessage(newString: String) {
|
||||||
restoreCompletedMessage.append(newString)
|
restoreCompletedMessage.append(newString)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func toggleRestoreCompletedAlert() {
|
private func toggleRestoreCompletedAlert() {
|
||||||
showRestoreCompletedAlert.toggle()
|
showRestoreCompletedAlert.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateBackupUrls(newUrl: URL) {
|
private func updateBackupUrls(newUrl: URL) {
|
||||||
backupUrls.append(newUrl)
|
backupUrls.append(newUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,14 +186,7 @@ public class BackupManager: ObservableObject {
|
||||||
|
|
||||||
PersistenceController.shared.save(backgroundContext)
|
PersistenceController.shared.save(backgroundContext)
|
||||||
|
|
||||||
// if iOS 14 is available, sleep to prevent any issues with alerts
|
await toggleRestoreCompletedAlert()
|
||||||
if #available(iOS 15, *) {
|
|
||||||
await toggleRestoreCompletedAlert()
|
|
||||||
} else {
|
|
||||||
try? await Task.sleep(seconds: 0.1)
|
|
||||||
|
|
||||||
await toggleRestoreCompletedAlert()
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
await logManager?.error(
|
await logManager?.error(
|
||||||
"Backup restore: \(error)",
|
"Backup restore: \(error)",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// ToastViewModel.swift
|
// LoggingManager.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/19/22.
|
// Created by Brian Dashore on 7/19/22.
|
||||||
|
|
@ -70,8 +70,8 @@ class LoggingManager: ObservableObject {
|
||||||
|
|
||||||
// TODO: Maybe append to a constant logfile?
|
// TODO: Maybe append to a constant logfile?
|
||||||
|
|
||||||
public func info(_ message: String,
|
func info(_ message: String,
|
||||||
description: String? = nil)
|
description: String? = nil)
|
||||||
{
|
{
|
||||||
let log = Log(
|
let log = Log(
|
||||||
level: .info,
|
level: .info,
|
||||||
|
|
@ -88,8 +88,8 @@ class LoggingManager: ObservableObject {
|
||||||
print("LOG: \(log.toMessage())")
|
print("LOG: \(log.toMessage())")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func warn(_ message: String,
|
func warn(_ message: String,
|
||||||
description: String? = nil)
|
description: String? = nil)
|
||||||
{
|
{
|
||||||
let log = Log(
|
let log = Log(
|
||||||
level: .warn,
|
level: .warn,
|
||||||
|
|
@ -106,9 +106,9 @@ class LoggingManager: ObservableObject {
|
||||||
print("LOG: \(log.toMessage())")
|
print("LOG: \(log.toMessage())")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func error(_ message: String,
|
func error(_ message: String,
|
||||||
description: String? = nil,
|
description: String? = nil,
|
||||||
showToast: Bool = true)
|
showToast: Bool = true)
|
||||||
{
|
{
|
||||||
let log = Log(
|
let log = Log(
|
||||||
level: .error,
|
level: .error,
|
||||||
|
|
@ -121,7 +121,7 @@ class LoggingManager: ObservableObject {
|
||||||
if let description {
|
if let description {
|
||||||
toastDescription = description
|
toastDescription = description
|
||||||
} else if showErrorToasts {
|
} else if showErrorToasts {
|
||||||
toastDescription = "An error was logged"
|
toastDescription = "An error was logged. Please look at logs in Settings."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +132,7 @@ class LoggingManager: ObservableObject {
|
||||||
|
|
||||||
// MARK: - Indeterminate functions
|
// MARK: - Indeterminate functions
|
||||||
|
|
||||||
public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) {
|
||||||
indeterminateToastDescription = description
|
indeterminateToastDescription = description
|
||||||
|
|
||||||
if let cancelAction {
|
if let cancelAction {
|
||||||
|
|
@ -144,13 +144,13 @@ class LoggingManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hideIndeterminateToast() {
|
func hideIndeterminateToast() {
|
||||||
showIndeterminateToast = false
|
showIndeterminateToast = false
|
||||||
indeterminateToastDescription = ""
|
indeterminateToastDescription = ""
|
||||||
indeterminateCancelAction = nil
|
indeterminateCancelAction = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func exportLogs() {
|
func exportLogs() {
|
||||||
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
logFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||||
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
|
let logFileName = "ferrite_session_\(logFormatter.string(from: Date())).txt"
|
||||||
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")
|
let logFolderPath = FileManager.default.appDirectory.appendingPathComponent("Logs")
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class NavigationViewModel: ObservableObject {
|
class NavigationViewModel: ObservableObject {
|
||||||
var logManager: LoggingManager?
|
var logManager: LoggingManager?
|
||||||
|
|
||||||
// Used between SearchResultsView and MagnetChoiceView
|
// Used between SearchResultsView and MagnetChoiceView
|
||||||
public enum ChoiceSheetType: Identifiable {
|
enum ChoiceSheetType: Identifiable {
|
||||||
public var id: Int {
|
var id: Int {
|
||||||
hashValue
|
hashValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +48,36 @@ public class NavigationViewModel: ObservableObject {
|
||||||
@Published var selectedTitle: String = ""
|
@Published var selectedTitle: String = ""
|
||||||
@Published var selectedBatchTitle: String = ""
|
@Published var selectedBatchTitle: String = ""
|
||||||
|
|
||||||
|
// For filters
|
||||||
|
@Published var enabledFilters: Set<FilterType> = []
|
||||||
|
@Published var currentSortFilter: SortFilter?
|
||||||
|
@Published var currentSortOrder: SortOrder = .forward
|
||||||
|
|
||||||
|
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||||
|
switch currentSortFilter {
|
||||||
|
case .leechers:
|
||||||
|
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSortOrder == .forward ? lhsLeechers > rhsLeechers : lhsLeechers < rhsLeechers
|
||||||
|
case .seeders:
|
||||||
|
guard let lhsSeeders = lhs.seeders, let rhsSeeders = rhs.seeders else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSortOrder == .forward ? lhsSeeders > rhsSeeders : lhsSeeders < rhsSeeders
|
||||||
|
case .size:
|
||||||
|
guard let lhsSize = lhs.rawSize(), let rhsSize = rhs.rawSize() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSortOrder == .forward ? lhsSize > rhsSize : lhsSize < rhsSize
|
||||||
|
case .none:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Published var kodiExpanded: Bool = false
|
@Published var kodiExpanded: Bool = false
|
||||||
|
|
||||||
@Published var currentChoiceSheet: ChoiceSheetType?
|
@Published var currentChoiceSheet: ChoiceSheetType?
|
||||||
|
|
@ -58,15 +88,37 @@ public class NavigationViewModel: ObservableObject {
|
||||||
|
|
||||||
@Published var selectedTab: ViewTab = .search
|
@Published var selectedTab: ViewTab = .search
|
||||||
|
|
||||||
// TODO: Maybe move these to their own StateObjects?
|
|
||||||
// Used between SourceListView and SourceSettingsView
|
|
||||||
@Published var showSourceSettings: Bool = false
|
|
||||||
var selectedSource: Source?
|
|
||||||
|
|
||||||
// Used between service views and editor views in Settings
|
// Used between service views and editor views in Settings
|
||||||
@Published var selectedPluginList: PluginList?
|
@Published var selectedPluginList: PluginList?
|
||||||
@Published var selectedKodiServer: KodiServer?
|
@Published var selectedKodiServer: KodiServer?
|
||||||
|
|
||||||
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
|
@Published var libraryPickerSelection: LibraryPickerSegment = .bookmarks
|
||||||
@Published var pluginPickerSelection: PluginPickerSegment = .sources
|
@Published var pluginPickerSelection: PluginPickerSegment = .sources
|
||||||
|
|
||||||
|
@Published var searchPrompt: String = "Search"
|
||||||
|
@Published var lastSearchPromptIndex: Int = -1
|
||||||
|
private let searchBarTextArray: [String] = [
|
||||||
|
"What's on your mind?",
|
||||||
|
"Discover something interesting",
|
||||||
|
"Find an engaging show",
|
||||||
|
"Feeling adventurous?",
|
||||||
|
"Look for something new",
|
||||||
|
"The classics are a good idea"
|
||||||
|
]
|
||||||
|
|
||||||
|
func getSearchPrompt() {
|
||||||
|
if UserDefaults.standard.bool(forKey: "Behavior.UsesRandomSearchText") {
|
||||||
|
let num = Int.random(in: 0 ..< searchBarTextArray.count - 1)
|
||||||
|
if num == lastSearchPromptIndex {
|
||||||
|
lastSearchPromptIndex = num + 1
|
||||||
|
searchPrompt = searchBarTextArray[safe: num + 1] ?? "Search"
|
||||||
|
} else {
|
||||||
|
lastSearchPromptIndex = num
|
||||||
|
searchPrompt = searchBarTextArray[safe: num] ?? "Search"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastSearchPromptIndex = -1
|
||||||
|
searchPrompt = "Search"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// SourceManager.swift
|
// PluginManager.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/25/22.
|
// Created by Brian Dashore on 7/25/22.
|
||||||
|
|
@ -7,14 +7,17 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Yams
|
||||||
|
|
||||||
public class PluginManager: ObservableObject {
|
class PluginManager: ObservableObject {
|
||||||
var logManager: LoggingManager?
|
var logManager: LoggingManager?
|
||||||
let kodi: Kodi = .init()
|
let kodi: Kodi = .init()
|
||||||
|
|
||||||
@Published var availableSources: [SourceJson] = []
|
@Published var availableSources: [SourceJson] = []
|
||||||
@Published var availableActions: [ActionJson] = []
|
@Published var availableActions: [ActionJson] = []
|
||||||
|
|
||||||
|
@Published var filteredInstalledSources: Set<Source> = []
|
||||||
|
|
||||||
@Published var showActionErrorAlert = false
|
@Published var showActionErrorAlert = false
|
||||||
@Published var actionErrorAlertMessage: String = ""
|
@Published var actionErrorAlertMessage: String = ""
|
||||||
|
|
||||||
|
|
@ -22,18 +25,18 @@ public class PluginManager: ObservableObject {
|
||||||
@Published var actionSuccessAlertMessage: String = ""
|
@Published var actionSuccessAlertMessage: String = ""
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cleanAvailablePlugins() {
|
private func cleanAvailablePlugins() {
|
||||||
availableSources = []
|
availableSources = []
|
||||||
availableActions = []
|
availableActions = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
|
private func updateAvailablePlugins(_ newPlugins: AvailablePlugins) {
|
||||||
availableSources += newPlugins.availableSources
|
availableSources += newPlugins.availableSources
|
||||||
availableActions += newPlugins.availableActions
|
availableActions += newPlugins.availableActions
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchPluginsFromUrl() async {
|
func fetchPluginsFromUrl() async {
|
||||||
let pluginListRequest = PluginList.fetchRequest()
|
let pluginListRequest = PluginList.fetchRequest()
|
||||||
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
|
guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else {
|
||||||
await logManager?.error("PluginManager: No plugin lists found")
|
await logManager?.error("PluginManager: No plugin lists found")
|
||||||
|
|
@ -94,7 +97,7 @@ public class PluginManager: ObservableObject {
|
||||||
await logManager?.info("Plugin list fetch finished")
|
await logManager?.info("Plugin list fetch finished")
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
|
private func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? {
|
||||||
var tempSources: [SourceJson] = []
|
var tempSources: [SourceJson] = []
|
||||||
var tempActions: [ActionJson] = []
|
var tempActions: [ActionJson] = []
|
||||||
|
|
||||||
|
|
@ -102,7 +105,18 @@ public class PluginManager: ObservableObject {
|
||||||
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
let pluginResponse: PluginListJson?
|
||||||
|
|
||||||
|
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
|
||||||
|
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
|
||||||
|
pluginResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
} else {
|
||||||
|
pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let pluginResponse else {
|
||||||
|
throw PluginManagerError.PluginFetch(description: "Could not decode plugin list data")
|
||||||
|
}
|
||||||
|
|
||||||
if let sources = pluginResponse.sources {
|
if let sources = pluginResponse.sources {
|
||||||
// Faster and more performant to map instead of a for loop
|
// Faster and more performant to map instead of a for loop
|
||||||
|
|
@ -112,9 +126,10 @@ public class PluginManager: ObservableObject {
|
||||||
name: inputJson.name,
|
name: inputJson.name,
|
||||||
version: inputJson.version,
|
version: inputJson.version,
|
||||||
minVersion: inputJson.minVersion,
|
minVersion: inputJson.minVersion,
|
||||||
baseUrl: inputJson.baseUrl,
|
about: inputJson.about,
|
||||||
|
website: inputJson.website,
|
||||||
|
dynamicWebsite: inputJson.dynamicWebsite,
|
||||||
fallbackUrls: inputJson.fallbackUrls,
|
fallbackUrls: inputJson.fallbackUrls,
|
||||||
dynamicBaseUrl: inputJson.dynamicBaseUrl,
|
|
||||||
trackers: inputJson.trackers,
|
trackers: inputJson.trackers,
|
||||||
api: inputJson.api,
|
api: inputJson.api,
|
||||||
jsonParser: inputJson.jsonParser,
|
jsonParser: inputJson.jsonParser,
|
||||||
|
|
@ -122,6 +137,7 @@ public class PluginManager: ObservableObject {
|
||||||
htmlParser: inputJson.htmlParser,
|
htmlParser: inputJson.htmlParser,
|
||||||
author: pluginList.author,
|
author: pluginList.author,
|
||||||
listId: pluginList.id,
|
listId: pluginList.id,
|
||||||
|
listName: pluginList.name,
|
||||||
tags: inputJson.tags
|
tags: inputJson.tags
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -141,10 +157,13 @@ public class PluginManager: ObservableObject {
|
||||||
name: inputJson.name,
|
name: inputJson.name,
|
||||||
version: inputJson.version,
|
version: inputJson.version,
|
||||||
minVersion: inputJson.minVersion,
|
minVersion: inputJson.minVersion,
|
||||||
|
about: inputJson.about,
|
||||||
|
website: inputJson.website,
|
||||||
requires: inputJson.requires,
|
requires: inputJson.requires,
|
||||||
deeplink: filteredDeeplinks,
|
deeplink: filteredDeeplinks,
|
||||||
author: pluginList.author,
|
author: pluginList.author,
|
||||||
listId: pluginList.id,
|
listId: pluginList.id,
|
||||||
|
listName: pluginList.name,
|
||||||
tags: inputJson.tags
|
tags: inputJson.tags
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -157,7 +176,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
|
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
|
||||||
func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
|
private func getFilteredDeeplinks(_ deeplinks: [DeeplinkActionJson]) -> [DeeplinkActionJson]? {
|
||||||
let osArray = deeplinks.filter { deeplink in
|
let osArray = deeplinks.filter { deeplink in
|
||||||
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
|
deeplink.os.contains(where: { $0.lowercased() == Application.shared.os.lowercased() })
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +244,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
||||||
switch String(describing: PJ.self) {
|
switch String(describing: PJ.self) {
|
||||||
case "SourceJson":
|
case "SourceJson":
|
||||||
return availableSources as? [PJ] ?? []
|
return availableSources as? [PJ] ?? []
|
||||||
|
|
@ -237,7 +256,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the current app version is supported by the source
|
// Checks if the current app version is supported by the source
|
||||||
func checkAppVersion(minVersion: String?) -> Bool {
|
private func checkAppVersion(minVersion: String?) -> Bool {
|
||||||
// If there's no min version, assume that every version is supported
|
// If there's no min version, assume that every version is supported
|
||||||
guard let minVersion else {
|
guard let minVersion else {
|
||||||
return true
|
return true
|
||||||
|
|
@ -247,10 +266,12 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches sources using the background context
|
// Fetches sources using the background context
|
||||||
public func fetchInstalledSources() -> [Source] {
|
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
|
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
|
||||||
|
return Array(filteredInstalledSources)
|
||||||
|
} else if let sources = try? backgroundContext.fetch(Source.fetchRequest()) {
|
||||||
return sources.compactMap { $0 }
|
return sources.compactMap { $0 }
|
||||||
} else {
|
} else {
|
||||||
return []
|
return []
|
||||||
|
|
@ -258,7 +279,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
|
func runDefaultAction(urlString: String?, navModel: NavigationViewModel) {
|
||||||
let context = PersistenceController.shared.backgroundContext
|
let context = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
guard let urlString else {
|
guard let urlString else {
|
||||||
|
|
@ -311,7 +332,7 @@ public class PluginManager: ObservableObject {
|
||||||
|
|
||||||
// The iOS version of Ferrite only runs deeplink actions
|
// The iOS version of Ferrite only runs deeplink actions
|
||||||
@MainActor
|
@MainActor
|
||||||
public func runDeeplinkAction(_ action: Action, urlString: String?) {
|
func runDeeplinkAction(_ action: Action, urlString: String?) {
|
||||||
guard let deeplink = action.deeplink, let urlString else {
|
guard let deeplink = action.deeplink, let urlString else {
|
||||||
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
|
actionErrorAlertMessage = "Could not run action: \(action.name) since there is no deeplink to execute. Contact the action dev!"
|
||||||
showActionErrorAlert.toggle()
|
showActionErrorAlert.toggle()
|
||||||
|
|
@ -334,7 +355,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public func sendToKodi(urlString: String?, server: KodiServer) async {
|
func sendToKodi(urlString: String?, server: KodiServer) async {
|
||||||
guard let urlString else {
|
guard let urlString else {
|
||||||
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
|
actionErrorAlertMessage = "Could not send URL to Kodi since there is no playback URL to send"
|
||||||
showActionErrorAlert.toggle()
|
showActionErrorAlert.toggle()
|
||||||
|
|
@ -359,7 +380,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
||||||
guard let actionJson else {
|
guard let actionJson else {
|
||||||
await logManager?.error("Action addition: No action present. Contact the app dev!")
|
await logManager?.error("Action addition: No action present. Contact the app dev!")
|
||||||
return
|
return
|
||||||
|
|
@ -395,6 +416,8 @@ public class PluginManager: ObservableObject {
|
||||||
newAction.id = UUID()
|
newAction.id = UUID()
|
||||||
newAction.name = actionJson.name
|
newAction.name = actionJson.name
|
||||||
newAction.version = actionJson.version
|
newAction.version = actionJson.version
|
||||||
|
newAction.website = actionJson.website
|
||||||
|
newAction.about = actionJson.about
|
||||||
newAction.author = actionJson.author ?? "Unknown"
|
newAction.author = actionJson.author ?? "Unknown"
|
||||||
newAction.listId = actionJson.listId
|
newAction.listId = actionJson.listId
|
||||||
newAction.requires = actionJson.requires.map(\.rawValue)
|
newAction.requires = actionJson.requires.map(\.rawValue)
|
||||||
|
|
@ -425,7 +448,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
||||||
guard let sourceJson else {
|
guard let sourceJson else {
|
||||||
await logManager?.error("Source addition: No source present. Contact the app dev!")
|
await logManager?.error("Source addition: No source present. Contact the app dev!")
|
||||||
return
|
return
|
||||||
|
|
@ -434,9 +457,9 @@ public class PluginManager: ObservableObject {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
// If there's no base URL and it isn't dynamic, return before any transactions occur
|
// If there's no base URL and it isn't dynamic, return before any transactions occur
|
||||||
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
|
let dynamicWebsite = sourceJson.dynamicWebsite ?? false
|
||||||
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
|
if !dynamicWebsite, sourceJson.website == nil {
|
||||||
await logManager?.error("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
|
await logManager?.error("Not adding this source because website parameters are malformed. Please contact the source dev.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,9 +481,10 @@ public class PluginManager: ObservableObject {
|
||||||
newSource.id = UUID()
|
newSource.id = UUID()
|
||||||
newSource.name = sourceJson.name
|
newSource.name = sourceJson.name
|
||||||
newSource.version = sourceJson.version
|
newSource.version = sourceJson.version
|
||||||
newSource.dynamicBaseUrl = dynamicBaseUrl
|
newSource.about = sourceJson.about
|
||||||
newSource.baseUrl = sourceJson.baseUrl
|
newSource.website = sourceJson.website
|
||||||
newSource.fallbackUrls = dynamicBaseUrl ? nil : sourceJson.fallbackUrls
|
newSource.dynamicWebsite = dynamicWebsite
|
||||||
|
newSource.fallbackUrls = dynamicWebsite ? nil : sourceJson.fallbackUrls
|
||||||
newSource.author = sourceJson.author ?? "Unknown"
|
newSource.author = sourceJson.author ?? "Unknown"
|
||||||
newSource.listId = sourceJson.listId
|
newSource.listId = sourceJson.listId
|
||||||
newSource.trackers = sourceJson.trackers
|
newSource.trackers = sourceJson.trackers
|
||||||
|
|
@ -511,7 +535,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let newSourceApi = SourceApi(context: backgroundContext)
|
let newSourceApi = SourceApi(context: backgroundContext)
|
||||||
|
|
@ -546,7 +570,8 @@ public class PluginManager: ObservableObject {
|
||||||
newSource.api = newSourceApi
|
newSource.api = newSourceApi
|
||||||
}
|
}
|
||||||
|
|
||||||
func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
|
// TODO: Migrate parser addition to a common protocol
|
||||||
|
private func addJsonParser(newSource: Source, jsonParserJson: SourceJsonParserJson) {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
|
let newSourceJsonParser = SourceJsonParser(context: backgroundContext)
|
||||||
|
|
@ -554,6 +579,13 @@ public class PluginManager: ObservableObject {
|
||||||
newSourceJsonParser.results = jsonParserJson.results
|
newSourceJsonParser.results = jsonParserJson.results
|
||||||
newSourceJsonParser.subResults = jsonParserJson.subResults
|
newSourceJsonParser.subResults = jsonParserJson.subResults
|
||||||
|
|
||||||
|
if let requestJson = newSourceJsonParser.request {
|
||||||
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
|
newParserRequest.method = requestJson.method
|
||||||
|
newParserRequest.headers = requestJson.headers
|
||||||
|
newParserRequest.body = requestJson.body
|
||||||
|
}
|
||||||
|
|
||||||
// Tune these complex queries to the final JSON parser format
|
// Tune these complex queries to the final JSON parser format
|
||||||
if let magnetLinkJson = jsonParserJson.magnetLink {
|
if let magnetLinkJson = jsonParserJson.magnetLink {
|
||||||
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||||
|
|
@ -582,14 +614,12 @@ public class PluginManager: ObservableObject {
|
||||||
newSourceJsonParser.subName = newSourceSubName
|
newSourceJsonParser.subName = newSourceSubName
|
||||||
}
|
}
|
||||||
|
|
||||||
if let titleJson = jsonParserJson.title {
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
newSourceTitle.query = jsonParserJson.title.query
|
||||||
newSourceTitle.query = titleJson.query
|
newSourceTitle.attribute = jsonParserJson.title.attribute ?? "text"
|
||||||
newSourceTitle.attribute = titleJson.attribute ?? "text"
|
newSourceTitle.discriminator = jsonParserJson.title.discriminator
|
||||||
newSourceTitle.discriminator = titleJson.discriminator
|
|
||||||
|
|
||||||
newSourceJsonParser.title = newSourceTitle
|
newSourceJsonParser.title = newSourceTitle
|
||||||
}
|
|
||||||
|
|
||||||
if let sizeJson = jsonParserJson.size {
|
if let sizeJson = jsonParserJson.size {
|
||||||
let newSourceSize = SourceSize(context: backgroundContext)
|
let newSourceSize = SourceSize(context: backgroundContext)
|
||||||
|
|
@ -616,7 +646,7 @@ public class PluginManager: ObservableObject {
|
||||||
newSource.jsonParser = newSourceJsonParser
|
newSource.jsonParser = newSourceJsonParser
|
||||||
}
|
}
|
||||||
|
|
||||||
func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
|
private func addRssParser(newSource: Source, rssParserJson: SourceRssParserJson) {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let newSourceRssParser = SourceRssParser(context: backgroundContext)
|
let newSourceRssParser = SourceRssParser(context: backgroundContext)
|
||||||
|
|
@ -624,6 +654,13 @@ public class PluginManager: ObservableObject {
|
||||||
newSourceRssParser.searchUrl = rssParserJson.searchUrl
|
newSourceRssParser.searchUrl = rssParserJson.searchUrl
|
||||||
newSourceRssParser.items = rssParserJson.items
|
newSourceRssParser.items = rssParserJson.items
|
||||||
|
|
||||||
|
if let requestJson = newSourceRssParser.request {
|
||||||
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
|
newParserRequest.method = requestJson.method
|
||||||
|
newParserRequest.headers = requestJson.headers
|
||||||
|
newParserRequest.body = requestJson.body
|
||||||
|
}
|
||||||
|
|
||||||
if let magnetLinkJson = rssParserJson.magnetLink {
|
if let magnetLinkJson = rssParserJson.magnetLink {
|
||||||
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
let newSourceMagnetLink = SourceMagnetLink(context: backgroundContext)
|
||||||
newSourceMagnetLink.query = magnetLinkJson.query
|
newSourceMagnetLink.query = magnetLinkJson.query
|
||||||
|
|
@ -654,15 +691,13 @@ public class PluginManager: ObservableObject {
|
||||||
newSourceRssParser.subName = newSourceSubName
|
newSourceRssParser.subName = newSourceSubName
|
||||||
}
|
}
|
||||||
|
|
||||||
if let titleJson = rssParserJson.title {
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
newSourceTitle.query = rssParserJson.title.query
|
||||||
newSourceTitle.query = titleJson.query
|
newSourceTitle.attribute = rssParserJson.title.attribute ?? "text"
|
||||||
newSourceTitle.attribute = titleJson.attribute ?? "text"
|
newSourceTitle.discriminator = rssParserJson.title.discriminator
|
||||||
newSourceTitle.discriminator = titleJson.discriminator
|
newSourceTitle.regex = rssParserJson.title.regex
|
||||||
newSourceTitle.regex = titleJson.regex
|
|
||||||
|
|
||||||
newSourceRssParser.title = newSourceTitle
|
newSourceRssParser.title = newSourceTitle
|
||||||
}
|
|
||||||
|
|
||||||
if let sizeJson = rssParserJson.size {
|
if let sizeJson = rssParserJson.size {
|
||||||
let newSourceSize = SourceSize(context: backgroundContext)
|
let newSourceSize = SourceSize(context: backgroundContext)
|
||||||
|
|
@ -690,7 +725,7 @@ public class PluginManager: ObservableObject {
|
||||||
newSource.rssParser = newSourceRssParser
|
newSource.rssParser = newSourceRssParser
|
||||||
}
|
}
|
||||||
|
|
||||||
func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
|
private func addHtmlParser(newSource: Source, htmlParserJson: SourceHtmlParserJson) {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
|
let newSourceHtmlParser = SourceHtmlParser(context: backgroundContext)
|
||||||
|
|
@ -706,16 +741,24 @@ public class PluginManager: ObservableObject {
|
||||||
newSourceHtmlParser.subName = newSourceSubName
|
newSourceHtmlParser.subName = newSourceSubName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a title complex query if present
|
if let requestJson = htmlParserJson.request {
|
||||||
if let titleJson = htmlParserJson.title {
|
print(requestJson)
|
||||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
newSourceTitle.query = titleJson.query
|
newParserRequest.method = requestJson.method
|
||||||
newSourceTitle.attribute = titleJson.attribute ?? "text"
|
newParserRequest.headers = requestJson.headers
|
||||||
newSourceTitle.regex = titleJson.regex
|
newParserRequest.body = requestJson.body
|
||||||
|
|
||||||
newSourceHtmlParser.title = newSourceTitle
|
newSourceHtmlParser.request = newParserRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adds a title complex query
|
||||||
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
|
newSourceTitle.query = htmlParserJson.title.query
|
||||||
|
newSourceTitle.attribute = htmlParserJson.title.attribute ?? "text"
|
||||||
|
newSourceTitle.regex = htmlParserJson.title.regex
|
||||||
|
|
||||||
|
newSourceHtmlParser.title = newSourceTitle
|
||||||
|
|
||||||
// Adds a size complex query if present
|
// Adds a size complex query if present
|
||||||
if let sizeJson = htmlParserJson.size {
|
if let sizeJson = htmlParserJson.size {
|
||||||
let newSourceSize = SourceSize(context: backgroundContext)
|
let newSourceSize = SourceSize(context: backgroundContext)
|
||||||
|
|
@ -752,25 +795,41 @@ public class PluginManager: ObservableObject {
|
||||||
|
|
||||||
// Adds a plugin list
|
// Adds a plugin list
|
||||||
// Can move this to PersistenceController if needed
|
// Can move this to PersistenceController if needed
|
||||||
public func addPluginList(_ url: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
if url.isEmpty || !url.starts(with: "https://") && !url.starts(with: "http://") {
|
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
|
||||||
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
|
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: URL(string: url)!))
|
guard let url = URL(string: urlString) else {
|
||||||
let rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
throw PluginManagerError.ListAddition(description: "The provided source list is invalid. Please check if the URL is formatted properly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
|
||||||
|
|
||||||
|
let rawResponse: PluginListJson?
|
||||||
|
|
||||||
|
// If the URL is a yaml file, decode as such. Otherwise assume legacy JSON
|
||||||
|
if url.pathExtension == "yaml" || url.pathExtension == "yml" {
|
||||||
|
rawResponse = try YAMLDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
} else {
|
||||||
|
rawResponse = try JSONDecoder().decode(PluginListJson.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let rawResponse else {
|
||||||
|
throw PluginManagerError.ListAddition(description: "Could not decode the plugin list from URL \(urlString)")
|
||||||
|
}
|
||||||
|
|
||||||
if let existingPluginList {
|
if let existingPluginList {
|
||||||
existingPluginList.urlString = url
|
existingPluginList.urlString = urlString
|
||||||
existingPluginList.name = rawResponse.name
|
existingPluginList.name = rawResponse.name
|
||||||
existingPluginList.author = rawResponse.author
|
existingPluginList.author = rawResponse.author
|
||||||
|
|
||||||
try PersistenceController.shared.container.viewContext.save()
|
try PersistenceController.shared.container.viewContext.save()
|
||||||
} else {
|
} else {
|
||||||
let pluginListRequest = PluginList.fetchRequest()
|
let pluginListRequest = PluginList.fetchRequest()
|
||||||
let urlPredicate = NSPredicate(format: "urlString == %@", url)
|
let urlPredicate = NSPredicate(format: "urlString == %@", urlString)
|
||||||
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
|
let infoPredicate = NSPredicate(format: "author == %@ AND name == %@", rawResponse.author, rawResponse.name)
|
||||||
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
|
pluginListRequest.predicate = NSCompoundPredicate(type: .or, subpredicates: [urlPredicate, infoPredicate])
|
||||||
pluginListRequest.fetchLimit = 1
|
pluginListRequest.fetchLimit = 1
|
||||||
|
|
@ -783,7 +842,7 @@ public class PluginManager: ObservableObject {
|
||||||
|
|
||||||
let newPluginList = PluginList(context: backgroundContext)
|
let newPluginList = PluginList(context: backgroundContext)
|
||||||
newPluginList.id = UUID()
|
newPluginList.id = UUID()
|
||||||
newPluginList.urlString = url
|
newPluginList.urlString = urlString
|
||||||
newPluginList.name = rawResponse.name
|
newPluginList.name = rawResponse.name
|
||||||
newPluginList.author = rawResponse.author
|
newPluginList.author = rawResponse.author
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,23 @@ class ScrapingViewModel: ObservableObject {
|
||||||
runningSearchTask = nil
|
runningSearchTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cleanedSearchText: String = ""
|
||||||
@Published var searchResults: [SearchResult] = []
|
@Published var searchResults: [SearchResult] = []
|
||||||
|
|
||||||
// Only add results with valid magnet hashes to the search results array
|
// Only add results with valid magnet hashes to the search results array
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateSearchResults(newResults: [SearchResult]) {
|
private func updateSearchResults(newResults: [SearchResult]) {
|
||||||
searchResults += newResults
|
searchResults += newResults
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearSearchResults() {
|
private func clearSearchResults() {
|
||||||
searchResults = []
|
searchResults = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var currentSourceNames: Set<String> = []
|
@Published var currentSourceNames: Set<String> = []
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateCurrentSourceNames(_ newName: String) {
|
private func updateCurrentSourceNames(_ newName: String) {
|
||||||
currentSourceNames.insert(newName)
|
currentSourceNames.insert(newName)
|
||||||
logManager?.updateIndeterminateToast(
|
logManager?.updateIndeterminateToast(
|
||||||
"Loading \(currentSourceNames.joined(separator: ", "))",
|
"Loading \(currentSourceNames.joined(separator: ", "))",
|
||||||
|
|
@ -46,7 +47,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func removeCurrentSourceName(_ removedName: String) {
|
private func removeCurrentSourceName(_ removedName: String) {
|
||||||
currentSourceNames.remove(removedName)
|
currentSourceNames.remove(removedName)
|
||||||
logManager?.updateIndeterminateToast(
|
logManager?.updateIndeterminateToast(
|
||||||
"Loading \(currentSourceNames.joined(separator: ", "))",
|
"Loading \(currentSourceNames.joined(separator: ", "))",
|
||||||
|
|
@ -55,19 +56,39 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearCurrentSourceNames() {
|
private func clearCurrentSourceNames() {
|
||||||
currentSourceNames = []
|
currentSourceNames = []
|
||||||
logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil)
|
logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var filteredSource: Source?
|
|
||||||
|
|
||||||
// Utility function to print source specific errors
|
// Utility function to print source specific errors
|
||||||
func sendSourceError(_ description: String) async {
|
private func sendSourceError(_ description: String) async {
|
||||||
await logManager?.error(description, showToast: false)
|
await logManager?.error(description, showToast: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
|
// Substitutes the given string with an arbitrary parameter dictionary
|
||||||
|
private func substituteParams(_ input: String, with params: [String: String]) -> String {
|
||||||
|
let replaced = params.reduce(input) { result, param -> String in
|
||||||
|
result.replacingOccurrences(of: "{\(param.key)}", with: param.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleans a SourceRequest's body and headers to be substituted
|
||||||
|
private func cleanRequest(request: SourceRequest, params: [String: String]) -> SourceRequest {
|
||||||
|
if let body = request.body {
|
||||||
|
request.body = substituteParams(body, with: params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let headers = request.headers {
|
||||||
|
request.headers = headers.mapValues { substituteParams($0, with: params) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async {
|
||||||
await logManager?.info("Started scanning sources for query \"\(searchText)\"")
|
await logManager?.info("Started scanning sources for query \"\(searchText)\"")
|
||||||
|
|
||||||
if sources.isEmpty {
|
if sources.isEmpty {
|
||||||
|
|
@ -79,10 +100,13 @@ class ScrapingViewModel: ObservableObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanedSearchText = searchText.lowercased()
|
||||||
|
|
||||||
if await !debridManager.enabledDebrids.isEmpty {
|
if await !debridManager.enabledDebrids.isEmpty {
|
||||||
await debridManager.clearIAValues()
|
await debridManager.clearIAValues()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clearCurrentSourceNames()
|
||||||
await clearSearchResults()
|
await clearSearchResults()
|
||||||
|
|
||||||
await logManager?.updateIndeterminateToast("Loading sources", cancelAction: {
|
await logManager?.updateIndeterminateToast("Loading sources", cancelAction: {
|
||||||
|
|
@ -101,7 +125,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
if source.enabled {
|
if source.enabled {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
await self.updateCurrentSourceNames(source.name)
|
await self.updateCurrentSourceNames(source.name)
|
||||||
let requestResult = await self.executeParser(source: source, searchText: searchText)
|
let requestResult = await self.executeParser(source: source)
|
||||||
|
|
||||||
return (requestResult, source.name)
|
return (requestResult, source.name)
|
||||||
}
|
}
|
||||||
|
|
@ -142,8 +166,8 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeParser(source: Source, searchText: String) async -> SearchRequestResult? {
|
private func executeParser(source: Source) async -> SearchRequestResult? {
|
||||||
guard let baseUrl = source.baseUrl else {
|
guard let website = source.website else {
|
||||||
await logManager?.error("Scraping: The base URL could not be found for source \(source.name)")
|
await logManager?.error("Scraping: The base URL could not be found for source \(source.name)")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -152,50 +176,61 @@ class ScrapingViewModel: ObservableObject {
|
||||||
// Default to HTML scraping
|
// Default to HTML scraping
|
||||||
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
|
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
|
||||||
|
|
||||||
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
guard let encodedQuery = cleanedSearchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||||
await sendSourceError("\(source.name): Could not process search query, invalid characters present.")
|
await sendSourceError("\(source.name): Could not process search query, invalid characters present.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial params dict to reference
|
||||||
|
// More params are added here as needed
|
||||||
|
var params: [String: String] = [
|
||||||
|
"query": encodedQuery,
|
||||||
|
"queryFirstLetter": encodedQuery.first.map { String($0).lowercased() } ?? ""
|
||||||
|
]
|
||||||
|
|
||||||
switch preferredParser {
|
switch preferredParser {
|
||||||
case .scraping:
|
case .scraping:
|
||||||
if let htmlParser = source.htmlParser {
|
if let htmlParser = source.htmlParser {
|
||||||
let replacedSearchUrl = htmlParser.searchUrl
|
let replacedSearchUrl = htmlParser.searchUrl.map {
|
||||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
substituteParams($0, with: params)
|
||||||
|
}
|
||||||
|
|
||||||
let data = await handleUrls(
|
let data = await handleUrls(
|
||||||
baseUrl: baseUrl,
|
website: website,
|
||||||
replacedSearchUrl: replacedSearchUrl,
|
replacedSearchUrl: replacedSearchUrl,
|
||||||
fallbackUrls: source.fallbackUrls,
|
fallbackUrls: source.fallbackUrls,
|
||||||
sourceName: source.name
|
sourceName: source.name,
|
||||||
|
requestParams: htmlParser.request.map { cleanRequest(request: $0, params: params) }
|
||||||
)
|
)
|
||||||
|
|
||||||
if let data,
|
if let data,
|
||||||
let html = String(data: data, encoding: .utf8)
|
let html = String(data: data, encoding: .utf8)
|
||||||
{
|
{
|
||||||
return await scrapeHtml(source: source, baseUrl: baseUrl, html: html)
|
return await scrapeHtml(source: source, website: website, html: html)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .rss:
|
case .rss:
|
||||||
if let rssParser = source.rssParser {
|
if let rssParser = source.rssParser {
|
||||||
let replacedSearchUrl = rssParser.searchUrl
|
params.updateValue(source.api?.clientSecret?.value ?? "", forKey: "secret")
|
||||||
.replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "")
|
|
||||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
let replacedSearchUrl = substituteParams(rssParser.searchUrl, with: params)
|
||||||
|
|
||||||
// Do not use fallback URLs if the base URL isn't used
|
// Do not use fallback URLs if the base URL isn't used
|
||||||
let data: Data?
|
let data: Data?
|
||||||
if let rssUrl = rssParser.rssUrl {
|
if let rssUrl = rssParser.rssUrl {
|
||||||
data = await fetchWebsiteData(
|
data = await fetchWebsiteData(
|
||||||
urlString: rssUrl + replacedSearchUrl,
|
urlString: rssUrl + replacedSearchUrl,
|
||||||
sourceName: source.name
|
sourceName: source.name,
|
||||||
|
requestParams: rssParser.request
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
data = await handleUrls(
|
data = await handleUrls(
|
||||||
baseUrl: baseUrl,
|
website: website,
|
||||||
replacedSearchUrl: replacedSearchUrl,
|
replacedSearchUrl: replacedSearchUrl,
|
||||||
fallbackUrls: source.fallbackUrls,
|
fallbackUrls: source.fallbackUrls,
|
||||||
sourceName: source.name
|
sourceName: source.name,
|
||||||
|
requestParams: rssParser.request
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,8 +242,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
case .siteApi:
|
case .siteApi:
|
||||||
if let jsonParser = source.jsonParser {
|
if let jsonParser = source.jsonParser {
|
||||||
var replacedSearchUrl = jsonParser.searchUrl
|
var replacedSearchUrl = substituteParams(jsonParser.searchUrl, with: params)
|
||||||
.replacingOccurrences(of: "{query}", with: encodedQuery)
|
|
||||||
|
|
||||||
// Handle anything API related including tokens, client IDs, and appending the API URL
|
// Handle anything API related including tokens, client IDs, and appending the API URL
|
||||||
// The source API key is for APIs that require extra credentials or use a different URL
|
// The source API key is for APIs that require extra credentials or use a different URL
|
||||||
|
|
@ -218,7 +252,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
replacement: "{clientId}",
|
replacement: "{clientId}",
|
||||||
searchUrl: replacedSearchUrl,
|
searchUrl: replacedSearchUrl,
|
||||||
apiUrl: sourceApi.apiUrl,
|
apiUrl: sourceApi.apiUrl,
|
||||||
baseUrl: baseUrl,
|
website: website,
|
||||||
sourceName: source.name)
|
sourceName: source.name)
|
||||||
{
|
{
|
||||||
replacedSearchUrl = newSearchUrl
|
replacedSearchUrl = newSearchUrl
|
||||||
|
|
@ -231,7 +265,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
replacement: "{secret}",
|
replacement: "{secret}",
|
||||||
searchUrl: replacedSearchUrl,
|
searchUrl: replacedSearchUrl,
|
||||||
apiUrl: sourceApi.apiUrl,
|
apiUrl: sourceApi.apiUrl,
|
||||||
baseUrl: baseUrl,
|
website: website,
|
||||||
sourceName: source.name)
|
sourceName: source.name)
|
||||||
{
|
{
|
||||||
replacedSearchUrl = newSearchUrl
|
replacedSearchUrl = newSearchUrl
|
||||||
|
|
@ -239,12 +273,13 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let passedUrl = source.api?.apiUrl ?? baseUrl
|
let passedUrl = source.api?.apiUrl ?? website
|
||||||
let data = await handleUrls(
|
let data = await handleUrls(
|
||||||
baseUrl: passedUrl,
|
website: passedUrl,
|
||||||
replacedSearchUrl: replacedSearchUrl,
|
replacedSearchUrl: replacedSearchUrl,
|
||||||
fallbackUrls: source.fallbackUrls,
|
fallbackUrls: source.fallbackUrls,
|
||||||
sourceName: source.name
|
sourceName: source.name,
|
||||||
|
requestParams: jsonParser.request
|
||||||
)
|
)
|
||||||
|
|
||||||
if let data {
|
if let data {
|
||||||
|
|
@ -259,14 +294,16 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks the base URL for any website data then iterates through the fallback URLs
|
// Checks the base URL for any website data then iterates through the fallback URLs
|
||||||
func handleUrls(baseUrl: String, replacedSearchUrl: String, fallbackUrls: [String]?, sourceName: String) async -> Data? {
|
private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? {
|
||||||
if let data = await fetchWebsiteData(urlString: baseUrl + replacedSearchUrl, sourceName: sourceName) {
|
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
|
||||||
|
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
if let fallbackUrls {
|
if let fallbackUrls {
|
||||||
for fallbackUrl in fallbackUrls {
|
for fallbackUrl in fallbackUrls {
|
||||||
if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl, sourceName: sourceName) {
|
let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
|
||||||
|
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,12 +312,12 @@ class ScrapingViewModel: ObservableObject {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handleApiCredential(_ credential: SourceApiCredential,
|
private func handleApiCredential(_ credential: SourceApiCredential,
|
||||||
replacement: String,
|
replacement: String,
|
||||||
searchUrl: String,
|
searchUrl: String,
|
||||||
apiUrl: String?,
|
apiUrl: String?,
|
||||||
baseUrl: String,
|
website: String,
|
||||||
sourceName: String) async -> String?
|
sourceName: String) async -> String?
|
||||||
{
|
{
|
||||||
// Is the credential expired
|
// Is the credential expired
|
||||||
var isExpired = false
|
var isExpired = false
|
||||||
|
|
@ -292,13 +329,12 @@ class ScrapingViewModel: ObservableObject {
|
||||||
|
|
||||||
// Fetch a new credential if it's expired or doesn't exist yet
|
// Fetch a new credential if it's expired or doesn't exist yet
|
||||||
if let value = credential.value, !isExpired {
|
if let value = credential.value, !isExpired {
|
||||||
return searchUrl
|
return substituteParams(searchUrl, with: [replacement: value])
|
||||||
.replacingOccurrences(of: replacement, with: value)
|
|
||||||
} else if
|
} else if
|
||||||
credential.value == nil || isExpired,
|
credential.value == nil || isExpired,
|
||||||
let credentialUrl = credential.urlString,
|
let credentialUrl = credential.urlString,
|
||||||
let newValue = await fetchApiCredential(
|
let newValue = await fetchApiCredential(
|
||||||
urlString: (apiUrl ?? baseUrl) + credentialUrl,
|
urlString: (apiUrl ?? website) + credentialUrl,
|
||||||
credential: credential,
|
credential: credential,
|
||||||
sourceName: sourceName
|
sourceName: sourceName
|
||||||
)
|
)
|
||||||
|
|
@ -317,9 +353,9 @@ class ScrapingViewModel: ObservableObject {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func fetchApiCredential(urlString: String,
|
private func fetchApiCredential(urlString: String,
|
||||||
credential: SourceApiCredential,
|
credential: SourceApiCredential,
|
||||||
sourceName: String) async -> String?
|
sourceName: String) async -> String?
|
||||||
{
|
{
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.")
|
await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.")
|
||||||
|
|
@ -363,14 +399,31 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches the data for a URL
|
// Fetches the data for a URL
|
||||||
public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? {
|
private func fetchWebsiteData(urlString: String, sourceName: String, requestParams: SourceRequest?) async -> Data? {
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||||
await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!")
|
await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = URLRequest(url: url, timeoutInterval: 15)
|
var timeout: Double = 15
|
||||||
|
|
||||||
|
let disableRequestTimeout = UserDefaults.standard.bool(forKey: "Behavior.DisableRequestTimeout")
|
||||||
|
if disableRequestTimeout {
|
||||||
|
timeout = Double.infinity
|
||||||
|
} else {
|
||||||
|
let requestTimeoutSecs = UserDefaults.standard.double(forKey: "Behavior.RequestTimeoutSecs")
|
||||||
|
if requestTimeoutSecs != 0 {
|
||||||
|
timeout = requestTimeoutSecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url, timeoutInterval: timeout)
|
||||||
|
request.httpMethod = requestParams?.method
|
||||||
|
request.httpBody = requestParams?.body?.data(using: .utf8)
|
||||||
|
requestParams?.headers?.forEach { field, value in
|
||||||
|
request.addValue(value, forHTTPHeaderField: field)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
@ -393,7 +446,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
||||||
guard let jsonParser = source.jsonParser else {
|
guard let jsonParser = source.jsonParser else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -467,11 +520,36 @@ class ScrapingViewModel: ObservableObject {
|
||||||
return SearchRequestResult(results: tempResults, magnets: magnets)
|
return SearchRequestResult(results: tempResults, magnets: magnets)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func parseJsonResult(_ result: JSON,
|
// TODO: Add regex parsing for API
|
||||||
jsonParser: SourceJsonParser,
|
private func parseJsonResult(_ result: JSON,
|
||||||
source: Source,
|
jsonParser: SourceJsonParser,
|
||||||
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
source: Source,
|
||||||
|
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
||||||
{
|
{
|
||||||
|
// Enforce these parsers
|
||||||
|
guard let titleParser = jsonParser.title else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String? = existingSearchResult?.title
|
||||||
|
if let existingTitle = title,
|
||||||
|
let discriminatorQuery = titleParser.discriminator
|
||||||
|
{
|
||||||
|
let rawDiscriminator = result[discriminatorQuery.components(separatedBy: ".")].rawValue
|
||||||
|
|
||||||
|
if !(rawDiscriminator is NSNull) {
|
||||||
|
title = String(describing: rawDiscriminator) + existingTitle
|
||||||
|
}
|
||||||
|
} else if title == nil {
|
||||||
|
let rawTitle = result[titleParser.query].rawValue
|
||||||
|
title = rawTitle is NSNull ? nil : String(describing: rawTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return if a title doesn't exist
|
||||||
|
if title == nil, jsonParser.subResults == nil, existingSearchResult == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var magnetHash: String? = existingSearchResult?.magnet.hash
|
var magnetHash: String? = existingSearchResult?.magnet.hash
|
||||||
if let magnetHashParser = jsonParser.magnetHash {
|
if let magnetHashParser = jsonParser.magnetHash {
|
||||||
let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue
|
let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue
|
||||||
|
|
@ -487,25 +565,9 @@ class ScrapingViewModel: ObservableObject {
|
||||||
link = rawLink is NSNull ? nil : String(describing: rawLink)
|
link = rawLink is NSNull ? nil : String(describing: rawLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: String? = existingSearchResult?.title
|
// Return if a magnet hash doesn't exist
|
||||||
if let titleParser = jsonParser.title {
|
|
||||||
if let existingTitle = existingSearchResult?.title,
|
|
||||||
let discriminatorQuery = titleParser.discriminator
|
|
||||||
{
|
|
||||||
let rawDiscriminator = result[discriminatorQuery.components(separatedBy: ".")].rawValue
|
|
||||||
|
|
||||||
if !(rawDiscriminator is NSNull) {
|
|
||||||
title = String(describing: rawDiscriminator) + existingTitle
|
|
||||||
}
|
|
||||||
} else if existingSearchResult?.title == nil {
|
|
||||||
let rawTitle = result[titleParser.query].rawValue
|
|
||||||
title = rawTitle is NSNull ? nil : String(describing: rawTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return if no magnet hash exists
|
|
||||||
let magnet = Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers)
|
let magnet = Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers)
|
||||||
if magnet.hash == nil {
|
if magnet.hash == nil, jsonParser.subResults == nil, existingSearchResult == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -553,7 +615,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RSS feed scraper
|
// RSS feed scraper
|
||||||
public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
|
private func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? {
|
||||||
guard let rssParser = source.rssParser else {
|
guard let rssParser = source.rssParser else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -573,6 +635,21 @@ class ScrapingViewModel: ObservableObject {
|
||||||
var magnets: [Magnet] = []
|
var magnets: [Magnet] = []
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
|
// Enforce these parsers
|
||||||
|
guard let titleParser = rssParser.title else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let title = try? runRssComplexQuery(
|
||||||
|
item: item,
|
||||||
|
query: titleParser.query,
|
||||||
|
attribute: titleParser.attribute,
|
||||||
|
discriminator: titleParser.discriminator,
|
||||||
|
regexString: titleParser.regex
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Parse magnet link or translate hash
|
// Parse magnet link or translate hash
|
||||||
var magnetHash: String?
|
var magnetHash: String?
|
||||||
if let magnetHashParser = rssParser.magnetHash {
|
if let magnetHashParser = rssParser.magnetHash {
|
||||||
|
|
@ -596,17 +673,6 @@ class ScrapingViewModel: ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: String?
|
|
||||||
if let titleParser = rssParser.title {
|
|
||||||
title = try? runRssComplexQuery(
|
|
||||||
item: item,
|
|
||||||
query: titleParser.query,
|
|
||||||
attribute: titleParser.attribute,
|
|
||||||
discriminator: titleParser.discriminator,
|
|
||||||
regexString: titleParser.regex
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches the subName for the source if there is one
|
// Fetches the subName for the source if there is one
|
||||||
var subName: String?
|
var subName: String?
|
||||||
if let subNameParser = rssParser.subName {
|
if let subNameParser = rssParser.subName {
|
||||||
|
|
@ -666,7 +732,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = SearchResult(
|
let result = SearchResult(
|
||||||
title: title ?? "No title",
|
title: title,
|
||||||
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
||||||
size: size ?? "",
|
size: size ?? "",
|
||||||
magnet: magnet,
|
magnet: magnet,
|
||||||
|
|
@ -684,11 +750,11 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complex query parsing for RSS scraping
|
// Complex query parsing for RSS scraping
|
||||||
func runRssComplexQuery(item: Element,
|
private func runRssComplexQuery(item: Element,
|
||||||
query: String,
|
query: String,
|
||||||
attribute: String,
|
attribute: String,
|
||||||
discriminator: String?,
|
discriminator: String?,
|
||||||
regexString: String?) throws -> String?
|
regexString: String?) throws -> String?
|
||||||
{
|
{
|
||||||
var parsedValue: String?
|
var parsedValue: String?
|
||||||
|
|
||||||
|
|
@ -708,17 +774,16 @@ class ScrapingViewModel: ObservableObject {
|
||||||
|
|
||||||
// A capture group must be used in the provided regex
|
// A capture group must be used in the provided regex
|
||||||
if let regexString,
|
if let regexString,
|
||||||
let parsedValue,
|
let parsedValue
|
||||||
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
|
|
||||||
{
|
{
|
||||||
return regexValue
|
return runRegex(parsedValue: parsedValue, regexString: regexString)
|
||||||
} else {
|
} else {
|
||||||
return parsedValue
|
return parsedValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML scraper
|
// HTML scraper
|
||||||
public func scrapeHtml(source: Source, baseUrl: String, html: String) async -> SearchRequestResult? {
|
private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
|
||||||
guard let htmlParser = source.htmlParser else {
|
guard let htmlParser = source.htmlParser else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -740,18 +805,37 @@ class ScrapingViewModel: ObservableObject {
|
||||||
// If there's an error, continue instead of returning with nothing
|
// If there's an error, continue instead of returning with nothing
|
||||||
for row in rows {
|
for row in rows {
|
||||||
do {
|
do {
|
||||||
// Fetches the magnet link
|
// Enforce these parsers
|
||||||
// If the magnet is located on an external page, fetch the external page and grab the magnet link
|
guard
|
||||||
// External page fetching affects source performance
|
let magnetParser = htmlParser.magnetLink,
|
||||||
guard let magnetParser = htmlParser.magnetLink else {
|
let titleParser = htmlParser.title
|
||||||
|
else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetches the episode/movie title
|
||||||
|
// Place here for filtering purposes
|
||||||
|
guard let title = try? runHtmlComplexQuery(
|
||||||
|
row: row,
|
||||||
|
query: titleParser.query,
|
||||||
|
attribute: titleParser.attribute,
|
||||||
|
regexString: titleParser.regex
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches the magnet link
|
||||||
|
// If the magnet is located on an external page, fetch the external page and grab the magnet link
|
||||||
|
// External page fetching affects source performance
|
||||||
var href: String
|
var href: String
|
||||||
if let externalMagnetQuery = magnetParser.externalLinkQuery, !externalMagnetQuery.isEmpty {
|
if let externalMagnetQuery = magnetParser.externalLinkQuery, !externalMagnetQuery.isEmpty {
|
||||||
|
guard let externalMagnetUrl = try row.select(externalMagnetQuery).first()?.attr("href") else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
|
||||||
guard
|
guard
|
||||||
let externalMagnetLink = try row.select(externalMagnetQuery).first()?.attr("href"),
|
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request),
|
||||||
let data = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink, sourceName: source.name),
|
|
||||||
let magnetHtml = String(data: data, encoding: .utf8)
|
let magnetHtml = String(data: data, encoding: .utf8)
|
||||||
else {
|
else {
|
||||||
continue
|
continue
|
||||||
|
|
@ -786,17 +870,6 @@ class ScrapingViewModel: ObservableObject {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches the episode/movie title
|
|
||||||
var title: String?
|
|
||||||
if let titleParser = htmlParser.title {
|
|
||||||
title = try? runHtmlComplexQuery(
|
|
||||||
row: row,
|
|
||||||
query: titleParser.query,
|
|
||||||
attribute: titleParser.attribute,
|
|
||||||
regexString: titleParser.regex
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var subName: String?
|
var subName: String?
|
||||||
if let subNameParser = htmlParser.subName {
|
if let subNameParser = htmlParser.subName {
|
||||||
subName = try? runHtmlComplexQuery(
|
subName = try? runHtmlComplexQuery(
|
||||||
|
|
@ -847,7 +920,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let leecherQuery = seederLeecher.seeders {
|
if let leecherQuery = seederLeecher.leechers {
|
||||||
leechers = try? runHtmlComplexQuery(
|
leechers = try? runHtmlComplexQuery(
|
||||||
row: row,
|
row: row,
|
||||||
query: leecherQuery,
|
query: leecherQuery,
|
||||||
|
|
@ -859,7 +932,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = SearchResult(
|
let result = SearchResult(
|
||||||
title: title ?? "No title",
|
title: title,
|
||||||
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
source: subName.map { "\(source.name) - \($0)" } ?? source.name,
|
||||||
size: size ?? "",
|
size: size ?? "",
|
||||||
magnet: magnet,
|
magnet: magnet,
|
||||||
|
|
@ -882,10 +955,10 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complex query parsing for HTML scraping
|
// Complex query parsing for HTML scraping
|
||||||
func runHtmlComplexQuery(row: Element,
|
private func runHtmlComplexQuery(row: Element,
|
||||||
query: String,
|
query: String,
|
||||||
attribute: String,
|
attribute: String,
|
||||||
regexString: String?) throws -> String?
|
regexString: String?) throws -> String?
|
||||||
{
|
{
|
||||||
var parsedValue: String?
|
var parsedValue: String?
|
||||||
|
|
||||||
|
|
@ -898,18 +971,39 @@ class ScrapingViewModel: ObservableObject {
|
||||||
parsedValue = try result?.attr(attribute)
|
parsedValue = try result?.attr(attribute)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A capture group must be used in the provided regex
|
if let parsedValue,
|
||||||
if let regexString,
|
let regexString
|
||||||
let parsedValue,
|
|
||||||
let regexValue = try? Regex(regexString).firstMatch(in: parsedValue)?.groups[safe: 0]?.value
|
|
||||||
{
|
{
|
||||||
return regexValue
|
return runRegex(parsedValue: parsedValue, regexString: regexString)
|
||||||
} else {
|
} else {
|
||||||
return parsedValue
|
return parsedValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSizeString(sizeString: String) -> String? {
|
private func runRegex(parsedValue: String, regexString: String) -> String? {
|
||||||
|
// TODO: Maybe dynamically parse flags
|
||||||
|
let replacedRegexString = regexString
|
||||||
|
.replacingOccurrences(of: "{query}", with: cleanedSearchText)
|
||||||
|
|
||||||
|
guard
|
||||||
|
let matchedRegex = try? Regex(
|
||||||
|
replacedRegexString,
|
||||||
|
options: [.caseInsensitive, .anchorsMatchLines]
|
||||||
|
)
|
||||||
|
.firstMatch(in: parsedValue)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is there a capture group present? Otherwise return the original matched string
|
||||||
|
if let group = matchedRegex.groups[safe: 0] {
|
||||||
|
return group.value
|
||||||
|
} else {
|
||||||
|
return parsedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseSizeString(sizeString: String) -> String? {
|
||||||
// Test if the string can be a full integer
|
// Test if the string can be a full integer
|
||||||
guard let size = Int(sizeString) else {
|
guard let size = Int(sizeString) else {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -931,7 +1025,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
private func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let hasCredentials = api.clientId != nil || api.clientSecret != nil
|
let hasCredentials = api.clientId != nil || api.clientSecret != nil
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ struct AboutView: View {
|
||||||
|
|
||||||
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
|
Text("Ferrite is a free and open source application developed by kingbri under the GNU-GPLv3 license.")
|
||||||
.textCase(.none)
|
.textCase(.none)
|
||||||
.foregroundColor(.label)
|
.foregroundColor(.init(uiColor: .label))
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
//
|
|
||||||
// AlertButton.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 9/8/22.
|
|
||||||
//
|
|
||||||
// Universal alert button for dynamic alert views
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AlertButton: Identifiable {
|
|
||||||
enum Role {
|
|
||||||
case destructive
|
|
||||||
case cancel
|
|
||||||
}
|
|
||||||
|
|
||||||
let id: UUID
|
|
||||||
let label: String
|
|
||||||
let action: () -> Void
|
|
||||||
let role: Role?
|
|
||||||
|
|
||||||
// Used for all buttons
|
|
||||||
init(_ label: String, role: Role? = nil, action: @escaping () -> Void) {
|
|
||||||
id = UUID()
|
|
||||||
self.label = label
|
|
||||||
self.action = action
|
|
||||||
self.role = role
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used for buttons with no action
|
|
||||||
init(_ label: String? = nil, role: Role? = nil) {
|
|
||||||
id = UUID()
|
|
||||||
self.label = label ?? (role == .cancel ? "Cancel" : "OK")
|
|
||||||
action = {}
|
|
||||||
self.role = role
|
|
||||||
}
|
|
||||||
|
|
||||||
func toActionButton() -> Alert.Button {
|
|
||||||
if let role {
|
|
||||||
switch role {
|
|
||||||
case .cancel:
|
|
||||||
return .cancel(Text(label))
|
|
||||||
case .destructive:
|
|
||||||
return .destructive(Text(label), action: action)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return .default(Text(label), action: action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
@ViewBuilder
|
|
||||||
func toButtonView() -> some View {
|
|
||||||
Button(label, role: toButtonRole(role), action: action)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func toButtonRole(_ role: Role?) -> ButtonRole? {
|
|
||||||
if let role {
|
|
||||||
switch role {
|
|
||||||
case .destructive:
|
|
||||||
return .destructive
|
|
||||||
case .cancel:
|
|
||||||
return .cancel
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
//
|
|
||||||
// Backport.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 9/29/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Introspect
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public struct Backport<Content> {
|
|
||||||
public let content: Content
|
|
||||||
|
|
||||||
public init(_ content: Content) {
|
|
||||||
self.content = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
var backport: Backport<Self> { Backport(self) }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Backport where Content: View {
|
|
||||||
@ViewBuilder func alert(isPresented: Binding<Bool>,
|
|
||||||
title: String,
|
|
||||||
message: String?,
|
|
||||||
buttons: [AlertButton] = []) -> some View
|
|
||||||
{
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
content
|
|
||||||
.alert(
|
|
||||||
title,
|
|
||||||
isPresented: isPresented,
|
|
||||||
actions: {
|
|
||||||
ForEach(buttons) { button in
|
|
||||||
button.toButtonView()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
if let message {
|
|
||||||
Text(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.background {
|
|
||||||
Color.clear
|
|
||||||
.alert(isPresented: isPresented) {
|
|
||||||
if let primaryButton = buttons[safe: 0],
|
|
||||||
let secondaryButton = buttons[safe: 1]
|
|
||||||
{
|
|
||||||
return Alert(
|
|
||||||
title: Text(title),
|
|
||||||
message: message.map { Text($0) } ?? nil,
|
|
||||||
primaryButton: primaryButton.toActionButton(),
|
|
||||||
secondaryButton: secondaryButton.toActionButton()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return Alert(
|
|
||||||
title: Text(title),
|
|
||||||
message: message.map { Text($0) } ?? nil,
|
|
||||||
dismissButton: buttons[safe: 0].map { $0.toActionButton() } ?? .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder func confirmationDialog(isPresented: Binding<Bool>,
|
|
||||||
title: String, message: String?,
|
|
||||||
buttons: [AlertButton]) -> some View
|
|
||||||
{
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
content
|
|
||||||
.confirmationDialog(
|
|
||||||
title,
|
|
||||||
isPresented: isPresented,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
ForEach(buttons) { button in
|
|
||||||
button.toButtonView()
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
if let message {
|
|
||||||
Text(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.actionSheet(isPresented: isPresented) {
|
|
||||||
ActionSheet(
|
|
||||||
title: Text(title),
|
|
||||||
message: message.map { Text($0) } ?? nil,
|
|
||||||
buttons: [buttons.map { $0.toActionButton() }, [.cancel()]].flatMap { $0 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder func tint(_ color: Color) -> some View {
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
content
|
|
||||||
.tint(color)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.accentColor(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder func onAppear(callback: @escaping () -> Void) -> some View {
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
content
|
|
||||||
.onAppear {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.viewDidAppear {
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder func introspectSearchController(customize: @escaping (UISearchController) -> Void) -> some View {
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
content.introspectSearchController(customize: customize)
|
|
||||||
} else {
|
|
||||||
content.introspectNavigationController { navigationController in
|
|
||||||
let navigationBar = navigationController.navigationBar
|
|
||||||
if let searchController = navigationBar.topItem?.searchController {
|
|
||||||
customize(searchController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
//
|
|
||||||
// DynamicFetchRequest.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 9/6/22.
|
|
||||||
//
|
|
||||||
// Used for FetchRequests with a dynamic predicate
|
|
||||||
// iOS 14 compatible view
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct DynamicFetchRequest<T: NSManagedObject, Content: View>: View {
|
|
||||||
@FetchRequest var fetchRequest: FetchedResults<T>
|
|
||||||
|
|
||||||
let content: (FetchedResults<T>) -> Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content(fetchRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(predicate: NSPredicate?,
|
|
||||||
sortDescriptors: [NSSortDescriptor] = [],
|
|
||||||
@ViewBuilder content: @escaping (FetchedResults<T>) -> Content)
|
|
||||||
{
|
|
||||||
_fetchRequest = FetchRequest<T>(entity: T.entity(), sortDescriptors: sortDescriptors, predicate: predicate)
|
|
||||||
self.content = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,7 @@ struct EmptyInstructionView: View {
|
||||||
.padding(.horizontal, 50)
|
.padding(.horizontal, 50)
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.secondaryLabel)
|
.foregroundColor(.init(uiColor: .secondaryLabel))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
//
|
|
||||||
// FilterLabelView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 2/12/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FilterLabelView: View {
|
|
||||||
var name: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(name)
|
|
||||||
.opacity(0.6)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.foregroundColor(.tertiaryLabel)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 9)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
.font(.caption, weight: .medium)
|
|
||||||
.background(Capsule().foregroundColor(.secondarySystemFill))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,27 +8,55 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct HybridSecureField: View {
|
struct HybridSecureField: View {
|
||||||
|
enum Field: Hashable {
|
||||||
|
case plain
|
||||||
|
case secure
|
||||||
|
}
|
||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
|
var onCommit: () -> Void = {}
|
||||||
|
|
||||||
@State private var showPassword = false
|
@State private var showPassword = false
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
private var isFieldDisabled: Bool = false
|
||||||
|
|
||||||
|
init(text: Binding<String>, onCommit: (() -> Void)? = nil, showPassword: Bool = false) {
|
||||||
|
_text = text
|
||||||
|
if let onCommit {
|
||||||
|
self.onCommit = onCommit
|
||||||
|
}
|
||||||
|
self.showPassword = showPassword
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Group {
|
Group {
|
||||||
if showPassword {
|
if showPassword {
|
||||||
TextField("Password", text: $text)
|
TextField("Password", text: $text, onCommit: onCommit)
|
||||||
|
.focused($focusedField, equals: .plain)
|
||||||
} else {
|
} else {
|
||||||
SecureField("Password", text: $text)
|
SecureField("Password", text: $text, onCommit: onCommit)
|
||||||
|
.focused($focusedField, equals: .secure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
|
.disabledAppearance(isFieldDisabled)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showPassword.toggle()
|
showPassword.toggle()
|
||||||
|
focusedField = showPassword ? .plain : .secure
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: self.showPassword ? "eye.slash.fill" : "eye.fill")
|
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension HybridSecureField {
|
||||||
|
func fieldDisabled(_ isFieldDisabled: Bool) -> Self {
|
||||||
|
modifyViewProp { $0.isFieldDisabled = isFieldDisabled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,12 @@ struct IndeterminateProgressView: View {
|
||||||
.foregroundColor(Color.accentColor)
|
.foregroundColor(Color.accentColor)
|
||||||
.frame(width: reader.size.width * 0.26, height: 6)
|
.frame(width: reader.size.width * 0.26, height: 6)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
|
||||||
.offset(x: -reader.size.width * 0.6, y: 0)
|
.offset(x: -reader.size.width * 0.6, y: 0)
|
||||||
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
|
.offset(x: reader.size.width * 1.2 * offset, y: 0)
|
||||||
.animation(.default.repeatForever().speed(0.5), value: self.offset)
|
.animation(.default.repeatForever().speed(0.5), value: offset)
|
||||||
.backport.onAppear {
|
.onAppear {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.offset = 1
|
offset = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
//
|
|
||||||
// ConditionalContextMenu.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 9/3/22.
|
|
||||||
//
|
|
||||||
// Used as a workaround for iOS 15 not updating context views with conditional variables
|
|
||||||
// A stateful ID is required for the contextMenu to update itself.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ConditionalContextMenuModifier<InternalContent: View, ID: Hashable>: ViewModifier {
|
|
||||||
let internalContent: () -> InternalContent
|
|
||||||
let id: ID
|
|
||||||
|
|
||||||
init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) {
|
|
||||||
self.internalContent = internalContent
|
|
||||||
self.id = id
|
|
||||||
}
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
content
|
|
||||||
.contextMenu {
|
|
||||||
internalContent()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.background {
|
|
||||||
Color.clear
|
|
||||||
.contextMenu {
|
|
||||||
internalContent()
|
|
||||||
}
|
|
||||||
.id(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
//
|
|
||||||
// ConditionalId.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 9/4/22.
|
|
||||||
//
|
|
||||||
// Applies an ID below iOS 16
|
|
||||||
// This is due to ID workarounds making iOS 16 apps crash
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ConditionalIdModifier<ID: Hashable>: ViewModifier {
|
|
||||||
let id: ID
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
content
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.id(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
//
|
|
||||||
// SearchAppearance.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 2/14/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Introspect
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CustomScopeBarModifier<V: View>: ViewModifier {
|
|
||||||
let scopeBarContent: V
|
|
||||||
@State private var hostingController: UIHostingController<V>?
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
content
|
|
||||||
.backport.introspectSearchController { searchController in
|
|
||||||
|
|
||||||
// MARK: One-time setup
|
|
||||||
|
|
||||||
guard hostingController == nil else { return }
|
|
||||||
|
|
||||||
searchController.hidesNavigationBarDuringPresentation = true
|
|
||||||
searchController.searchBar.showsScopeBar = true
|
|
||||||
searchController.searchBar.scopeButtonTitles = [""]
|
|
||||||
(searchController.searchBar.value(forKey: "_scopeBar") as? UIView)?.isHidden = true
|
|
||||||
|
|
||||||
let hostingController = UIHostingController(rootView: scopeBarContent)
|
|
||||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
hostingController.view.backgroundColor = .clear
|
|
||||||
|
|
||||||
guard let containerView = searchController.searchBar.value(forKey: "_scopeBarContainerView") as? UIView else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
containerView.addSubview(hostingController.view)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
hostingController.view.widthAnchor.constraint(equalTo: containerView.widthAnchor),
|
|
||||||
hostingController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
|
|
||||||
hostingController.view.heightAnchor.constraint(equalTo: containerView.heightAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
self.hostingController = hostingController
|
|
||||||
}
|
|
||||||
.introspectNavigationController { navigationController in
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
navigationController.viewControllers.first?.navigationItem.preferredSearchBarPlacement = .stacked
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationController.navigationBar.prefersLargeTitles = true
|
|
||||||
navigationController.navigationBar.sizeToFit()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VStack {
|
|
||||||
scopeBarContent
|
|
||||||
content
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,26 +5,18 @@
|
||||||
// Created by Brian Dashore on 9/4/22.
|
// Created by Brian Dashore on 9/4/22.
|
||||||
//
|
//
|
||||||
// Removes the top padding on unsectioned lists
|
// Removes the top padding on unsectioned lists
|
||||||
// If a list is sectioned, see InlineHeader
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Introspect
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftUIIntrospect
|
||||||
|
|
||||||
struct InlinedListModifier: ViewModifier {
|
struct InlinedListModifier: ViewModifier {
|
||||||
let inset: CGFloat
|
let inset: CGFloat
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(iOS 16, *) {
|
content
|
||||||
content
|
.introspect(.list, on: .iOS(.v16, .v17, .v18)) { collectionView in
|
||||||
.introspectCollectionView { collectionView in
|
collectionView.contentInset.top = inset
|
||||||
collectionView.contentInset.top = inset
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.introspectTableView { tableView in
|
|
||||||
tableView.contentInset.top = inset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
//
|
|
||||||
// ViewDidAppearModifier.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 2/8/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ViewDidAppearModifier: ViewModifier {
|
|
||||||
let callback: () -> Void
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content
|
|
||||||
.background(ViewDidAppearHandler(callback: callback))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
//
|
|
||||||
// NavView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 7/4/22.
|
|
||||||
// Contributed by Mantton
|
|
||||||
//
|
|
||||||
// A wrapper that switches between NavigationStack and the legacy NavigationView
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct NavView<Content: View>: View {
|
|
||||||
@ViewBuilder var content: Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
NavigationStack {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NavigationView {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
.navigationViewStyle(.stack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,7 @@ struct Tag: View {
|
||||||
.padding(.vertical, verticalPadding)
|
.padding(.vertical, verticalPadding)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 5)
|
RoundedRectangle(cornerRadius: 5)
|
||||||
.foregroundColor(color.map { $0 } ?? .tertiaryLabel)
|
.foregroundColor(color.map { $0 } ?? .init(uiColor: .tertiaryLabel))
|
||||||
.opacity(0.3)
|
.opacity(0.3)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,38 +8,34 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DebridLabelView: View {
|
struct DebridLabelView: View {
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
@Store var debridSource: DebridSource
|
||||||
|
|
||||||
@State var cloudLinks: [String] = []
|
@State var cloudLinks: [String] = []
|
||||||
|
@State var tagColor: Color = .red
|
||||||
var magnet: Magnet?
|
var magnet: Magnet?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let selectedDebridType = debridManager.selectedDebridType {
|
Tag(
|
||||||
Tag(
|
name: debridSource.abbreviation,
|
||||||
name: selectedDebridType.toString(abbreviated: true),
|
color: getTagColor(),
|
||||||
color: getTagColor(),
|
horizontalPadding: 5,
|
||||||
horizontalPadding: 5,
|
verticalPadding: 3
|
||||||
verticalPadding: 3
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTagColor() -> Color {
|
func getTagColor() -> Color {
|
||||||
if let magnet, cloudLinks.isEmpty {
|
if let magnet, cloudLinks.isEmpty {
|
||||||
switch debridManager.matchMagnetHash(magnet) {
|
guard let match = debridSource.IAValues.first(where: { magnet.hash == $0.magnet.hash }) else {
|
||||||
case .full:
|
return .red
|
||||||
return Color.green
|
|
||||||
case .partial:
|
|
||||||
return Color.orange
|
|
||||||
case .none:
|
|
||||||
return Color.red
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return match.files.count > 1 ? .orange : .green
|
||||||
} else if cloudLinks.count == 1 {
|
} else if cloudLinks.count == 1 {
|
||||||
return Color.green
|
return .green
|
||||||
} else if cloudLinks.count > 1 {
|
} else if cloudLinks.count > 1 {
|
||||||
return Color.orange
|
return .orange
|
||||||
} else {
|
} else {
|
||||||
return Color.red
|
return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
//
|
|
||||||
// DebridChoiceView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 11/26/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct DebridPickerView<Content: View>: View {
|
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
|
||||||
|
|
||||||
@ViewBuilder var label: Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Menu {
|
|
||||||
Picker("", selection: $debridManager.selectedDebridType) {
|
|
||||||
Text("None")
|
|
||||||
.tag(nil as DebridType?)
|
|
||||||
|
|
||||||
ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in
|
|
||||||
if debridManager.enabledDebrids.contains(debridType) {
|
|
||||||
Text(debridType.toString())
|
|
||||||
.tag(DebridType?.some(debridType))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// FilterAmountLabelView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/11/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FilterAmountLabelView: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var amount: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(String(amount))
|
||||||
|
.padding(5)
|
||||||
|
.foregroundColor(colorScheme == .light ? .white : .accentColor)
|
||||||
|
.background {
|
||||||
|
Circle()
|
||||||
|
.foregroundColor(colorScheme == .light ? .accentColor : .white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Ferrite/Views/ComponentViews/Filters/FilterLabelView.swift
Normal file
44
Ferrite/Views/ComponentViews/Filters/FilterLabelView.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// FilterLabelView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 2/12/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FilterLabelView: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var name: String?
|
||||||
|
var fallbackName: String
|
||||||
|
var count: Int?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let count, count > 1 {
|
||||||
|
FilterAmountLabelView(amount: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(count ?? 1 == 1 ? name ?? fallbackName : fallbackName)
|
||||||
|
.opacity(count ?? 0 > 0 ? 1 : 0.6)
|
||||||
|
.foregroundColor(count ?? 0 > 0 && colorScheme == .light ? .accentColor : .primary)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.foregroundColor(count ?? 0 > 0 ? (colorScheme == .light ? .accentColor : .primary) : .init(uiColor: .tertiaryLabel))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 9)
|
||||||
|
.padding(.vertical, count ?? 1 > 1 ? 2 : 7)
|
||||||
|
.font(
|
||||||
|
.caption
|
||||||
|
.weight(.medium)
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.foregroundColor(
|
||||||
|
count ?? 0 > 0 ? .accentColor : .init(uiColor: .secondarySystemFill)
|
||||||
|
)
|
||||||
|
.opacity(count ?? 0 > 0 && colorScheme == .light ? 0.1 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Ferrite/Views/ComponentViews/Filters/IAFilterView.swift
Normal file
67
Ferrite/Views/ComponentViews/Filters/IAFilterView.swift
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
//
|
||||||
|
// IAFilterView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: Make this use multiple selections
|
||||||
|
struct IAFilterView: View {
|
||||||
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
@EnvironmentObject var navModel: NavigationViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
debridManager.filteredIAStatus = []
|
||||||
|
} label: {
|
||||||
|
Text("Any")
|
||||||
|
|
||||||
|
if debridManager.filteredIAStatus.isEmpty {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(IAStatus.allCases, id: \.self) { status in
|
||||||
|
let containsIAStatus = debridManager.filteredIAStatus.contains(status)
|
||||||
|
Button {
|
||||||
|
if containsIAStatus {
|
||||||
|
debridManager.filteredIAStatus.remove(status)
|
||||||
|
} else {
|
||||||
|
debridManager.filteredIAStatus.insert(status)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(status.rawValue)
|
||||||
|
|
||||||
|
if containsIAStatus {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
FilterLabelView(
|
||||||
|
name: debridManager.filteredIAStatus.first?.rawValue,
|
||||||
|
fallbackName: "Cache Status",
|
||||||
|
count: debridManager.filteredIAStatus.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.id(debridManager.filteredIAStatus)
|
||||||
|
.onChange(of: debridManager.filteredIAStatus) { newSources in
|
||||||
|
if newSources.isEmpty {
|
||||||
|
navModel.enabledFilters.remove(.IA)
|
||||||
|
} else {
|
||||||
|
navModel.enabledFilters.insert(.IA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: navModel.enabledFilters) { newFilters in
|
||||||
|
if newFilters.isEmpty {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(seconds: 0.25)
|
||||||
|
debridManager.filteredIAStatus = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// SelectedDebridFilterView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SelectedDebridFilterView<Content: View>: View {
|
||||||
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
|
||||||
|
@ViewBuilder var label: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
debridManager.selectedDebridSource = nil
|
||||||
|
} label: {
|
||||||
|
Text("None")
|
||||||
|
|
||||||
|
if debridManager.selectedDebridSource == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(debridManager.debridSources, id: \.id) { debridSource in
|
||||||
|
if debridSource.isLoggedIn {
|
||||||
|
Button {
|
||||||
|
debridManager.selectedDebridSource = debridSource
|
||||||
|
} label: {
|
||||||
|
Text(debridSource.id)
|
||||||
|
|
||||||
|
if debridManager.selectedDebridSource?.id == debridSource.id {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Ferrite/Views/ComponentViews/Filters/SortFilterView.swift
Normal file
73
Ferrite/Views/ComponentViews/Filters/SortFilterView.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
//
|
||||||
|
// SortFilterView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/14/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SortFilterView: View {
|
||||||
|
@EnvironmentObject var navModel: NavigationViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
navModel.currentSortFilter = nil
|
||||||
|
navModel.currentSortOrder = .forward
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("None")
|
||||||
|
|
||||||
|
if navModel.currentSortFilter == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(SortFilter.allCases, id: \.self) { sortFilter in
|
||||||
|
Button {
|
||||||
|
navModel.currentSortFilter = sortFilter
|
||||||
|
navModel.currentSortOrder = navModel.currentSortOrder == .forward ? .reverse : .forward
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(sortFilter.rawValue)
|
||||||
|
|
||||||
|
if navModel.currentSortFilter == sortFilter {
|
||||||
|
Image(systemName: navModel.currentSortOrder == .forward ? "chevron.down" : "chevron.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
FilterLabelView(
|
||||||
|
name: "Sort\(navModel.currentSortFilter.map { ": \($0.rawValue)" } ?? "")",
|
||||||
|
fallbackName: "Sort",
|
||||||
|
count: navModel.currentSortFilter == nil ? 0 : 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.id(navModel.currentSortFilter)
|
||||||
|
.onChange(of: navModel.currentSortFilter) { newFilter in
|
||||||
|
navModel.currentSortOrder = .forward
|
||||||
|
if newFilter == nil {
|
||||||
|
navModel.enabledFilters.remove(.sort)
|
||||||
|
} else {
|
||||||
|
navModel.enabledFilters.insert(.sort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: navModel.enabledFilters) { newFilters in
|
||||||
|
if newFilters.isEmpty {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(seconds: 0.25)
|
||||||
|
navModel.currentSortFilter = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SortFilterView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
SortFilterView()
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Ferrite/Views/ComponentViews/Filters/SourceFilterView.swift
Normal file
74
Ferrite/Views/ComponentViews/Filters/SourceFilterView.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
//
|
||||||
|
// SourceFilterView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: Make this use multiple selections
|
||||||
|
struct SourceFilterView: View {
|
||||||
|
@EnvironmentObject var pluginManager: PluginManager
|
||||||
|
@EnvironmentObject var navModel: NavigationViewModel
|
||||||
|
|
||||||
|
@FetchRequest(
|
||||||
|
entity: Source.entity(),
|
||||||
|
sortDescriptors: []
|
||||||
|
) var sources: FetchedResults<Source>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
pluginManager.filteredInstalledSources = []
|
||||||
|
} label: {
|
||||||
|
Text("All")
|
||||||
|
|
||||||
|
if pluginManager.filteredInstalledSources.isEmpty {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(sources, id: \.self) { source in
|
||||||
|
let containsSource = pluginManager.filteredInstalledSources.contains(source)
|
||||||
|
if source.enabled {
|
||||||
|
Button {
|
||||||
|
if containsSource {
|
||||||
|
pluginManager.filteredInstalledSources.remove(source)
|
||||||
|
} else {
|
||||||
|
pluginManager.filteredInstalledSources.insert(source)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(source.name)
|
||||||
|
|
||||||
|
if containsSource {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
FilterLabelView(
|
||||||
|
name: pluginManager.filteredInstalledSources.first?.name,
|
||||||
|
fallbackName: "Source",
|
||||||
|
count: pluginManager.filteredInstalledSources.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.id(pluginManager.filteredInstalledSources)
|
||||||
|
.onChange(of: pluginManager.filteredInstalledSources) { newSources in
|
||||||
|
if newSources.isEmpty {
|
||||||
|
navModel.enabledFilters.remove(.source)
|
||||||
|
} else {
|
||||||
|
navModel.enabledFilters.insert(.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: navModel.enabledFilters) { newFilters in
|
||||||
|
if newFilters.isEmpty {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(seconds: 0.25)
|
||||||
|
pluginManager.filteredInstalledSources = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,84 +8,75 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct BookmarksView: View {
|
struct BookmarksView: View {
|
||||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
|
||||||
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
@EnvironmentObject var navModel: NavigationViewModel
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
@Binding var searchText: String
|
@Binding var searchText: String
|
||||||
@Binding var bookmarksEmpty: Bool
|
|
||||||
|
|
||||||
@State private var viewTask: Task<Void, Never>?
|
var bookmarks: FetchedResults<Bookmark>
|
||||||
@State private var bookmarkPredicate: NSPredicate?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DynamicFetchRequest(
|
List {
|
||||||
predicate: bookmarkPredicate,
|
if !bookmarks.isEmpty {
|
||||||
sortDescriptors: [NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)]
|
ForEach(bookmarks, id: \.self) { bookmark in
|
||||||
) { (bookmarks: FetchedResults<Bookmark>) in
|
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
|
||||||
List {
|
}
|
||||||
if !bookmarks.isEmpty {
|
.onDelete { offsets in
|
||||||
ForEach(bookmarks, id: \.self) { bookmark in
|
for index in offsets {
|
||||||
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
|
if let bookmark = bookmarks[safe: index] {
|
||||||
}
|
PersistenceController.shared.delete(bookmark, context: backgroundContext)
|
||||||
.onDelete { offsets in
|
NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
|
||||||
for index in offsets {
|
|
||||||
if let bookmark = bookmarks[safe: index] {
|
|
||||||
PersistenceController.shared.delete(bookmark, context: backgroundContext)
|
|
||||||
NotificationCenter.default.post(name: .didDeleteBookmark, object: bookmark)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onMove { source, destination in
|
|
||||||
var changedBookmarks = bookmarks.map { $0 }
|
|
||||||
|
|
||||||
changedBookmarks.move(fromOffsets: source, toOffset: destination)
|
|
||||||
|
|
||||||
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
|
|
||||||
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
PersistenceController.shared.save()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.onMove { source, destination in
|
||||||
.listStyle(.insetGrouped)
|
var changedBookmarks = bookmarks.map { $0 }
|
||||||
.inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 15 : -25)
|
|
||||||
.backport.onAppear {
|
|
||||||
bookmarksEmpty = bookmarks.isEmpty
|
|
||||||
|
|
||||||
if debridManager.enabledDebrids.count > 0 {
|
changedBookmarks.move(fromOffsets: source, toOffset: destination)
|
||||||
viewTask = Task {
|
|
||||||
let magnets = bookmarks.compactMap {
|
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
|
||||||
if let magnetHash = $0.magnetHash {
|
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
|
||||||
return Magnet(hash: magnetHash, link: $0.magnetLink)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await debridManager.populateDebridIA(magnets)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PersistenceController.shared.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
|
||||||
viewTask?.cancel()
|
|
||||||
}
|
|
||||||
.onChange(of: bookmarks.count) { newCount in
|
|
||||||
bookmarksEmpty = newCount == 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.backport.onAppear {
|
.onAppear {
|
||||||
applyPredicate()
|
fetchPredicate()
|
||||||
}
|
}
|
||||||
.onChange(of: searchText) { _ in
|
.onChange(of: searchText) { _ in
|
||||||
applyPredicate()
|
fetchPredicate()
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 15)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await matchAgainstIA()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await matchAgainstIA()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyPredicate() {
|
func fetchPredicate() {
|
||||||
bookmarkPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
|
bookmarks.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAgainstIA() async {
|
||||||
|
if !debridManager.enabledDebrids.isEmpty {
|
||||||
|
let magnets = bookmarks.compactMap {
|
||||||
|
if let magnetHash = $0.magnetHash {
|
||||||
|
return Magnet(hash: magnetHash, link: $0.magnetLink)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await debridManager.populateDebridIA(magnets)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// CloudDownloadView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 6/6/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CloudDownloadView: View {
|
||||||
|
@EnvironmentObject var navModel: NavigationViewModel
|
||||||
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
@EnvironmentObject var pluginManager: PluginManager
|
||||||
|
|
||||||
|
@Store var debridSource: DebridSource
|
||||||
|
|
||||||
|
@Binding var searchText: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DisclosureGroup("Downloads") {
|
||||||
|
ForEach(debridSource.cloudDownloads.filter {
|
||||||
|
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
|
||||||
|
}, id: \.self) { cloudDownload in
|
||||||
|
Button(cloudDownload.fileName) {
|
||||||
|
navModel.resultFromCloud = true
|
||||||
|
navModel.selectedTitle = cloudDownload.fileName
|
||||||
|
var historyEntry = HistoryEntryJson(
|
||||||
|
name: cloudDownload.fileName,
|
||||||
|
source: debridSource.id
|
||||||
|
)
|
||||||
|
|
||||||
|
debridManager.currentDebridTask = Task {
|
||||||
|
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: cloudDownload.link)
|
||||||
|
|
||||||
|
if !debridManager.downloadUrl.isEmpty {
|
||||||
|
historyEntry.url = debridManager.downloadUrl
|
||||||
|
PersistenceController.shared.createHistory(historyEntry, performSave: true)
|
||||||
|
|
||||||
|
pluginManager.runDefaultAction(
|
||||||
|
urlString: debridManager.downloadUrl,
|
||||||
|
navModel: navModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
||||||
|
.tint(.primary)
|
||||||
|
}
|
||||||
|
.onDelete { offsets in
|
||||||
|
for index in offsets {
|
||||||
|
if let cloudDownload = debridSource.cloudDownloads[safe: index] {
|
||||||
|
Task {
|
||||||
|
await debridManager.deleteCloudDownload(cloudDownload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,84 +1,93 @@
|
||||||
//
|
//
|
||||||
// AllDebridCloudView.swift
|
// CloudMagnetView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 1/5/23.
|
// Created by Brian Dashore on 6/6/24.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AllDebridCloudView: View {
|
struct CloudMagnetView: View {
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
@EnvironmentObject var navModel: NavigationViewModel
|
||||||
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
@EnvironmentObject var pluginManager: PluginManager
|
@EnvironmentObject var pluginManager: PluginManager
|
||||||
|
|
||||||
|
@Store var debridSource: DebridSource
|
||||||
|
|
||||||
@Binding var searchText: String
|
@Binding var searchText: String
|
||||||
|
|
||||||
@State private var viewTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DisclosureGroup("Magnets") {
|
DisclosureGroup("Magnets") {
|
||||||
ForEach(debridManager.allDebridCloudMagnets.filter {
|
ForEach(debridSource.cloudMagnets.filter {
|
||||||
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
|
searchText.isEmpty ? true : $0.fileName.lowercased().contains(searchText.lowercased())
|
||||||
}, id: \.id) { magnet in
|
}, id: \.self) { cloudMagnet in
|
||||||
Button {
|
Button {
|
||||||
if magnet.status == "Ready", !magnet.links.isEmpty {
|
if debridSource.cachedStatus.contains(cloudMagnet.status), !cloudMagnet.links.isEmpty {
|
||||||
navModel.resultFromCloud = true
|
navModel.resultFromCloud = true
|
||||||
navModel.selectedTitle = magnet.filename
|
navModel.selectedTitle = cloudMagnet.fileName
|
||||||
|
|
||||||
var historyInfo = HistoryEntryJson(
|
var historyInfo = HistoryEntryJson(
|
||||||
name: magnet.filename,
|
name: cloudMagnet.fileName,
|
||||||
source: DebridType.allDebrid.toString()
|
source: debridSource.id
|
||||||
)
|
)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if magnet.links.count == 1 {
|
let magnet = Magnet(hash: cloudMagnet.hash, link: nil)
|
||||||
if let lockedLink = magnet.links[safe: 0]?.link {
|
await debridManager.populateDebridIA([magnet])
|
||||||
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: lockedLink)
|
if debridManager.selectDebridResult(magnet: magnet) {
|
||||||
|
// Is this a batch?
|
||||||
|
|
||||||
|
if cloudMagnet.links.count == 1 {
|
||||||
|
await debridManager.fetchDebridDownload(magnet: magnet)
|
||||||
|
|
||||||
|
// Bump to batch
|
||||||
|
if debridManager.requiresUnrestrict {
|
||||||
|
navModel.selectedHistoryInfo = historyInfo
|
||||||
|
navModel.currentChoiceSheet = .batch
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
if !debridManager.downloadUrl.isEmpty {
|
||||||
historyInfo.url = debridManager.downloadUrl
|
historyInfo.url = debridManager.downloadUrl
|
||||||
PersistenceController.shared.createHistory(historyInfo, performSave: true)
|
PersistenceController.shared.createHistory(historyInfo, performSave: true)
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
pluginManager.runDefaultAction(
|
||||||
urlString: debridManager.downloadUrl,
|
urlString: debridManager.downloadUrl,
|
||||||
navModel: navModel
|
navModel: navModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
navModel.selectedMagnet = magnet
|
||||||
let magnet = Magnet(hash: magnet.hash, link: nil)
|
|
||||||
|
|
||||||
// Do not clear old IA values
|
|
||||||
await debridManager.populateDebridIA([magnet])
|
|
||||||
|
|
||||||
if debridManager.selectDebridResult(magnet: magnet) {
|
|
||||||
navModel.selectedHistoryInfo = historyInfo
|
navModel.selectedHistoryInfo = historyInfo
|
||||||
navModel.currentChoiceSheet = .batch
|
navModel.currentChoiceSheet = .batch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(magnet.filename)
|
Text(cloudMagnet.fileName)
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(4)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(magnet.status)
|
Text(cloudMagnet.status.capitalizingFirstLetter())
|
||||||
Spacer()
|
Spacer()
|
||||||
DebridLabelView(cloudLinks: magnet.links.map(\.link))
|
DebridLabelView(debridSource: debridSource, cloudLinks: cloudMagnet.links)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
||||||
.backport.tint(.black)
|
.tint(.primary)
|
||||||
}
|
}
|
||||||
.onDelete { offsets in
|
.onDelete { offsets in
|
||||||
for index in offsets {
|
for index in offsets {
|
||||||
if let magnet = debridManager.allDebridCloudMagnets[safe: index] {
|
if let cloudMagnet = debridSource.cloudMagnets[safe: index] {
|
||||||
Task {
|
Task {
|
||||||
await debridManager.deleteAdMagnet(magnetId: magnet.id)
|
await debridManager.deleteUserMagnet(cloudMagnet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
//
|
|
||||||
// PremiumizeCloudView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 1/2/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftUIX
|
|
||||||
|
|
||||||
struct PremiumizeCloudView: View {
|
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
|
||||||
@EnvironmentObject var pluginManager: PluginManager
|
|
||||||
|
|
||||||
@Binding var searchText: String
|
|
||||||
|
|
||||||
@State private var viewTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
DisclosureGroup("Items") {
|
|
||||||
ForEach(debridManager.premiumizeCloudItems.filter {
|
|
||||||
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
|
|
||||||
}, id: \.id) { item in
|
|
||||||
Button(item.name) {
|
|
||||||
Task {
|
|
||||||
navModel.resultFromCloud = true
|
|
||||||
navModel.selectedTitle = item.name
|
|
||||||
|
|
||||||
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: item.id)
|
|
||||||
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
|
||||||
PersistenceController.shared.createHistory(
|
|
||||||
HistoryEntryJson(
|
|
||||||
name: item.name,
|
|
||||||
url: debridManager.downloadUrl,
|
|
||||||
source: DebridType.premiumize.toString()
|
|
||||||
),
|
|
||||||
performSave: true
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
|
||||||
.backport.tint(.black)
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for index in offsets {
|
|
||||||
if let item = debridManager.premiumizeCloudItems[safe: index] {
|
|
||||||
Task {
|
|
||||||
await debridManager.deletePmItem(id: item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
//
|
|
||||||
// RealDebridCloudView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 12/31/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct RealDebridCloudView: View {
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
|
||||||
@EnvironmentObject var pluginManager: PluginManager
|
|
||||||
|
|
||||||
@Binding var searchText: String
|
|
||||||
|
|
||||||
@State private var viewTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
DisclosureGroup("Downloads") {
|
|
||||||
ForEach(debridManager.realDebridCloudDownloads.filter {
|
|
||||||
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
|
|
||||||
}, id: \.self) { downloadResponse in
|
|
||||||
Button(downloadResponse.filename) {
|
|
||||||
navModel.resultFromCloud = true
|
|
||||||
navModel.selectedTitle = downloadResponse.filename
|
|
||||||
debridManager.downloadUrl = downloadResponse.download
|
|
||||||
|
|
||||||
PersistenceController.shared.createHistory(
|
|
||||||
HistoryEntryJson(
|
|
||||||
name: downloadResponse.filename,
|
|
||||||
url: downloadResponse.download,
|
|
||||||
source: DebridType.realDebrid.toString()
|
|
||||||
),
|
|
||||||
performSave: true
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.backport.tint(.primary)
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for index in offsets {
|
|
||||||
if let downloadResponse = debridManager.realDebridCloudDownloads[safe: index] {
|
|
||||||
Task {
|
|
||||||
await debridManager.deleteRdDownload(downloadID: downloadResponse.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisclosureGroup("Torrents") {
|
|
||||||
ForEach(debridManager.realDebridCloudTorrents.filter {
|
|
||||||
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
|
|
||||||
}, id: \.self) { torrentResponse in
|
|
||||||
Button {
|
|
||||||
if torrentResponse.status == "downloaded", !torrentResponse.links.isEmpty {
|
|
||||||
navModel.resultFromCloud = true
|
|
||||||
navModel.selectedTitle = torrentResponse.filename
|
|
||||||
|
|
||||||
var historyInfo = HistoryEntryJson(
|
|
||||||
name: torrentResponse.filename,
|
|
||||||
source: DebridType.realDebrid.toString()
|
|
||||||
)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
if torrentResponse.links.count == 1 {
|
|
||||||
if let torrentLink = torrentResponse.links[safe: 0] {
|
|
||||||
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink)
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
|
||||||
historyInfo.url = debridManager.downloadUrl
|
|
||||||
PersistenceController.shared.createHistory(historyInfo, performSave: true)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let magnet = Magnet(hash: torrentResponse.hash, link: nil)
|
|
||||||
|
|
||||||
// Do not clear old IA values
|
|
||||||
await debridManager.populateDebridIA([magnet])
|
|
||||||
|
|
||||||
if debridManager.selectDebridResult(magnet: magnet) {
|
|
||||||
navModel.selectedHistoryInfo = historyInfo
|
|
||||||
navModel.currentChoiceSheet = .batch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text(torrentResponse.filename)
|
|
||||||
.font(.callout)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.lineLimit(4)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text(torrentResponse.status.capitalizingFirstLetter())
|
|
||||||
Spacer()
|
|
||||||
DebridLabelView(cloudLinks: torrentResponse.links)
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
|
||||||
.backport.tint(.primary)
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for index in offsets {
|
|
||||||
if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] {
|
|
||||||
Task {
|
|
||||||
await debridManager.deleteRdTorrent(torrentID: torrentResponse.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,37 +10,25 @@ import SwiftUI
|
||||||
struct DebridCloudView: View {
|
struct DebridCloudView: View {
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
|
||||||
@Binding var searchText: String
|
@Store var debridSource: DebridSource
|
||||||
|
|
||||||
@State private var viewTask: Task<Void, Never>?
|
@Binding var searchText: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
switch debridManager.selectedDebridType {
|
CloudDownloadView(debridSource: debridSource, searchText: $searchText)
|
||||||
case .realDebrid:
|
CloudMagnetView(debridSource: debridSource, searchText: $searchText)
|
||||||
RealDebridCloudView(searchText: $searchText)
|
|
||||||
case .premiumize:
|
|
||||||
PremiumizeCloudView(searchText: $searchText)
|
|
||||||
case .allDebrid:
|
|
||||||
AllDebridCloudView(searchText: $searchText)
|
|
||||||
case .none:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.backport.onAppear {
|
.task {
|
||||||
viewTask = Task {
|
await debridManager.fetchDebridCloud()
|
||||||
await debridManager.fetchDebridCloud()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.refreshable {
|
||||||
viewTask?.cancel()
|
await debridManager.fetchDebridCloud(bypassTTL: true)
|
||||||
}
|
}
|
||||||
.onChange(of: debridManager.selectedDebridType) { newType in
|
.onChange(of: debridManager.selectedDebridSource?.id) { newType in
|
||||||
viewTask?.cancel()
|
|
||||||
|
|
||||||
if newType != nil {
|
if newType != nil {
|
||||||
viewTask = Task {
|
Task {
|
||||||
await debridManager.fetchDebridCloud()
|
await debridManager.fetchDebridCloud()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,26 +16,27 @@ struct HistoryActionsView: View {
|
||||||
Button("Clear") {
|
Button("Clear") {
|
||||||
showActionSheet.toggle()
|
showActionSheet.toggle()
|
||||||
}
|
}
|
||||||
.backport.tint(.red)
|
.tint(.red)
|
||||||
.backport.confirmationDialog(
|
.confirmationDialog(
|
||||||
|
"Clear watch history",
|
||||||
isPresented: $showActionSheet,
|
isPresented: $showActionSheet,
|
||||||
title: "Clear watch history",
|
titleVisibility: .visible
|
||||||
message: "This is an irreversible action!",
|
) {
|
||||||
buttons: [
|
Button("Past day", role: .destructive) {
|
||||||
AlertButton("Past day", role: .destructive) {
|
deleteHistory(.day)
|
||||||
deleteHistory(.day)
|
}
|
||||||
},
|
Button("Past week", role: .destructive) {
|
||||||
AlertButton("Past week", role: .destructive) {
|
deleteHistory(.week)
|
||||||
deleteHistory(.week)
|
}
|
||||||
},
|
Button("Past month", role: .destructive) {
|
||||||
AlertButton("Past month", role: .destructive) {
|
deleteHistory(.month)
|
||||||
deleteHistory(.month)
|
}
|
||||||
},
|
Button("All time", role: .destructive) {
|
||||||
AlertButton("All time", role: .destructive) {
|
deleteHistory(.allTime)
|
||||||
deleteHistory(.allTime)
|
}
|
||||||
}
|
} message: {
|
||||||
]
|
Text("This is an irreversible action!")
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteHistory(_ deleteRange: HistoryDeleteRange) {
|
func deleteHistory(_ deleteRange: HistoryDeleteRange) {
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ struct HistoryButtonView: View {
|
||||||
}
|
}
|
||||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
||||||
}
|
}
|
||||||
.backport.tint(.primary)
|
.tint(.primary)
|
||||||
.disableInteraction(navModel.currentChoiceSheet != nil)
|
.disableInteraction(navModel.currentChoiceSheet != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,29 +17,24 @@ struct HistoryView: View {
|
||||||
]
|
]
|
||||||
) var history: FetchedResults<History>
|
) var history: FetchedResults<History>
|
||||||
|
|
||||||
|
var allHistoryEntries: FetchedResults<HistoryEntry>
|
||||||
|
|
||||||
@Binding var searchText: String
|
@Binding var searchText: String
|
||||||
@Binding var historyEmpty: Bool
|
|
||||||
|
|
||||||
@State private var historyPredicate: NSPredicate?
|
@State private var historyPredicate: NSPredicate?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DynamicFetchRequest(predicate: historyPredicate) { (allEntries: FetchedResults<HistoryEntry>) in
|
List {
|
||||||
List {
|
if !history.isEmpty {
|
||||||
if !history.isEmpty {
|
ForEach(groupedHistory(history), id: \.self) { historyGroup in
|
||||||
ForEach(groupedHistory(history), id: \.self) { historyGroup in
|
HistorySectionView(allEntries: allHistoryEntries, historyGroup: historyGroup)
|
||||||
HistorySectionView(allEntries: allEntries, historyGroup: historyGroup)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
}
|
}
|
||||||
.backport.onAppear {
|
.listStyle(.insetGrouped)
|
||||||
historyEmpty = history.isEmpty
|
.onAppear {
|
||||||
applyPredicate()
|
applyPredicate()
|
||||||
}
|
}
|
||||||
.onChange(of: history.count) { newCount in
|
|
||||||
historyEmpty = newCount == 0
|
|
||||||
}
|
|
||||||
.onChange(of: searchText) { _ in
|
.onChange(of: searchText) { _ in
|
||||||
applyPredicate()
|
applyPredicate()
|
||||||
}
|
}
|
||||||
|
|
@ -47,11 +42,11 @@ struct HistoryView: View {
|
||||||
|
|
||||||
func applyPredicate() {
|
func applyPredicate() {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
historyPredicate = nil
|
allHistoryEntries.nsPredicate = nil
|
||||||
} else {
|
} else {
|
||||||
let namePredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased())
|
let namePredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased())
|
||||||
let subNamePredicate = NSPredicate(format: "subName CONTAINS[cd] %@", searchText.lowercased())
|
let subNamePredicate = NSPredicate(format: "subName CONTAINS[cd] %@", searchText.lowercased())
|
||||||
historyPredicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, subNamePredicate])
|
allHistoryEntries.nsPredicate = NSCompoundPredicate(type: .or, subpredicates: [namePredicate, subNamePredicate])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +76,7 @@ struct HistorySectionView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if compareGroup(historyGroup) > 0 {
|
if compareGroup(historyGroup) > 0 {
|
||||||
Section(header: InlineHeader(formatter.string(from: historyGroup[0].date ?? Date()))) {
|
Section(formatter.string(from: historyGroup[0].date ?? Date())) {
|
||||||
ForEach(historyGroup, id: \.self) { history in
|
ForEach(historyGroup, id: \.self) { history in
|
||||||
ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in
|
ForEach(history.entryArray.filter { allEntries.contains($0) }, id: \.self) { entry in
|
||||||
HistoryButtonView(entry: entry)
|
HistoryButtonView(entry: entry)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// InstalledSourceButtonView.swift
|
// InstalledPluginButtonView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 8/5/22.
|
// Created by Brian Dashore on 8/5/22.
|
||||||
|
|
@ -10,10 +10,11 @@ import SwiftUI
|
||||||
struct InstalledPluginButtonView<P: Plugin>: View {
|
struct InstalledPluginButtonView<P: Plugin>: View {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
|
||||||
|
|
||||||
@ObservedObject var installedPlugin: P
|
@ObservedObject var installedPlugin: P
|
||||||
|
|
||||||
|
@Binding var showPluginOptions: Bool
|
||||||
|
@Binding var selectedPlugin: P?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Toggle(isOn: Binding<Bool>(
|
Toggle(isOn: Binding<Bool>(
|
||||||
get: { installedPlugin.enabled },
|
get: { installedPlugin.enabled },
|
||||||
|
|
@ -24,7 +25,7 @@ struct InstalledPluginButtonView<P: Plugin>: View {
|
||||||
)) {
|
)) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack(spacing: 5) {
|
||||||
Text(installedPlugin.name)
|
Text(installedPlugin.name)
|
||||||
Text("v\(installedPlugin.version)")
|
Text("v\(installedPlugin.version)")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -32,39 +33,30 @@ struct InstalledPluginButtonView<P: Plugin>: View {
|
||||||
|
|
||||||
Text("by \(installedPlugin.author)")
|
Text("by \(installedPlugin.author)")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let tags = installedPlugin.getTags(), !tags.isEmpty {
|
let tags = installedPlugin.getTags()
|
||||||
|
if !tags.isEmpty {
|
||||||
PluginTagsView(tags: tags)
|
PluginTagsView(tags: tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if let installedSource = installedPlugin as? Source {
|
Button {
|
||||||
Button {
|
selectedPlugin = installedPlugin
|
||||||
navModel.selectedSource = installedSource
|
showPluginOptions.toggle()
|
||||||
navModel.showSourceSettings.toggle()
|
} label: {
|
||||||
} label: {
|
Text("Options")
|
||||||
Text("Settings")
|
Image(systemName: "gear")
|
||||||
Image(systemName: "gear")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if #available(iOS 15.0, *) {
|
Button(role: .destructive) {
|
||||||
Button(role: .destructive) {
|
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
|
||||||
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
|
} label: {
|
||||||
} label: {
|
Text("Remove")
|
||||||
Text("Remove")
|
Image(systemName: "trash")
|
||||||
Image(systemName: "trash")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
PersistenceController.shared.delete(installedPlugin, context: backgroundContext)
|
|
||||||
} label: {
|
|
||||||
Text("Remove")
|
|
||||||
Image(systemName: "trash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// SourceCatalogButtonView.swift
|
// PluginCatalogButtonView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 8/5/22.
|
// Created by Brian Dashore on 8/5/22.
|
||||||
|
|
@ -8,44 +8,62 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PluginCatalogButtonView<PJ: PluginJson>: View {
|
struct PluginCatalogButtonView<PJ: PluginJson>: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@EnvironmentObject var pluginManager: PluginManager
|
@EnvironmentObject var pluginManager: PluginManager
|
||||||
|
|
||||||
let availablePlugin: PJ
|
let availablePlugin: PJ
|
||||||
let doUpsert: Bool
|
let needsUpdate: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack(spacing: 5) {
|
||||||
Text(availablePlugin.name)
|
Text(availablePlugin.name)
|
||||||
Text("v\(availablePlugin.version)")
|
Text("v\(availablePlugin.version)")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("by \(availablePlugin.author ?? "No author")")
|
Group {
|
||||||
.foregroundColor(.secondary)
|
Text("by \(availablePlugin.author ?? "No author")")
|
||||||
|
|
||||||
|
Text(availablePlugin.listName.map { "from \($0)" } ?? "an unknown list")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let tags = availablePlugin.getTags(), !tags.isEmpty {
|
let tags = availablePlugin.getTags()
|
||||||
|
if !tags.isEmpty {
|
||||||
PluginTagsView(tags: tags)
|
PluginTagsView(tags: tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("Install") {
|
Button(needsUpdate ? "UPDATE" : "INSTALL") {
|
||||||
Task {
|
Task {
|
||||||
if let availableSource = availablePlugin as? SourceJson {
|
if let availableSource = availablePlugin as? SourceJson {
|
||||||
await pluginManager.installSource(sourceJson: availableSource, doUpsert: doUpsert)
|
await pluginManager.installSource(sourceJson: availableSource, doUpsert: needsUpdate)
|
||||||
} else if let availableAction = availablePlugin as? ActionJson {
|
} else if let availableAction = availablePlugin as? ActionJson {
|
||||||
await pluginManager.installAction(actionJson: availableAction, doUpsert: doUpsert)
|
await pluginManager.installAction(actionJson: availableAction, doUpsert: needsUpdate)
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.font(
|
||||||
|
.footnote
|
||||||
|
.weight(.bold)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(colorScheme == .light ? Color(uiColor: .secondarySystemBackground) : Color(uiColor: .tertiarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
//
|
||||||
|
// PluginInfoAboutView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PluginInfoAboutView<P: Plugin>: View {
|
||||||
|
@ObservedObject var selectedPlugin: P
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Description") {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if let pluginAbout = selectedPlugin.about {
|
||||||
|
if pluginAbout.last == "\n" {
|
||||||
|
Text(pluginAbout.dropLast())
|
||||||
|
} else {
|
||||||
|
Text(pluginAbout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pluginWebsite = selectedPlugin.website {
|
||||||
|
Link("Website", destination: URL(string: pluginWebsite) ?? URL(string: "https://kingbri.dev/ferrite")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// PluginInfoMetaView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 4/2/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PluginInfoMetaView<P: Plugin>: View {
|
||||||
|
@ObservedObject var selectedPlugin: P
|
||||||
|
|
||||||
|
@FetchRequest(
|
||||||
|
entity: PluginList.entity(),
|
||||||
|
sortDescriptors: []
|
||||||
|
) var pluginLists: FetchedResults<PluginList>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Metadata") {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text(selectedPlugin.name)
|
||||||
|
|
||||||
|
Text("v\(selectedPlugin.version)")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("by \(selectedPlugin.author)")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
Text("ID: \(selectedPlugin.id)")
|
||||||
|
|
||||||
|
if let pluginList = pluginLists.first(where: { $0.id == selectedPlugin.listId }) {
|
||||||
|
Text("List: \(pluginList.name)")
|
||||||
|
Text("List ID: \(pluginList.id.uuidString)")
|
||||||
|
} else {
|
||||||
|
Text("No plugin list found. This source should be removed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = selectedPlugin.getTags()
|
||||||
|
if !tags.isEmpty {
|
||||||
|
PluginTagsView(tags: tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,75 +12,82 @@ struct PluginAggregateView<P: Plugin, PJ: PluginJson>: View {
|
||||||
|
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
|
@FetchRequest(
|
||||||
|
entity: PluginList.entity(),
|
||||||
|
sortDescriptors: []
|
||||||
|
) var pluginLists: FetchedResults<PluginList>
|
||||||
|
|
||||||
|
var installedPlugins: FetchedResults<P>
|
||||||
|
|
||||||
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
|
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
|
||||||
|
|
||||||
@Binding var searchText: String
|
@Binding var searchText: String
|
||||||
@Binding var pluginsEmpty: Bool
|
|
||||||
|
|
||||||
@State private var isEditingSearch = false
|
@State private var isEditingSearch = false
|
||||||
@State private var isSearching = false
|
@State private var isSearching = false
|
||||||
|
|
||||||
@State private var sourcePredicate: NSPredicate?
|
@State private var sourcePredicate: NSPredicate?
|
||||||
|
|
||||||
|
@State private var showPluginOptions = false
|
||||||
|
@State private var selectedPlugin: P?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DynamicFetchRequest(predicate: sourcePredicate) { (installedPlugins: FetchedResults<P>) in
|
List {
|
||||||
List {
|
let filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins(
|
||||||
if
|
forType: PJ.self,
|
||||||
let filteredUpdatedPlugins = pluginManager.fetchUpdatedPlugins(
|
installedPlugins: installedPlugins,
|
||||||
forType: PJ.self,
|
searchText: searchText
|
||||||
installedPlugins: installedPlugins,
|
)
|
||||||
searchText: searchText
|
if !filteredUpdatedPlugins.isEmpty {
|
||||||
),
|
Section("Updates") {
|
||||||
!filteredUpdatedPlugins.isEmpty
|
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
|
||||||
{
|
PluginCatalogButtonView(availablePlugin: updatedPlugin, needsUpdate: true)
|
||||||
Section(header: InlineHeader("Updates")) {
|
|
||||||
ForEach(filteredUpdatedPlugins, id: \.self) { (updatedPlugin: PJ) in
|
|
||||||
PluginCatalogButtonView(availablePlugin: updatedPlugin, doUpsert: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !installedPlugins.isEmpty {
|
if !installedPlugins.isEmpty {
|
||||||
Section(header: InlineHeader("Installed")) {
|
Section("Installed") {
|
||||||
ForEach(installedPlugins, id: \.self) { source in
|
ForEach(installedPlugins, id: \.self) { installedPlugin in
|
||||||
InstalledPluginButtonView(installedPlugin: source)
|
InstalledPluginButtonView(
|
||||||
}
|
installedPlugin: installedPlugin,
|
||||||
|
showPluginOptions: $showPluginOptions,
|
||||||
|
selectedPlugin: $selectedPlugin
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if
|
let filteredAvailablePlugins = pluginManager.fetchFilteredPlugins(
|
||||||
let filteredAvailablePlugins = pluginManager.fetchFilteredPlugins(
|
forType: PJ.self,
|
||||||
forType: PJ.self,
|
installedPlugins: installedPlugins,
|
||||||
installedPlugins: installedPlugins,
|
searchText: searchText
|
||||||
searchText: searchText
|
)
|
||||||
),
|
if !filteredAvailablePlugins.isEmpty {
|
||||||
!filteredAvailablePlugins.isEmpty
|
Section("Catalog") {
|
||||||
{
|
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
|
||||||
Section(header: InlineHeader("Catalog")) {
|
PluginCatalogButtonView(availablePlugin: availablePlugin, needsUpdate: false)
|
||||||
ForEach(filteredAvailablePlugins, id: \.self) { availablePlugin in
|
|
||||||
PluginCatalogButtonView(availablePlugin: availablePlugin, doUpsert: false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.inlinedList(inset: 0)
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.sheet(isPresented: $navModel.showSourceSettings) {
|
.onAppear {
|
||||||
if String(describing: P.self) == "Source" {
|
fetchPredicate()
|
||||||
SourceSettingsView()
|
}
|
||||||
.environmentObject(navModel)
|
.onChange(of: searchText) { _ in
|
||||||
}
|
fetchPredicate()
|
||||||
}
|
}
|
||||||
.backport.onAppear {
|
// Alternatively, place the sheet in the parent view
|
||||||
pluginsEmpty = installedPlugins.isEmpty
|
.refreshable {
|
||||||
}
|
await pluginManager.fetchPluginsFromUrl()
|
||||||
.onChange(of: searchText) { _ in
|
}
|
||||||
sourcePredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
|
.sheet(isPresented: $showPluginOptions) {
|
||||||
}
|
PluginInfoView(selectedPlugin: $selectedPlugin)
|
||||||
.onChange(of: installedPlugins.count) { newCount in
|
|
||||||
pluginsEmpty = newCount == 0
|
|
||||||
}
|
|
||||||
.id(UUID())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchPredicate() {
|
||||||
|
installedPlugins.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "name CONTAINS[cd] %@", searchText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
Ferrite/Views/ComponentViews/Plugin/PluginInfoView.swift
Normal file
44
Ferrite/Views/ComponentViews/Plugin/PluginInfoView.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// PluginInfoView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PluginInfoView<P: Plugin>: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@Binding var selectedPlugin: P?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if let selectedPlugin {
|
||||||
|
PluginInfoMetaView(selectedPlugin: selectedPlugin)
|
||||||
|
|
||||||
|
if selectedPlugin.about != nil || selectedPlugin.website != nil {
|
||||||
|
PluginInfoAboutView(selectedPlugin: selectedPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selectedSource = selectedPlugin as? Source {
|
||||||
|
SourceSettingsView(selectedSource: selectedSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.onDisappear {
|
||||||
|
PersistenceController.shared.save()
|
||||||
|
}
|
||||||
|
.navigationTitle("Plugin Options")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// PluginTagView.swift
|
// PluginTagsView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 2/7/23.
|
// Created by Brian Dashore on 2/7/23.
|
||||||
|
|
@ -14,7 +14,7 @@ struct PluginTagsView: View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(tags, id: \.self) { tag in
|
ForEach(tags, id: \.self) { tag in
|
||||||
Tag(name: tag.name, color: tag.colorHex.map { Color(hexadecimal: $0) })
|
Tag(name: tag.name, color: tag.colorHex.map { Color(hex: $0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// SourceSettingsApiView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourceSettingsApiView: View {
|
||||||
|
@ObservedObject var selectedSourceApi: SourceApi
|
||||||
|
|
||||||
|
@State private var tempClientId: String = ""
|
||||||
|
@State private var tempClientSecret: String = ""
|
||||||
|
|
||||||
|
enum Field {
|
||||||
|
case secure, plain
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(
|
||||||
|
header: Text("API credentials"),
|
||||||
|
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.")
|
||||||
|
) {
|
||||||
|
if let clientId = selectedSourceApi.clientId, clientId.dynamic {
|
||||||
|
TextField("Client ID", text: $tempClientId, onEditingChanged: { isFocused in
|
||||||
|
if !isFocused {
|
||||||
|
clientId.value = tempClientId
|
||||||
|
clientId.timeStamp = Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.onAppear {
|
||||||
|
tempClientId = clientId.value ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let clientSecret = selectedSourceApi.clientSecret, clientSecret.dynamic {
|
||||||
|
TextField("Token", text: $tempClientSecret, onEditingChanged: { isFocused in
|
||||||
|
if !isFocused {
|
||||||
|
clientSecret.value = tempClientSecret
|
||||||
|
clientSecret.timeStamp = Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.onAppear {
|
||||||
|
tempClientSecret = clientSecret.value ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
//
|
||||||
|
// SourceSettingsBaseUrlView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourceSettingsBaseUrlView: View {
|
||||||
|
@ObservedObject var selectedSource: Source
|
||||||
|
|
||||||
|
@State private var tempSite: String = ""
|
||||||
|
var body: some View {
|
||||||
|
Section(
|
||||||
|
header: Text("Base URL"),
|
||||||
|
footer: Text("Enter the base URL of your server.")
|
||||||
|
) {
|
||||||
|
TextField("https://...", text: $tempSite, onEditingChanged: { isFocused in
|
||||||
|
if !isFocused {
|
||||||
|
if tempSite.last == "/" {
|
||||||
|
selectedSource.website = String(tempSite.dropLast())
|
||||||
|
} else {
|
||||||
|
selectedSource.website = tempSite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.onAppear {
|
||||||
|
tempSite = selectedSource.website ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// SourceSettingsMethodView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 3/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourceSettingsMethodView: View {
|
||||||
|
@ObservedObject var selectedSource: Source
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Fetch method") {
|
||||||
|
Picker("", selection: $selectedSource.preferredParser) {
|
||||||
|
if selectedSource.jsonParser != nil {
|
||||||
|
Text("Website API").tag(SourcePreferredParser.siteApi.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedSource.rssParser != nil {
|
||||||
|
Text("RSS").tag(SourcePreferredParser.rss.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedSource.htmlParser != nil {
|
||||||
|
Text("Web scraping").tag(SourcePreferredParser.scraping.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,207 +8,19 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SourceSettingsView: View {
|
struct SourceSettingsView: View {
|
||||||
@Environment(\.presentationMode) var presentationMode
|
|
||||||
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
|
||||||
|
|
||||||
@FetchRequest(
|
|
||||||
entity: PluginList.entity(),
|
|
||||||
sortDescriptors: []
|
|
||||||
) var pluginLists: FetchedResults<PluginList>
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavView {
|
|
||||||
List {
|
|
||||||
if let selectedSource = navModel.selectedSource {
|
|
||||||
Section(header: InlineHeader("Info")) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
HStack {
|
|
||||||
Text(selectedSource.name)
|
|
||||||
|
|
||||||
Text("v\(selectedSource.version)")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("by \(selectedSource.author)")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Group {
|
|
||||||
Text("ID: \(selectedSource.id)")
|
|
||||||
|
|
||||||
if let pluginList = pluginLists.first(where: { $0.id == selectedSource.listId })
|
|
||||||
{
|
|
||||||
Text("List: \(pluginList.name)")
|
|
||||||
Text("List ID: \(pluginList.id.uuidString)")
|
|
||||||
} else {
|
|
||||||
Text("No plugin list found. This source should be removed.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tags = selectedSource.getTags(), !tags.isEmpty {
|
|
||||||
PluginTagsView(tags: tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedSource.dynamicBaseUrl {
|
|
||||||
SourceSettingsBaseUrlView(selectedSource: selectedSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sourceApi = selectedSource.api,
|
|
||||||
sourceApi.clientId?.dynamic ?? false || sourceApi.clientSecret?.dynamic ?? false
|
|
||||||
{
|
|
||||||
SourceSettingsApiView(selectedSourceApi: sourceApi)
|
|
||||||
}
|
|
||||||
|
|
||||||
SourceSettingsMethodView(selectedSource: selectedSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
.onDisappear {
|
|
||||||
PersistenceController.shared.save()
|
|
||||||
}
|
|
||||||
.navigationTitle("Source Settings")
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Done") {
|
|
||||||
presentationMode.wrappedValue.dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SourceSettingsBaseUrlView: View {
|
|
||||||
@ObservedObject var selectedSource: Source
|
|
||||||
|
|
||||||
@State private var tempBaseUrl: String = ""
|
|
||||||
var body: some View {
|
|
||||||
Section(
|
|
||||||
header: InlineHeader("Base URL"),
|
|
||||||
footer: Text("Enter the base URL of your server.")
|
|
||||||
) {
|
|
||||||
TextField("https://...", text: $tempBaseUrl, onEditingChanged: { isFocused in
|
|
||||||
if !isFocused {
|
|
||||||
if tempBaseUrl.last == "/" {
|
|
||||||
selectedSource.baseUrl = String(tempBaseUrl.dropLast())
|
|
||||||
} else {
|
|
||||||
selectedSource.baseUrl = tempBaseUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.backport.onAppear {
|
|
||||||
tempBaseUrl = selectedSource.baseUrl ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SourceSettingsApiView: View {
|
|
||||||
@ObservedObject var selectedSourceApi: SourceApi
|
|
||||||
|
|
||||||
@State private var tempClientId: String = ""
|
|
||||||
@State private var tempClientSecret: String = ""
|
|
||||||
|
|
||||||
enum Field {
|
|
||||||
case secure, plain
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section(
|
|
||||||
header: InlineHeader("API credentials"),
|
|
||||||
footer: Text("Grab the required API credentials from the website. A client secret can be an API token.")
|
|
||||||
) {
|
|
||||||
if let clientId = selectedSourceApi.clientId, clientId.dynamic {
|
|
||||||
TextField("Client ID", text: $tempClientId, onEditingChanged: { isFocused in
|
|
||||||
if !isFocused {
|
|
||||||
clientId.value = tempClientId
|
|
||||||
clientId.timeStamp = Date()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.backport.onAppear {
|
|
||||||
tempClientId = clientId.value ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let clientSecret = selectedSourceApi.clientSecret, clientSecret.dynamic {
|
|
||||||
TextField("Token", text: $tempClientSecret, onEditingChanged: { isFocused in
|
|
||||||
if !isFocused {
|
|
||||||
clientSecret.value = tempClientSecret
|
|
||||||
clientSecret.timeStamp = Date()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.backport.onAppear {
|
|
||||||
tempClientSecret = clientSecret.value ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SourceSettingsMethodView: View {
|
|
||||||
@ObservedObject var selectedSource: Source
|
@ObservedObject var selectedSource: Source
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section(header: InlineHeader("Fetch method")) {
|
if selectedSource.dynamicWebsite {
|
||||||
if selectedSource.jsonParser != nil {
|
SourceSettingsBaseUrlView(selectedSource: selectedSource)
|
||||||
Button {
|
|
||||||
selectedSource.preferredParser = SourcePreferredParser.siteApi.rawValue
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Website API")
|
|
||||||
Spacer()
|
|
||||||
if SourcePreferredParser.siteApi.rawValue == selectedSource.preferredParser {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedSource.rssParser != nil {
|
|
||||||
Button {
|
|
||||||
selectedSource.preferredParser = SourcePreferredParser.rss.rawValue
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("RSS")
|
|
||||||
Spacer()
|
|
||||||
if SourcePreferredParser.rss.rawValue == selectedSource.preferredParser {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedSource.htmlParser != nil {
|
|
||||||
Button {
|
|
||||||
selectedSource.preferredParser = SourcePreferredParser.scraping.rawValue
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Web scraping")
|
|
||||||
Spacer()
|
|
||||||
if SourcePreferredParser.scraping.rawValue == selectedSource.preferredParser {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.backport.tint(.primary)
|
|
||||||
|
if let sourceApi = selectedSource.api,
|
||||||
|
sourceApi.clientId?.dynamic ?? false || sourceApi.clientSecret?.dynamic ?? false
|
||||||
|
{
|
||||||
|
SourceSettingsApiView(selectedSourceApi: sourceApi)
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceSettingsMethodView(selectedSource: selectedSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue