Compare commits
75 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d7bc9b314 | ||
|
|
25bff02875 | ||
|
|
20dd00fa85 | ||
|
|
f9d2f38329 | ||
|
|
a7e20f30e6 | ||
|
|
ecf92239d2 | ||
|
|
dd54ec027b | ||
|
|
84357ea2c5 | ||
|
|
4fb5f77718 | ||
|
|
e5a872e09f | ||
|
|
1d6ac13e84 | ||
|
|
896efed663 | ||
|
|
a3463948ea | ||
|
|
215cd0feec | ||
|
|
9213b8627b | ||
|
|
6b40bb3ea2 | ||
|
|
dbf12c0a79 | ||
|
|
70b628b608 | ||
|
|
78f2aff25b | ||
|
|
489da8e82e | ||
|
|
078e48d316 | ||
|
|
646c22c9be | ||
|
|
d512d8b88d | ||
|
|
d0728e1a9b | ||
|
|
89367b72da | ||
|
|
c5a08cc725 | ||
|
|
0d39fd481a | ||
|
|
5223c60acd | ||
|
|
80e966512a | ||
|
|
8f7fe94d21 | ||
|
|
3ef041f889 | ||
|
|
e49e37af36 | ||
|
|
d6d731102c | ||
|
|
4beb953596 | ||
|
|
e1eca593f3 | ||
|
|
9b4f31daac | ||
|
|
24e39f9fba | ||
|
|
904b5a74b5 | ||
|
|
ecdd0199f6 | ||
|
|
3b771e5deb | ||
|
|
d8107cb5b6 | ||
|
|
42e202b207 | ||
|
|
afceea7bfb | ||
|
|
4ae1966934 | ||
|
|
796cc65016 | ||
|
|
90f44348b8 | ||
|
|
6192ef1ede | ||
|
|
973fbb4099 | ||
|
|
243a16e3c4 | ||
|
|
44a90b77eb | ||
|
|
59ac719d9a | ||
|
|
02636e0bda | ||
|
|
40b323bd56 | ||
|
|
91f124130c | ||
|
|
ec8455c08d | ||
|
|
0c3648120d | ||
|
|
9650e6deec | ||
|
|
07731e7b00 | ||
|
|
b80f8900b7 | ||
|
|
cf0c5a30f7 | ||
|
|
96a6722e65 | ||
|
|
0caf8a8120 | ||
|
|
273403b711 | ||
|
|
f9ecc746a1 | ||
|
|
c641fdf300 | ||
|
|
f902142fee | ||
|
|
37ef64224e | ||
|
|
9e306eff1e | ||
|
|
37450ef979 | ||
|
|
0fe1cbc888 | ||
|
|
2e746320cf | ||
|
|
13a40a237a | ||
|
|
46e0687bd7 | ||
|
|
b8978fd29c | ||
|
|
cc550dd208 |
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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,7 +23,6 @@
|
||||||
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 */; };
|
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 */; };
|
||||||
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 */; };
|
||||||
|
|
@ -54,7 +56,6 @@
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
|
|
@ -96,10 +97,14 @@
|
||||||
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
|
0C84FCE729E4B61A00B0DFE4 /* FilterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */; };
|
||||||
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C84FCE829E5ADEF00B0DFE4 /* FilterAmountLabelView.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 */; };
|
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35129CE287E008A83AD /* PluginInfoView.swift */; };
|
||||||
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
|
0C8DC35429CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35329CE2AB5008A83AD /* SourceSettingsBaseUrlView.swift */; };
|
||||||
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
|
0C8DC35629CE2ABF008A83AD /* SourceSettingsApiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35529CE2ABF008A83AD /* SourceSettingsApiView.swift */; };
|
||||||
0C8DC35829CE2ACA008A83AD /* SourceSettingsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8DC35729CE2ACA008A83AD /* SourceSettingsMethodView.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 */; };
|
||||||
|
|
@ -129,12 +134,13 @@
|
||||||
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
||||||
|
|
@ -142,6 +148,7 @@
|
||||||
0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.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 */; };
|
0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; };
|
||||||
0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.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 */; };
|
||||||
|
|
@ -153,6 +160,10 @@
|
||||||
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.swift */; };
|
0CE1C4182981E8D700418F20 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1C4172981E8D700418F20 /* Plugin.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 */; };
|
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF2C0A429D1EBD400E716DD /* UIApplication.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
|
@ -162,6 +173,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>"; };
|
||||||
|
|
@ -170,7 +184,6 @@
|
||||||
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>"; };
|
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>"; };
|
||||||
|
|
@ -203,7 +216,6 @@
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
|
@ -241,10 +253,14 @@
|
||||||
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterModels.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
||||||
|
|
@ -274,12 +290,13 @@
|
||||||
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
||||||
|
|
@ -288,6 +305,7 @@
|
||||||
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>"; };
|
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>"; };
|
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>"; };
|
||||||
|
|
@ -298,6 +316,10 @@
|
||||||
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>"; };
|
||||||
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>"; };
|
0CF2C0A429D1EBD400E716DD /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
|
@ -377,6 +399,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>";
|
||||||
|
|
@ -388,6 +412,7 @@
|
||||||
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
|
0C6C7C9C29315292002DF910 /* AllDebridModels.swift */,
|
||||||
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
|
0C7ED14028D61BBA009E29AD /* BackupModels.swift */,
|
||||||
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
|
0C0755C7293425B500ECA142 /* DebridManagerModels.swift */,
|
||||||
|
0CF1ABE12C0C3D2F009F6C26 /* DebridModels.swift */,
|
||||||
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */,
|
0C84FCE629E4B61A00B0DFE4 /* FilterModels.swift */,
|
||||||
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
|
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
|
||||||
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
|
0C422E7F293542F300486D65 /* PremiumizeModels.swift */,
|
||||||
|
|
@ -397,6 +422,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>";
|
||||||
|
|
@ -404,9 +431,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>";
|
||||||
|
|
@ -449,6 +475,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>";
|
||||||
|
|
@ -489,6 +518,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0CE1C4172981E8D700418F20 /* Plugin.swift */,
|
0CE1C4172981E8D700418F20 /* Plugin.swift */,
|
||||||
|
0CF1ABDB2C0C04B2009F6C26 /* Debrid.swift */,
|
||||||
);
|
);
|
||||||
path = Protocols;
|
path = Protocols;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -527,6 +557,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>";
|
||||||
|
|
@ -646,6 +677,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>";
|
||||||
|
|
@ -738,7 +771,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;
|
||||||
|
|
@ -814,6 +847,7 @@
|
||||||
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 */,
|
||||||
|
|
@ -844,14 +878,15 @@
|
||||||
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
|
0C5005522992B6750064606A /* PluginTagsView.swift in Sources */,
|
||||||
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
|
0CB0AB5F29BD2A200015422C /* KodiServerView.swift in Sources */,
|
||||||
0CF2C0A529D1EBD400E716DD /* UIApplication.swift in Sources */,
|
0CF2C0A529D1EBD400E716DD /* UIApplication.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 */,
|
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
|
||||||
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
|
0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */,
|
||||||
|
|
@ -861,10 +896,10 @@
|
||||||
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 */,
|
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 */,
|
||||||
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
|
0C95D8D828A55B03005E22B3 /* DefaultActionPickerView.swift in Sources */,
|
||||||
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
|
0C44E2AF28D52E8A007711AE /* BackupsView.swift in Sources */,
|
||||||
|
|
@ -891,7 +926,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 */,
|
||||||
|
|
@ -912,16 +946,22 @@
|
||||||
0C84FCE929E5ADEF00B0DFE4 /* FilterAmountLabelView.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 */,
|
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 */,
|
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 */,
|
0C7075E429D374C50093DB2D /* Color.swift in Sources */,
|
||||||
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
|
0C8DC35229CE287E008A83AD /* PluginInfoView.swift in Sources */,
|
||||||
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
|
0C422E80293542F300486D65 /* PremiumizeModels.swift in Sources */,
|
||||||
|
|
@ -938,11 +978,15 @@
|
||||||
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 */,
|
||||||
|
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 */,
|
||||||
|
|
@ -960,6 +1004,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";
|
||||||
|
|
@ -992,6 +1037,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;
|
||||||
|
|
@ -1013,6 +1059,7 @@
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
@ -1020,6 +1067,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";
|
||||||
|
|
@ -1052,6 +1100,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;
|
||||||
|
|
@ -1066,6 +1115,7 @@
|
||||||
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;
|
||||||
|
|
@ -1076,10 +1126,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 = 17;
|
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;
|
||||||
|
|
@ -1095,7 +1146,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.7.0;
|
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;
|
||||||
|
|
@ -1111,10 +1162,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 = 17;
|
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;
|
||||||
|
|
@ -1130,7 +1182,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.7.0;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,8 +7,8 @@
|
||||||
|
|
||||||
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/Ferrite-iOS/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,7 +17,7 @@ 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/Ferrite-iOS/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,11 +7,11 @@
|
||||||
|
|
||||||
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?,
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
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,31 +110,30 @@ 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)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
// We don't care if this fails
|
// We don't care if this fails
|
||||||
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
let rawResponse = try? jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
|
||||||
|
|
||||||
// If there's a client ID from the response, end the task successfully
|
// If there's a client ID from the response, end the task successfully
|
||||||
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
|
||||||
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
|
await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId")
|
||||||
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret")
|
||||||
|
|
||||||
try await getTokens(deviceCode: deviceCode)
|
try await getApiTokens(deviceCode: deviceCode)
|
||||||
|
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -94,22 +142,17 @@ public class RealDebrid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw RDError.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 {
|
// Fetch all tokens for the user and store in FerriteKeychain.shared
|
||||||
throw error
|
func getApiTokens(deviceCode: String) async throws {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all tokens for the user and store in keychain
|
|
||||||
public func getTokens(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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTL: 5 minutes
|
// Fetch the user magnets to the latest version
|
||||||
availableHashes.append(
|
try await getUserMagnets()
|
||||||
RealDebrid.IA(
|
|
||||||
magnet: Magnet(hash: hash, link: nil),
|
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,
|
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
|
||||||
files: files,
|
files: []
|
||||||
batches: batches
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
availableHashes.append(
|
|
||||||
RealDebrid.IA(
|
|
||||||
magnet: Magnet(hash: hash, link: nil),
|
|
||||||
expiryTimeStamp: Date().timeIntervalSince1970 + 300
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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" {
|
|
||||||
throw RDError.EmptyTorrents
|
|
||||||
} else {
|
|
||||||
throw RDError.EmptyData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
return rawResponse
|
||||||
|
case "downloading", "queued":
|
||||||
|
throw DebridError.IsCaching
|
||||||
|
default:
|
||||||
|
throw DebridError.EmptyUserMagnets
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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,5 +1,5 @@
|
||||||
<?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="about" optional="YES" attributeType="String"/>
|
||||||
<attribute name="author" attributeType="String" defaultValueString=""/>
|
<attribute name="author" attributeType="String" defaultValueString=""/>
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
<relationship name="magnetHash" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="SourceMagnetHash" inverseName="parentHtmlParser" inverseEntity="SourceMagnetHash"/>
|
<relationship name="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"/>
|
||||||
|
|
@ -118,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"/>
|
||||||
|
|
@ -134,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"/>
|
||||||
|
|
@ -141,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"/>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public extension Color {
|
extension Color {
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
var int: UInt64 = 0
|
var int: UInt64 = 0
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@ 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,
|
func conditionalContextMenu(id: some Hashable,
|
||||||
|
|
|
||||||
|
|
@ -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,20 +7,20 @@
|
||||||
|
|
||||||
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 about: String?
|
||||||
let website: 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 listName: String?
|
let listName: String?
|
||||||
public let tags: [PluginTagJson]?
|
let tags: [PluginTagJson]?
|
||||||
|
|
||||||
public init(name: String,
|
init(name: String,
|
||||||
version: Int16,
|
version: Int16,
|
||||||
minVersion: String?,
|
minVersion: String?,
|
||||||
about: String?,
|
about: String?,
|
||||||
|
|
@ -45,7 +45,7 @@ public struct ActionJson: Codable, Hashable, PluginJson {
|
||||||
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)
|
||||||
|
|
@ -68,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
|
||||||
|
|
||||||
|
|
@ -77,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) {
|
||||||
|
|
@ -92,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] {
|
||||||
|
|
@ -100,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,7 +10,7 @@ import Foundation
|
||||||
|
|
||||||
// MARK: - Universal IA enum (IA = InstantAvailability)
|
// MARK: - Universal IA enum (IA = InstantAvailability)
|
||||||
|
|
||||||
public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||||
case full = "Cached"
|
case full = "Cached"
|
||||||
case partial = "Batch"
|
case partial = "Batch"
|
||||||
case none = "Uncached"
|
case none = "Uncached"
|
||||||
|
|
@ -18,7 +18,7 @@ public enum IAStatus: String, Codable, Hashable, Sendable, CaseIterable {
|
||||||
|
|
||||||
// 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
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,7 +8,7 @@
|
||||||
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?
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@
|
||||||
|
|
||||||
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 about: String?
|
let about: String?
|
||||||
let website: String?
|
let website: String?
|
||||||
|
|
@ -25,33 +25,33 @@ public struct SourceJson: Codable, Hashable, Sendable, PluginJson {
|
||||||
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 listName: String?
|
let listName: String?
|
||||||
public let tags: [PluginTagJson]?
|
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?
|
||||||
|
|
@ -60,8 +60,9 @@ 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 title: SourceComplexQueryJson
|
||||||
|
|
@ -72,9 +73,10 @@ public struct SourceJsonParserJson: Codable, Hashable, Sendable {
|
||||||
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 title: SourceComplexQueryJson
|
||||||
let magnetHash: SourceComplexQueryJson?
|
let magnetHash: SourceComplexQueryJson?
|
||||||
|
|
@ -84,8 +86,9 @@ public struct SourceRssParserJson: Codable, Hashable, Sendable {
|
||||||
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 title: SourceComplexQueryJson
|
||||||
let magnet: SourceMagnetJson
|
let magnet: SourceMagnetJson
|
||||||
|
|
@ -94,21 +97,21 @@ public struct SourceHtmlParserJson: Codable, Hashable, Sendable {
|
||||||
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?
|
||||||
|
|
@ -117,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
|
|
@ -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
|
|
@ -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,7 +8,7 @@
|
||||||
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 }
|
||||||
|
|
@ -27,7 +27,7 @@ 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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,7 +70,7 @@ 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(
|
||||||
|
|
@ -88,7 +88,7 @@ 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(
|
||||||
|
|
@ -106,7 +106,7 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ public class NavigationViewModel: ObservableObject {
|
||||||
@Published var currentSortFilter: SortFilter?
|
@Published var currentSortFilter: SortFilter?
|
||||||
@Published var currentSortOrder: SortOrder = .forward
|
@Published var currentSortOrder: SortOrder = .forward
|
||||||
|
|
||||||
public func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
func compareSearchResult(lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||||
switch currentSortFilter {
|
switch currentSortFilter {
|
||||||
case .leechers:
|
case .leechers:
|
||||||
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
|
guard let lhsLeechers = lhs.leechers, let rhsLeechers = rhs.leechers else {
|
||||||
|
|
@ -97,7 +97,7 @@ public class NavigationViewModel: ObservableObject {
|
||||||
|
|
||||||
@Published var searchPrompt: String = "Search"
|
@Published var searchPrompt: String = "Search"
|
||||||
@Published var lastSearchPromptIndex: Int = -1
|
@Published var lastSearchPromptIndex: Int = -1
|
||||||
let searchBarTextArray: [String] = [
|
private let searchBarTextArray: [String] = [
|
||||||
"What's on your mind?",
|
"What's on your mind?",
|
||||||
"Discover something interesting",
|
"Discover something interesting",
|
||||||
"Find an engaging show",
|
"Find an engaging show",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -9,7 +9,7 @@ import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Yams
|
import Yams
|
||||||
|
|
||||||
public class PluginManager: ObservableObject {
|
class PluginManager: ObservableObject {
|
||||||
var logManager: LoggingManager?
|
var logManager: LoggingManager?
|
||||||
let kodi: Kodi = .init()
|
let kodi: Kodi = .init()
|
||||||
|
|
||||||
|
|
@ -25,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")
|
||||||
|
|
@ -97,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] = []
|
||||||
|
|
||||||
|
|
@ -176,7 +176,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if a deeplink action is present and if there's a single action for the OS (or fallback)
|
// 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() })
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +244,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
private func fetchCastedPlugins<PJ: PluginJson>(_ forType: PJ.Type) -> [PJ] {
|
||||||
switch String(describing: PJ.self) {
|
switch String(describing: PJ.self) {
|
||||||
case "SourceJson":
|
case "SourceJson":
|
||||||
return availableSources as? [PJ] ?? []
|
return availableSources as? [PJ] ?? []
|
||||||
|
|
@ -256,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
|
||||||
|
|
@ -266,7 +266,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches sources using the background context
|
// Fetches sources using the background context
|
||||||
public func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
|
func fetchInstalledSources(searchResultsEmpty: Bool) -> [Source] {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
|
if !filteredInstalledSources.isEmpty, !searchResultsEmpty {
|
||||||
|
|
@ -279,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 {
|
||||||
|
|
@ -332,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()
|
||||||
|
|
@ -355,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()
|
||||||
|
|
@ -380,7 +380,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async {
|
||||||
guard let actionJson else {
|
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
|
||||||
|
|
@ -448,7 +448,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async {
|
||||||
guard let sourceJson else {
|
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
|
||||||
|
|
@ -535,7 +535,7 @@ public class PluginManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
private func addSourceApi(newSource: Source, apiJson: SourceApiJson) {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let newSourceApi = SourceApi(context: backgroundContext)
|
let newSourceApi = SourceApi(context: backgroundContext)
|
||||||
|
|
@ -570,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)
|
||||||
|
|
@ -578,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)
|
||||||
|
|
@ -638,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)
|
||||||
|
|
@ -646,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
|
||||||
|
|
@ -710,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)
|
||||||
|
|
@ -726,6 +741,16 @@ public class PluginManager: ObservableObject {
|
||||||
newSourceHtmlParser.subName = newSourceSubName
|
newSourceHtmlParser.subName = newSourceSubName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let requestJson = htmlParserJson.request {
|
||||||
|
print(requestJson)
|
||||||
|
let newParserRequest = SourceRequest(context: backgroundContext)
|
||||||
|
newParserRequest.method = requestJson.method
|
||||||
|
newParserRequest.headers = requestJson.headers
|
||||||
|
newParserRequest.body = requestJson.body
|
||||||
|
|
||||||
|
newSourceHtmlParser.request = newParserRequest
|
||||||
|
}
|
||||||
|
|
||||||
// Adds a title complex query
|
// Adds a title complex query
|
||||||
let newSourceTitle = SourceTitle(context: backgroundContext)
|
let newSourceTitle = SourceTitle(context: backgroundContext)
|
||||||
newSourceTitle.query = htmlParserJson.title.query
|
newSourceTitle.query = htmlParserJson.title.query
|
||||||
|
|
@ -770,7 +795,7 @@ 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(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
func addPluginList(_ urlString: String, isSheet: Bool = false, existingPluginList: PluginList? = nil) async throws {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
|
if urlString.isEmpty || !urlString.starts(with: "https://") && !urlString.starts(with: "http://") {
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,18 @@ class ScrapingViewModel: ObservableObject {
|
||||||
|
|
||||||
// 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: ", "))",
|
||||||
|
|
@ -47,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: ", "))",
|
||||||
|
|
@ -56,17 +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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|
@ -144,7 +166,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeParser(source: Source) async -> SearchRequestResult? {
|
private func executeParser(source: Source) async -> SearchRequestResult? {
|
||||||
guard let website = source.website else {
|
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)")
|
||||||
|
|
||||||
|
|
@ -160,18 +182,26 @@ class ScrapingViewModel: ObservableObject {
|
||||||
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.map {
|
let replacedSearchUrl = htmlParser.searchUrl.map {
|
||||||
$0.replacingOccurrences(of: "{query}", with: encodedQuery)
|
substituteParams($0, with: params)
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = await handleUrls(
|
let data = await handleUrls(
|
||||||
website: website,
|
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,
|
||||||
|
|
@ -182,23 +212,25 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
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(
|
||||||
website: website,
|
website: website,
|
||||||
replacedSearchUrl: replacedSearchUrl,
|
replacedSearchUrl: replacedSearchUrl,
|
||||||
fallbackUrls: source.fallbackUrls,
|
fallbackUrls: source.fallbackUrls,
|
||||||
sourceName: source.name
|
sourceName: source.name,
|
||||||
|
requestParams: rssParser.request
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,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
|
||||||
|
|
@ -247,7 +278,8 @@ class ScrapingViewModel: ObservableObject {
|
||||||
website: 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 {
|
||||||
|
|
@ -262,16 +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(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String) async -> Data? {
|
private func handleUrls(website: String, replacedSearchUrl: String?, fallbackUrls: [String]?, sourceName: String, requestParams: SourceRequest?) async -> Data? {
|
||||||
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
|
let fetchUrl = website + (replacedSearchUrl.map { $0 } ?? "")
|
||||||
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
|
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
if let fallbackUrls {
|
if let fallbackUrls {
|
||||||
for fallbackUrl in fallbackUrls {
|
for fallbackUrl in fallbackUrls {
|
||||||
let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
|
let fetchUrl = fallbackUrl + (replacedSearchUrl.map { $0 } ?? "")
|
||||||
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName) {
|
if let data = await fetchWebsiteData(urlString: fetchUrl, sourceName: sourceName, requestParams: requestParams) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -280,7 +312,7 @@ 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?,
|
||||||
|
|
@ -297,8 +329,7 @@ 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,
|
||||||
|
|
@ -322,7 +353,7 @@ 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?
|
||||||
{
|
{
|
||||||
|
|
@ -368,7 +399,7 @@ 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.trimmingCharacters(in: .whitespacesAndNewlines)) 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!")
|
||||||
|
|
||||||
|
|
@ -387,7 +418,12 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = URLRequest(url: url, timeoutInterval: timeout)
|
var request = URLRequest(url: url, timeoutInterval: timeout)
|
||||||
|
request.httpMethod = requestParams?.method
|
||||||
|
request.httpBody = requestParams?.body?.data(using: .utf8)
|
||||||
|
requestParams?.headers?.forEach { field, value in
|
||||||
|
request.addValue(value, forHTTPHeaderField: field)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
@ -410,7 +446,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
private func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? {
|
||||||
guard let jsonParser = source.jsonParser else {
|
guard let jsonParser = source.jsonParser else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -485,7 +521,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add regex parsing for API
|
// TODO: Add regex parsing for API
|
||||||
public func parseJsonResult(_ result: JSON,
|
private func parseJsonResult(_ result: JSON,
|
||||||
jsonParser: SourceJsonParser,
|
jsonParser: SourceJsonParser,
|
||||||
source: Source,
|
source: Source,
|
||||||
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
existingSearchResult: SearchResult? = nil) -> SearchResult?
|
||||||
|
|
@ -579,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
|
||||||
}
|
}
|
||||||
|
|
@ -714,7 +750,7 @@ 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?,
|
||||||
|
|
@ -747,7 +783,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML scraper
|
// HTML scraper
|
||||||
public func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
|
private func scrapeHtml(source: Source, website: String, html: String) async -> SearchRequestResult? {
|
||||||
guard let htmlParser = source.htmlParser else {
|
guard let htmlParser = source.htmlParser else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -799,7 +835,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
|
|
||||||
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
|
let replacedMagnetUrl = externalMagnetUrl.starts(with: "/") ? website + externalMagnetUrl : externalMagnetUrl
|
||||||
guard
|
guard
|
||||||
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name),
|
let data = await fetchWebsiteData(urlString: replacedMagnetUrl, sourceName: source.name, requestParams: htmlParser.request),
|
||||||
let magnetHtml = String(data: data, encoding: .utf8)
|
let magnetHtml = String(data: data, encoding: .utf8)
|
||||||
else {
|
else {
|
||||||
continue
|
continue
|
||||||
|
|
@ -884,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,
|
||||||
|
|
@ -919,7 +955,7 @@ 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?
|
||||||
|
|
@ -944,7 +980,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRegex(parsedValue: String, regexString: String) -> String? {
|
private func runRegex(parsedValue: String, regexString: String) -> String? {
|
||||||
// TODO: Maybe dynamically parse flags
|
// TODO: Maybe dynamically parse flags
|
||||||
let replacedRegexString = regexString
|
let replacedRegexString = regexString
|
||||||
.replacingOccurrences(of: "{query}", with: cleanedSearchText)
|
.replacingOccurrences(of: "{query}", with: cleanedSearchText)
|
||||||
|
|
@ -967,7 +1003,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSizeString(sizeString: String) -> String? {
|
private func parseSizeString(sizeString: String) -> String? {
|
||||||
// Test if the string can be a full integer
|
// 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
|
||||||
|
|
@ -989,7 +1025,7 @@ class ScrapingViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
private func cleanApiCreds(api: SourceApi, sourceName: String) async {
|
||||||
let backgroundContext = PersistenceController.shared.backgroundContext
|
let backgroundContext = PersistenceController.shared.backgroundContext
|
||||||
|
|
||||||
let hasCredentials = api.clientId != nil || api.clientSecret != nil
|
let hasCredentials = api.clientId != nil || api.clientSecret != nil
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,34 @@ struct HybridSecureField: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@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?
|
@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)
|
.focused($focusedField, equals: .plain)
|
||||||
} else {
|
} else {
|
||||||
SecureField("Password", text: $text)
|
SecureField("Password", text: $text, onCommit: onCommit)
|
||||||
.focused($focusedField, equals: .secure)
|
.focused($focusedField, equals: .secure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
|
.disabledAppearance(isFieldDisabled)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showPassword.toggle()
|
showPassword.toggle()
|
||||||
|
|
@ -42,3 +54,9 @@ struct HybridSecureField: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.offset = 1
|
offset = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,16 @@ struct NavView<Content: View>: View {
|
||||||
@ViewBuilder var content: Content
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Uncomment once NavigationStack issues are fixed
|
// NavigationStack issues are fixed on iOS 17
|
||||||
/*
|
if #available(iOS 17, *) {
|
||||||
if #available(iOS 16, *) {
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
*/
|
|
||||||
NavigationView {
|
NavigationView {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
.navigationViewStyle(.stack)
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: selectedDebridType.toString(abbreviated: true),
|
name: debridSource.abbreviation,
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,23 @@ struct SelectedDebridFilterView<Content: View>: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
debridManager.selectedDebridType = nil
|
debridManager.selectedDebridSource = nil
|
||||||
} label: {
|
} label: {
|
||||||
Text("None")
|
Text("None")
|
||||||
|
|
||||||
if debridManager.selectedDebridType == nil {
|
if debridManager.selectedDebridSource == nil {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(DebridType.allCases, id: \.self) { (debridType: DebridType) in
|
ForEach(debridManager.debridSources, id: \.id) { debridSource in
|
||||||
if debridManager.enabledDebrids.contains(debridType) {
|
if debridSource.isLoggedIn {
|
||||||
Button {
|
Button {
|
||||||
debridManager.selectedDebridType = debridType
|
debridManager.selectedDebridSource = debridSource
|
||||||
} label: {
|
} label: {
|
||||||
Text(debridType.toString())
|
Text(debridSource.id)
|
||||||
|
|
||||||
if debridManager.selectedDebridType == debridType {
|
if debridManager.selectedDebridSource?.id == debridSource.id {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +40,5 @@ struct SelectedDebridFilterView<Content: View>: View {
|
||||||
} label: {
|
} label: {
|
||||||
label
|
label
|
||||||
}
|
}
|
||||||
.id(debridManager.selectedDebridType)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,19 @@ struct BookmarksView: View {
|
||||||
.frame(height: 15)
|
.frame(height: 15)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
if debridManager.enabledDebrids.count > 0 {
|
await matchAgainstIA()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await matchAgainstIA()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPredicate() {
|
||||||
|
bookmarks.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAgainstIA() async {
|
||||||
|
if !debridManager.enabledDebrids.isEmpty {
|
||||||
let magnets = bookmarks.compactMap {
|
let magnets = bookmarks.compactMap {
|
||||||
if let magnetHash = $0.magnetHash {
|
if let magnetHash = $0.magnetHash {
|
||||||
return Magnet(hash: magnetHash, link: $0.magnetLink)
|
return Magnet(hash: magnetHash, link: $0.magnetLink)
|
||||||
|
|
@ -68,8 +80,3 @@ struct BookmarksView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPredicate() {
|
|
||||||
bookmarks.nsPredicate = searchText.isEmpty ? nil : NSPredicate(format: "title CONTAINS[cd] %@", searchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,82 +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
|
||||||
|
|
||||||
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 {
|
||||||
let magnet = Magnet(hash: magnet.hash, link: nil)
|
navModel.selectedMagnet = magnet
|
||||||
|
|
||||||
// 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))
|
||||||
.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,60 +0,0 @@
|
||||||
//
|
|
||||||
// PremiumizeCloudView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 1/2/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct PremiumizeCloudView: View {
|
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
|
||||||
@EnvironmentObject var pluginManager: PluginManager
|
|
||||||
|
|
||||||
@Binding var searchText: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
DisclosureGroup("Items") {
|
|
||||||
ForEach(debridManager.premiumizeCloudItems.filter {
|
|
||||||
searchText.isEmpty ? true : $0.name.lowercased().contains(searchText.lowercased())
|
|
||||||
}, id: \.id) { item in
|
|
||||||
Button(item.name) {
|
|
||||||
Task {
|
|
||||||
navModel.resultFromCloud = true
|
|
||||||
navModel.selectedTitle = item.name
|
|
||||||
|
|
||||||
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: item.id)
|
|
||||||
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
|
||||||
PersistenceController.shared.createHistory(
|
|
||||||
HistoryEntryJson(
|
|
||||||
name: item.name,
|
|
||||||
url: debridManager.downloadUrl,
|
|
||||||
source: DebridType.premiumize.toString()
|
|
||||||
),
|
|
||||||
performSave: true
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
|
||||||
.tint(.black)
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for index in offsets {
|
|
||||||
if let item = debridManager.premiumizeCloudItems[safe: index] {
|
|
||||||
Task {
|
|
||||||
await debridManager.deletePmItem(id: item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
//
|
|
||||||
// RealDebridCloudView.swift
|
|
||||||
// Ferrite
|
|
||||||
//
|
|
||||||
// Created by Brian Dashore on 12/31/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct RealDebridCloudView: View {
|
|
||||||
@EnvironmentObject var navModel: NavigationViewModel
|
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
|
||||||
@EnvironmentObject var pluginManager: PluginManager
|
|
||||||
|
|
||||||
@Binding var searchText: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
DisclosureGroup("Downloads") {
|
|
||||||
ForEach(debridManager.realDebridCloudDownloads.filter {
|
|
||||||
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
|
|
||||||
}, id: \.self) { downloadResponse in
|
|
||||||
Button(downloadResponse.filename) {
|
|
||||||
navModel.resultFromCloud = true
|
|
||||||
navModel.selectedTitle = downloadResponse.filename
|
|
||||||
debridManager.downloadUrl = downloadResponse.download
|
|
||||||
|
|
||||||
PersistenceController.shared.createHistory(
|
|
||||||
HistoryEntryJson(
|
|
||||||
name: downloadResponse.filename,
|
|
||||||
url: downloadResponse.download,
|
|
||||||
source: DebridType.realDebrid.toString()
|
|
||||||
),
|
|
||||||
performSave: true
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.tint(.primary)
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for index in offsets {
|
|
||||||
if let downloadResponse = debridManager.realDebridCloudDownloads[safe: index] {
|
|
||||||
Task {
|
|
||||||
await debridManager.deleteRdDownload(downloadID: downloadResponse.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisclosureGroup("Torrents") {
|
|
||||||
ForEach(debridManager.realDebridCloudTorrents.filter {
|
|
||||||
searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased())
|
|
||||||
}, id: \.self) { torrentResponse in
|
|
||||||
Button {
|
|
||||||
if torrentResponse.status == "downloaded", !torrentResponse.links.isEmpty {
|
|
||||||
navModel.resultFromCloud = true
|
|
||||||
navModel.selectedTitle = torrentResponse.filename
|
|
||||||
|
|
||||||
var historyInfo = HistoryEntryJson(
|
|
||||||
name: torrentResponse.filename,
|
|
||||||
source: DebridType.realDebrid.toString()
|
|
||||||
)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
if torrentResponse.links.count == 1 {
|
|
||||||
if let torrentLink = torrentResponse.links[safe: 0] {
|
|
||||||
await debridManager.fetchDebridDownload(magnet: nil, cloudInfo: torrentLink)
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
|
||||||
historyInfo.url = debridManager.downloadUrl
|
|
||||||
PersistenceController.shared.createHistory(historyInfo, performSave: true)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let magnet = Magnet(hash: torrentResponse.hash, link: nil)
|
|
||||||
|
|
||||||
// Do not clear old IA values
|
|
||||||
await debridManager.populateDebridIA([magnet])
|
|
||||||
|
|
||||||
if debridManager.selectDebridResult(magnet: magnet) {
|
|
||||||
navModel.selectedHistoryInfo = historyInfo
|
|
||||||
navModel.currentChoiceSheet = .batch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text(torrentResponse.filename)
|
|
||||||
.font(.callout)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.lineLimit(4)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text(torrentResponse.status.capitalizingFirstLetter())
|
|
||||||
Spacer()
|
|
||||||
DebridLabelView(cloudLinks: torrentResponse.links)
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2))
|
|
||||||
.tint(.primary)
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for index in offsets {
|
|
||||||
if let torrentResponse = debridManager.realDebridCloudTorrents[safe: index] {
|
|
||||||
Task {
|
|
||||||
await debridManager.deleteRdTorrent(torrentID: torrentResponse.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,29 +10,23 @@ import SwiftUI
|
||||||
struct DebridCloudView: View {
|
struct DebridCloudView: View {
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
|
||||||
|
@Store var debridSource: DebridSource
|
||||||
|
|
||||||
@Binding var searchText: String
|
@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)
|
||||||
.task {
|
.task {
|
||||||
await debridManager.fetchDebridCloud()
|
await debridManager.fetchDebridCloud()
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await debridManager.fetchDebridCloud()
|
await debridManager.fetchDebridCloud(bypassTTL: true)
|
||||||
}
|
}
|
||||||
.onChange(of: debridManager.selectedDebridType) { newType in
|
.onChange(of: debridManager.selectedDebridSource?.id) { newType in
|
||||||
if newType != nil {
|
if newType != nil {
|
||||||
Task {
|
Task {
|
||||||
await debridManager.fetchDebridCloud()
|
await debridManager.fetchDebridCloud()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,7 @@ struct PluginInfoMetaView<P: Plugin>: View {
|
||||||
Group {
|
Group {
|
||||||
Text("ID: \(selectedPlugin.id)")
|
Text("ID: \(selectedPlugin.id)")
|
||||||
|
|
||||||
if let pluginList = pluginLists.first(where: { $0.id == selectedPlugin.listId })
|
if let pluginList = pluginLists.first(where: { $0.id == selectedPlugin.listId }) {
|
||||||
{
|
|
||||||
Text("List: \(pluginList.name)")
|
Text("List: \(pluginList.name)")
|
||||||
Text("List ID: \(pluginList.id.uuidString)")
|
Text("List ID: \(pluginList.id.uuidString)")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ struct SearchFilterHeaderView: View {
|
||||||
|
|
||||||
SelectedDebridFilterView {
|
SelectedDebridFilterView {
|
||||||
FilterLabelView(
|
FilterLabelView(
|
||||||
name: debridManager.selectedDebridType?.toString(),
|
name: debridManager.selectedDebridSource?.id,
|
||||||
fallbackName: "Debrid"
|
fallbackName: "Debrid"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,50 +28,26 @@ struct SearchResultButtonView: View {
|
||||||
navModel.selectedTitle = result.title ?? ""
|
navModel.selectedTitle = result.title ?? ""
|
||||||
navModel.resultFromCloud = false
|
navModel.resultFromCloud = false
|
||||||
|
|
||||||
|
var historyEntry = HistoryEntryJson(
|
||||||
|
name: result.title,
|
||||||
|
source: result.source
|
||||||
|
)
|
||||||
|
|
||||||
switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) {
|
switch debridIAStatus ?? debridManager.matchMagnetHash(result.magnet) {
|
||||||
case .full:
|
case .full:
|
||||||
if debridManager.selectDebridResult(magnet: result.magnet) {
|
if debridManager.selectDebridResult(magnet: result.magnet) {
|
||||||
debridManager.currentDebridTask = Task {
|
debridManager.currentDebridTask = Task {
|
||||||
await debridManager.fetchDebridDownload(magnet: result.magnet)
|
await downloadToDebrid()
|
||||||
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
|
||||||
PersistenceController.shared.createHistory(
|
|
||||||
HistoryEntryJson(
|
|
||||||
name: result.title,
|
|
||||||
url: debridManager.downloadUrl,
|
|
||||||
source: result.source
|
|
||||||
),
|
|
||||||
performSave: true
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
|
||||||
urlString: debridManager.downloadUrl,
|
|
||||||
navModel: navModel
|
|
||||||
)
|
|
||||||
|
|
||||||
if navModel.currentChoiceSheet != .action {
|
|
||||||
debridManager.downloadUrl = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .partial:
|
case .partial:
|
||||||
if debridManager.selectDebridResult(magnet: result.magnet) {
|
if debridManager.selectDebridResult(magnet: result.magnet) {
|
||||||
navModel.selectedHistoryInfo = HistoryEntryJson(
|
navModel.selectedHistoryInfo = historyEntry
|
||||||
name: result.title,
|
|
||||||
source: result.source
|
|
||||||
)
|
|
||||||
navModel.currentChoiceSheet = .batch
|
navModel.currentChoiceSheet = .batch
|
||||||
}
|
}
|
||||||
case .none:
|
case .none:
|
||||||
PersistenceController.shared.createHistory(
|
historyEntry.url = result.magnet.link
|
||||||
HistoryEntryJson(
|
PersistenceController.shared.createHistory(historyEntry, performSave: true)
|
||||||
name: result.title,
|
|
||||||
url: result.magnet.link,
|
|
||||||
source: result.source
|
|
||||||
),
|
|
||||||
performSave: true
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginManager.runDefaultAction(
|
pluginManager.runDefaultAction(
|
||||||
urlString: result.magnet.link,
|
urlString: result.magnet.link,
|
||||||
|
|
@ -123,18 +99,46 @@ struct SearchResultButtonView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if debridManager.currentDebridTask == nil {
|
||||||
|
let foundIAResult = debridManager.selectDebridResult(magnet: result.magnet)
|
||||||
|
|
||||||
|
// Add a fake IA because we don't know if the magnet is cached at this point
|
||||||
|
if !foundIAResult {
|
||||||
|
debridManager.selectedDebridItem = DebridIA(
|
||||||
|
magnet: result.magnet,
|
||||||
|
expiryTimeStamp: Date().timeIntervalSince1970,
|
||||||
|
files: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
debridManager.currentDebridTask = Task {
|
||||||
|
await downloadToDebrid()
|
||||||
|
|
||||||
|
// Re-populate the IA cache if a result wasn't initially found
|
||||||
|
if !foundIAResult {
|
||||||
|
await debridManager.populateDebridIA([result.magnet])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Download to Debrid")
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert("Caching file", isPresented: $debridManager.showDeleteAlert) {
|
.alert("Caching file", isPresented: $debridManager.showDeleteAlert) {
|
||||||
Button("Yes", role: .destructive) {
|
Button("Yes", role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
await debridManager.deleteRdTorrent()
|
try? await debridManager.selectedDebridSource?.deleteUserMagnet(cloudMagnetId: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(
|
Text(
|
||||||
"RealDebrid is currently caching this file. Would you like to delete it? \n\n" +
|
"\(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") is currently caching this file. " +
|
||||||
"Progress can be checked on the RealDebrid website."
|
"Would you like to delete it? \n\n" +
|
||||||
|
"Progress can be checked on the \(debridManager.selectedDebridSource?.id ?? "Unknown Debrid") website."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { notification in
|
||||||
|
|
@ -167,4 +171,35 @@ struct SearchResultButtonView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Common function to download
|
||||||
|
func downloadToDebrid() async {
|
||||||
|
var historyEntry = HistoryEntryJson(
|
||||||
|
name: result.title,
|
||||||
|
source: result.source
|
||||||
|
)
|
||||||
|
|
||||||
|
await debridManager.fetchDebridDownload(magnet: result.magnet)
|
||||||
|
navModel.selectedTitle = result.title ?? ""
|
||||||
|
|
||||||
|
if debridManager.requiresUnrestrict {
|
||||||
|
navModel.currentChoiceSheet = .batch
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !debridManager.downloadUrl.isEmpty {
|
||||||
|
historyEntry.url = debridManager.downloadUrl
|
||||||
|
PersistenceController.shared.createHistory(historyEntry, performSave: true)
|
||||||
|
|
||||||
|
pluginManager.runDefaultAction(
|
||||||
|
urlString: debridManager.downloadUrl,
|
||||||
|
navModel: navModel
|
||||||
|
)
|
||||||
|
|
||||||
|
if navModel.currentChoiceSheet != .action {
|
||||||
|
debridManager.downloadUrl = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// SearchResultRDView.swift
|
// SearchResultInfoView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/26/22.
|
// Created by Brian Dashore on 7/26/22.
|
||||||
|
|
@ -30,7 +30,9 @@ struct SearchResultInfoView: View {
|
||||||
Text(size)
|
Text(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
DebridLabelView(magnet: result.magnet)
|
if let debridSource = debridManager.selectedDebridSource {
|
||||||
|
DebridLabelView(debridSource: debridSource, magnet: result.magnet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// DefaultActionsPickerViews.swift
|
// DefaultActionPickerView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 8/11/22.
|
// Created by Brian Dashore on 8/11/22.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// SourceListEditorView.swift
|
// PluginListEditorView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/25/22.
|
// Created by Brian Dashore on 7/25/22.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// SettingsSourceListView.swift
|
// SettingsPluginListView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/25/22.
|
// Created by Brian Dashore on 7/25/22.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// DebridInfoView.swift
|
// SettingsDebridInfoView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 3/5/23.
|
// Created by Brian Dashore on 3/5/23.
|
||||||
|
|
@ -10,15 +10,19 @@ import SwiftUI
|
||||||
struct SettingsDebridInfoView: View {
|
struct SettingsDebridInfoView: View {
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
|
||||||
let debridType: DebridType
|
@Store var debridSource: DebridSource
|
||||||
|
|
||||||
|
@State private var apiKeyTempText: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section(header: InlineHeader("Description")) {
|
Section(header: InlineHeader("Description")) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("\(debridType.toString()) is a debrid service that is used for unrestricting downloads and media playback. You must pay to access the service.")
|
Text(debridSource.description ??
|
||||||
|
"\(debridSource.id) is a debrid service that is used for downloads and media playback. You must pay to access the service."
|
||||||
|
)
|
||||||
|
|
||||||
Link("Website", destination: URL(string: debridType.website()) ?? URL(string: "https://kingbri.dev/ferrite")!)
|
Link("Website", destination: URL(string: debridSource.website) ?? URL(string: "https://kingbri.dev/ferrite")!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,24 +32,56 @@ struct SettingsDebridInfoView: View {
|
||||||
) {
|
) {
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
if debridManager.enabledDebrids.contains(debridType) {
|
if debridSource.isLoggedIn {
|
||||||
await debridManager.logoutDebrid(debridType: debridType)
|
await debridManager.logout(debridSource)
|
||||||
} else if !debridManager.getAuthProcessingBool(debridType: debridType) {
|
} else if !debridSource.authProcessing {
|
||||||
await debridManager.authenticateDebrid(debridType: debridType)
|
await debridManager.authenticateDebrid(debridSource, apiKey: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? ""
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(
|
Text(
|
||||||
debridManager.enabledDebrids.contains(debridType)
|
debridSource.isLoggedIn
|
||||||
? "Logout"
|
? "Logout"
|
||||||
: (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login")
|
: (debridSource.authProcessing ? "Processing" : "Login")
|
||||||
)
|
)
|
||||||
.foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue)
|
.foregroundColor(debridSource.isLoggedIn ? .red : .blue)
|
||||||
|
}
|
||||||
|
.alert("Invalid web login", isPresented: $debridManager.showWebLoginAlert) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(
|
||||||
|
"\(debridSource.id) does not have a login portal. Please use an API key to login."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(
|
||||||
|
header: InlineHeader("API key"),
|
||||||
|
footer: Text("Add a permanent API key here. Only use this if web authentication does not work!")
|
||||||
|
) {
|
||||||
|
HybridSecureField(
|
||||||
|
text: $apiKeyTempText,
|
||||||
|
onCommit: {
|
||||||
|
Task {
|
||||||
|
if !apiKeyTempText.isEmpty {
|
||||||
|
await debridManager.authenticateDebrid(debridSource, apiKey: apiKeyTempText)
|
||||||
|
apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.fieldDisabled(debridSource.isLoggedIn)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
apiKeyTempText = await debridManager.getManualAuthKey(debridSource) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle(debridType.toString())
|
.navigationTitle(debridSource.id)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
//
|
||||||
|
// SettingsDebridLinkView.swift
|
||||||
|
// Ferrite
|
||||||
|
//
|
||||||
|
// Created by Brian Dashore on 11/27/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsDebridLinkView: View {
|
||||||
|
var debridSource: DebridSource
|
||||||
|
|
||||||
|
// TODO: Use a roundabout state for now
|
||||||
|
@State private var isLoggedIn = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink {
|
||||||
|
SettingsDebridInfoView(debridSource: debridSource)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(debridSource.id)
|
||||||
|
Spacer()
|
||||||
|
Text(isLoggedIn ? "Enabled" : "Disabled")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
isLoggedIn = debridSource.isLoggedIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,13 @@ struct LibraryView: View {
|
||||||
case .history:
|
case .history:
|
||||||
HistoryView(allHistoryEntries: allHistoryEntries, searchText: $searchText)
|
HistoryView(allHistoryEntries: allHistoryEntries, searchText: $searchText)
|
||||||
case .debridCloud:
|
case .debridCloud:
|
||||||
DebridCloudView(searchText: $searchText)
|
if let selectedDebridSource = debridManager.selectedDebridSource {
|
||||||
|
DebridCloudView(debridSource: selectedDebridSource, searchText: $searchText)
|
||||||
|
} else {
|
||||||
|
// Placeholder view that takes up the entire parent view
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|
@ -53,7 +59,7 @@ struct LibraryView: View {
|
||||||
EmptyInstructionView(title: "No History", message: "Start watching to build history")
|
EmptyInstructionView(title: "No History", message: "Start watching to build history")
|
||||||
}
|
}
|
||||||
case .debridCloud:
|
case .debridCloud:
|
||||||
if debridManager.selectedDebridType == nil {
|
if debridManager.selectedDebridSource == nil {
|
||||||
EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service")
|
EmptyInstructionView(title: "Cloud Unavailable", message: "Listing is not available for this service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +75,7 @@ struct LibraryView: View {
|
||||||
switch navModel.libraryPickerSelection {
|
switch navModel.libraryPickerSelection {
|
||||||
case .bookmarks, .debridCloud:
|
case .bookmarks, .debridCloud:
|
||||||
SelectedDebridFilterView {
|
SelectedDebridFilterView {
|
||||||
Text(debridManager.selectedDebridType?.toString(abbreviated: true) ?? "Debrid")
|
Text(debridManager.selectedDebridSource?.abbreviation ?? "Debrid")
|
||||||
}
|
}
|
||||||
.transaction {
|
.transaction {
|
||||||
$0.animation = .none
|
$0.animation = .none
|
||||||
|
|
@ -90,6 +96,11 @@ struct LibraryView: View {
|
||||||
.esAutocapitalization(autocorrectSearch ? .sentences : .none)
|
.esAutocapitalization(autocorrectSearch ? .sentences : .none)
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
}
|
}
|
||||||
|
.alert("Not implemented", isPresented: $debridManager.showNotImplementedAlert) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(debridManager.notImplementedMessage)
|
||||||
|
}
|
||||||
.onChange(of: navModel.libraryPickerSelection) { _ in
|
.onChange(of: navModel.libraryPickerSelection) { _ in
|
||||||
editMode = .inactive
|
editMode = .inactive
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public extension View {
|
extension View {
|
||||||
// A dismissAction must be added in the parent view struct due to lifecycle issues
|
// A dismissAction must be added in the parent view struct due to lifecycle issues
|
||||||
func expandedSearchable(text: Binding<String>,
|
func expandedSearchable(text: Binding<String>,
|
||||||
isSearching: Binding<Bool>? = nil,
|
isSearching: Binding<Bool>? = nil,
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,23 @@ import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
struct WebView: UIViewRepresentable {
|
struct WebView: UIViewRepresentable {
|
||||||
|
@AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth: Bool = true
|
||||||
var url: URL
|
var url: URL
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
// Make the WebView ephemeral
|
// Make the WebView ephemeral depending on the ephemeral auth setting
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = WKWebsiteDataStore.nonPersistent()
|
|
||||||
|
config.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default()
|
||||||
|
|
||||||
let webView = WKWebView(frame: .zero, configuration: config)
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
let _ = webView.load(URLRequest(url: url))
|
let _ = webView.load(URLRequest(url: url))
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
|
webView.configuration.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WebView_Previews: PreviewProvider {
|
struct WebView_Previews: PreviewProvider {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import BetterSafariView
|
import BetterSafariView
|
||||||
import Introspect
|
import Introspect
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var debridManager: DebridManager
|
@EnvironmentObject var debridManager: DebridManager
|
||||||
|
|
@ -24,6 +25,7 @@ struct SettingsView: View {
|
||||||
|
|
||||||
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
|
@AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true
|
||||||
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false
|
@AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false
|
||||||
|
@AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth = true
|
||||||
@AppStorage("Behavior.DisableRequestTimeout") var disableRequestTimeout = false
|
@AppStorage("Behavior.DisableRequestTimeout") var disableRequestTimeout = false
|
||||||
@AppStorage("Behavior.RequestTimeoutSecs") var requestTimeoutSecs: Double = 15
|
@AppStorage("Behavior.RequestTimeoutSecs") var requestTimeoutSecs: Double = 15
|
||||||
|
|
||||||
|
|
@ -44,17 +46,8 @@ struct SettingsView: View {
|
||||||
NavView {
|
NavView {
|
||||||
Form {
|
Form {
|
||||||
Section(header: InlineHeader("Debrid services")) {
|
Section(header: InlineHeader("Debrid services")) {
|
||||||
ForEach(DebridType.allCases, id: \.self) { debridType in
|
ForEach(debridManager.debridSources, id: \.id) { (debridSource: DebridSource) in
|
||||||
NavigationLink {
|
SettingsDebridLinkView(debridSource: debridSource)
|
||||||
SettingsDebridInfoView(debridType: debridType)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(debridType.toString())
|
|
||||||
Spacer()
|
|
||||||
Text(debridManager.enabledDebrids.contains(debridType) ? "Enabled" : "Disabled")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +66,10 @@ struct SettingsView: View {
|
||||||
|
|
||||||
Section(
|
Section(
|
||||||
header: InlineHeader("Behavior"),
|
header: InlineHeader("Behavior"),
|
||||||
footer: Text("Only disable search timeout if results are slow to fetch")
|
footer: VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Temporarily disable ephemeral auth if you cannot log into a service")
|
||||||
|
Text("Only disable search timeout if results are slow to fetch")
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Toggle(isOn: $autocorrectSearch) {
|
Toggle(isOn: $autocorrectSearch) {
|
||||||
Text("Autocorrect search")
|
Text("Autocorrect search")
|
||||||
|
|
@ -83,6 +79,21 @@ struct SettingsView: View {
|
||||||
Text("Random searchbar text")
|
Text("Random searchbar text")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toggle(isOn: $useEphemeralAuth) {
|
||||||
|
Text("Ephemeral authentication")
|
||||||
|
}
|
||||||
|
.onChange(of: useEphemeralAuth) { changed in
|
||||||
|
// Does not work with ASWebAuthenticationSession
|
||||||
|
if changed {
|
||||||
|
Task {
|
||||||
|
let dataRecords = await WKWebsiteDataStore.default().dataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes())
|
||||||
|
|
||||||
|
await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: dataRecords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Change this to enable search timeout instead
|
||||||
Toggle(isOn: $disableRequestTimeout) {
|
Toggle(isOn: $disableRequestTimeout) {
|
||||||
Text("Disable search timeout")
|
Text("Disable search timeout")
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +119,7 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: InlineHeader("Default actions")) {
|
Section(header: InlineHeader("Default actions")) {
|
||||||
if debridManager.enabledDebrids.count > 0 {
|
if !debridManager.enabledDebrids.isEmpty {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
DefaultActionPickerView(
|
DefaultActionPickerView(
|
||||||
actionRequirement: .debrid,
|
actionRequirement: .debrid,
|
||||||
|
|
@ -207,10 +218,10 @@ struct SettingsView: View {
|
||||||
callbackURLScheme: "ferrite"
|
callbackURLScheme: "ferrite"
|
||||||
) { callbackURL, error in
|
) { callbackURL, error in
|
||||||
Task {
|
Task {
|
||||||
await debridManager.handleCallback(url: callbackURL, error: error)
|
await debridManager.handleAuthCallback(url: callbackURL, error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.prefersEphemeralWebBrowserSession(true)
|
.prefersEphemeralWebBrowserSession(useEphemeralAuth)
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// MagnetChoiceView.swift
|
// ActionChoiceView.swift
|
||||||
// Ferrite
|
// Ferrite
|
||||||
//
|
//
|
||||||
// Created by Brian Dashore on 7/20/22.
|
// Created by Brian Dashore on 7/20/22.
|
||||||
|
|
@ -143,6 +143,8 @@ struct ActionChoiceView: View {
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
debridManager.downloadUrl = ""
|
debridManager.downloadUrl = ""
|
||||||
|
debridManager.clearSelectedDebridItems()
|
||||||
|
debridManager.requiresUnrestrict = false
|
||||||
navModel.selectedTitle = ""
|
navModel.selectedTitle = ""
|
||||||
navModel.selectedBatchTitle = ""
|
navModel.selectedBatchTitle = ""
|
||||||
navModel.resultFromCloud = false
|
navModel.resultFromCloud = false
|
||||||
|
|
@ -153,8 +155,11 @@ struct ActionChoiceView: View {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
debridManager.downloadUrl = ""
|
debridManager.downloadUrl = ""
|
||||||
|
debridManager.clearSelectedDebridItems()
|
||||||
|
debridManager.requiresUnrestrict = false
|
||||||
navModel.selectedTitle = ""
|
navModel.selectedTitle = ""
|
||||||
navModel.selectedBatchTitle = ""
|
navModel.selectedBatchTitle = ""
|
||||||
|
navModel.resultFromCloud = false
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,40 +23,15 @@ struct BatchChoiceView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavView {
|
NavView {
|
||||||
List {
|
List {
|
||||||
switch debridManager.selectedDebridType {
|
ForEach(debridManager.selectedDebridItem?.files ?? [], id: \.self) { file in
|
||||||
case .realDebrid:
|
|
||||||
ForEach(debridManager.selectedRealDebridItem?.files ?? [], id: \.self) { file in
|
|
||||||
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
|
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
|
||||||
Button(file.name) {
|
Button(file.name) {
|
||||||
debridManager.selectedRealDebridFile = file
|
debridManager.selectedDebridFile = file
|
||||||
|
|
||||||
queueCommonDownload(fileName: file.name)
|
queueCommonDownload(fileName: file.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .allDebrid:
|
|
||||||
ForEach(debridManager.selectedAllDebridItem?.files ?? [], id: \.self) { file in
|
|
||||||
if file.fileName.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
|
|
||||||
Button(file.fileName) {
|
|
||||||
debridManager.selectedAllDebridFile = file
|
|
||||||
|
|
||||||
queueCommonDownload(fileName: file.fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .premiumize:
|
|
||||||
ForEach(debridManager.selectedPremiumizeItem?.files ?? [], id: \.self) { file in
|
|
||||||
if file.name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
|
|
||||||
Button(file.name) {
|
|
||||||
debridManager.selectedPremiumizeFile = file
|
|
||||||
|
|
||||||
queueCommonDownload(fileName: file.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .none:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.tint(.primary)
|
.tint(.primary)
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
|
|
@ -64,6 +39,10 @@ struct BatchChoiceView: View {
|
||||||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||||
.autocorrectionDisabled(!autocorrectSearch)
|
.autocorrectionDisabled(!autocorrectSearch)
|
||||||
.textInputAutocapitalization(autocorrectSearch ? .sentences : .never)
|
.textInputAutocapitalization(autocorrectSearch ? .sentences : .never)
|
||||||
|
.onDisappear {
|
||||||
|
debridManager.clearSelectedDebridItems()
|
||||||
|
debridManager.requiresUnrestrict = false
|
||||||
|
}
|
||||||
.navigationTitle("Select a file")
|
.navigationTitle("Select a file")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|
@ -75,6 +54,7 @@ struct BatchChoiceView: View {
|
||||||
try? await Task.sleep(seconds: 1)
|
try? await Task.sleep(seconds: 1)
|
||||||
|
|
||||||
debridManager.clearSelectedDebridItems()
|
debridManager.clearSelectedDebridItems()
|
||||||
|
debridManager.requiresUnrestrict = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +65,11 @@ struct BatchChoiceView: View {
|
||||||
// Common function to communicate betwen VMs and queue/display a download
|
// Common function to communicate betwen VMs and queue/display a download
|
||||||
func queueCommonDownload(fileName: String) {
|
func queueCommonDownload(fileName: String) {
|
||||||
debridManager.currentDebridTask = Task {
|
debridManager.currentDebridTask = Task {
|
||||||
await debridManager.fetchDebridDownload(magnet: navModel.resultFromCloud ? nil : navModel.selectedMagnet)
|
if debridManager.requiresUnrestrict {
|
||||||
|
await debridManager.unrestrictDownload()
|
||||||
|
} else {
|
||||||
|
await debridManager.fetchDebridDownload(magnet: navModel.selectedMagnet)
|
||||||
|
}
|
||||||
|
|
||||||
if !debridManager.downloadUrl.isEmpty {
|
if !debridManager.downloadUrl.isEmpty {
|
||||||
try? await Task.sleep(seconds: 1)
|
try? await Task.sleep(seconds: 1)
|
||||||
|
|
|
||||||
BIN
Misc/Media/Demo/Dark/Bookmarks.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
Misc/Media/Demo/Dark/Cloud.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
Misc/Media/Demo/Dark/History.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
Misc/Media/Demo/Dark/Plugins.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
Misc/Media/Demo/Dark/Search.png
Normal file
|
After Width: | Height: | Size: 539 KiB |
BIN
Misc/Media/Demo/Light/Bookmarks.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
Misc/Media/Demo/Light/Cloud.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
Misc/Media/Demo/Light/History.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
Misc/Media/Demo/Light/Plugins.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
Misc/Media/Demo/Light/Search.png
Normal file
|
After Width: | Height: | Size: 475 KiB |
5
Misc/Referrals/TorBox.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Enter the following code on [TorBox's subscription page](https://torbox.app/subscription)
|
||||||
|
|
||||||
|
bb2d4f54-61bf-4d64-af08-8db0a900485a
|
||||||
|
|
||||||
|
Thanks for the referral!
|
||||||
105
README.md
|
|
@ -1,7 +1,65 @@
|
||||||
# Ferrite
|
# Ferrite
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<img src="https://img.shields.io/badge/Swift-5.10-orange.svg" alt="Swift 5.2"/>
|
||||||
|
<img src="https://img.shields.io/badge/platform-iOS%20%7C%20iPadOS-lightgrey" alt="Platform: iOS | iPadOS"/>
|
||||||
|
<a href="/LICENSE">
|
||||||
|
<img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Ferrite-iOS/Ferrite/releases">
|
||||||
|
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/Ferrite-iOS/Ferrite/total?label=Downloads">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://github.com/Ferrite-iOS/Ferrite/actions/workflows/nightly.yml">
|
||||||
|
<img src="https://github.com/Ferrite-iOS/Ferrite/actions/workflows/nightly.yml/badge.svg?branch=next" alt="Nightly Build Status"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/sYQxnuD7Fj">
|
||||||
|
<img src="https://img.shields.io/discord/545740643247456267.svg?logo=discord&color=blue" alt="Discord Server"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="http://real-debrid.com/?id=8109785">
|
||||||
|
<img src="https://img.shields.io/badge/Refer_on_RealDebrid-98ceeb?style=for-the-badge" alt="Refer on RealDebrid">
|
||||||
|
</a>
|
||||||
|
<a href="Misc/Referrals/TorBox.md">
|
||||||
|
<img src="https://img.shields.io/badge/Refer_on_TorBox-52a153?style=for-the-badge" alt="Refer on TorBox">
|
||||||
|
</a>
|
||||||
|
<a href="https://ko-fi.com/I2I3BDTSW">
|
||||||
|
<img src="https://img.shields.io/badge/Support_on_Ko--fi-FF5E5B?logo=ko-fi&style=for-the-badge&logoColor=white" alt="Support on Ko-Fi">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://testflight.apple.com/join/YohgCnC4"><img src="https://i.imgur.com/A5Kpowu.png" width="200"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
A media search engine for iOS with a plugin API to extend its functionality.
|
A media search engine for iOS with a plugin API to extend its functionality.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
|
||||||
|
| Search | Bookmarks | History |
|
||||||
|
| ------------- | -------- | -------- |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
| Debrid Cloud | Plugins |
|
||||||
|
| ----------- | -------------------- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
### Light Mode
|
||||||
|
|
||||||
|
| Search | Bookmarks | History |
|
||||||
|
| ------------- | -------- | -------- |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
| Debrid Cloud | Plugins |
|
||||||
|
| ----------- | -------------------- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
This project is developed with a hobbyist/educational purpose and I am not responsible for what happens when you install Ferrite.
|
This project is developed with a hobbyist/educational purpose and I am not responsible for what happens when you install Ferrite.
|
||||||
|
|
@ -16,31 +74,62 @@ However, the main problem is that these websites tend to suck in terms of UI or
|
||||||
|
|
||||||
I also wanted to support the use of debrid services since there aren't any (free) options on iOS that have support for this service.
|
I also wanted to support the use of debrid services since there aren't any (free) options on iOS that have support for this service.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [x] Ad-free
|
||||||
|
- [x] Clean UI with native performance
|
||||||
|
- [x] Powerful search with an intuitive filter system
|
||||||
|
- [x] Modular plugin system
|
||||||
|
- [x] Integrates with many debrid providers
|
||||||
|
- [x] Flexible parser system written in native Swift
|
||||||
|
- [x] Local library with bookmarks and history
|
||||||
|
- [x] Manage your debrid cloud
|
||||||
|
- [x] Does not pollute your debrid cloud
|
||||||
|
- [x] Kodi integration
|
||||||
|
|
||||||
|
If there's a feature that's not listed here, open an issue or ask in the support Discord.
|
||||||
|
|
||||||
## What iOS versions are supported?
|
## What iOS versions are supported?
|
||||||
|
|
||||||
|
To decide what minimum version of iOS is supported, Ferrite follows an "n - 2" patten. For example, if iOS 18 is the latest version, the minimum supported iOS version is 16 (18-2 = 16).
|
||||||
|
|
||||||
|
To make this easier, the minimum required iOS version and Ferrite versions are listed below:
|
||||||
|
|
||||||
|
- v0.8 and up: iOS 16 and up
|
||||||
|
|
||||||
- v0.7 and up: iOS 15 and up
|
- v0.7 and up: iOS 15 and up
|
||||||
|
|
||||||
- v0.6.x and lower: iOS 14 and up
|
- v0.6.x and lower: iOS 14 and up
|
||||||
|
|
||||||
## Planned features
|
## Supported debrid services
|
||||||
|
|
||||||
More of these can be found in [issues](https://github.com/bdashore3/Ferrite/issues), but here is a small snippet:
|
Ferrite primarily uses Debrid services for instant streaming. A list of supported services are provided below:
|
||||||
|
|
||||||
- More involved search filtering
|
- RealDebrid
|
||||||
|
- AllDebrid
|
||||||
|
- Premiumize
|
||||||
|
- TorBox
|
||||||
|
- OffCloud
|
||||||
|
|
||||||
- Companion apps for playback on other devices
|
Want another debrid service? Make a request in issues or the support Discord.
|
||||||
|
|
||||||
## Downloads
|
## Downloads
|
||||||
|
|
||||||
Ferrite will only exist as an ipa. There will never be any plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts.
|
At this time, Ferrite will only exist as an ipa. There are no plans to release on TestFlight or the App Store. Ipa builds are automatically built and are provided in Github actions artifacts.
|
||||||
|
|
||||||
## Plugins/Sources
|
## Plugins/Sources
|
||||||
|
|
||||||
Sources are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease.
|
Plugins are not provided by the application. They must be found from external means or you can make them yourself using the [wiki](https://github.com/bdashore3/Ferrite/wiki). Various communities have created sources for Ferrite and they can be imported in the app with ease.
|
||||||
|
|
||||||
|
There are two types of plugins:
|
||||||
|
- Source: A plugin that looks something up in an indexer
|
||||||
|
- Action: A plugin that "does" something, such as opening a result in a separate application
|
||||||
|
|
||||||
|
To start off, a plugin list is located here (you can copy and paste this in the app) -> [https://raw.githubusercontent.com/Ferrite-iOS/example-sources/default/public-domain-plugins.yml](https://raw.githubusercontent.com/Ferrite-iOS/example-sources/default/public-domain-plugins.yml)
|
||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
Xcode 14 must be used.
|
Use the latest stable version of Xcode.
|
||||||
|
|
||||||
There are currently two branches in the repository:
|
There are currently two branches in the repository:
|
||||||
|
|
||||||
|
|
@ -67,7 +156,7 @@ If you have issues with the app:
|
||||||
|
|
||||||
- Describe the issue in detail
|
- Describe the issue in detail
|
||||||
- If you have a feature request, please indicate it as so. Planned features are in a different section of the README, so be sure to read those before submitting.
|
- If you have a feature request, please indicate it as so. Planned features are in a different section of the README, so be sure to read those before submitting.
|
||||||
- Please join [the discord](https://discord.gg/sYQxnuD7Fj) for more info
|
- Please join [the discord](https://discord.gg/sYQxnuD7Fj) for more info and support
|
||||||
|
|
||||||
## Developers and Permissions
|
## Developers and Permissions
|
||||||
|
|
||||||
|
|
|
||||||