mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 03:28:14 +00:00
Compare commits
75 Commits
1.5.2-210
...
v1.4-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d344ab15dc | ||
|
|
888813f095 | ||
|
|
a0a6020ee6 | ||
|
|
e3e68cd158 | ||
|
|
42e56d6e4b | ||
|
|
f979af7f01 | ||
|
|
124a48812a | ||
|
|
44e6c28fd4 | ||
|
|
4f6e0e2a3d | ||
|
|
ccc1cc89ad | ||
|
|
5d0eb2478c | ||
|
|
c28429ec7f | ||
|
|
6c3a7882e1 | ||
|
|
76286e8a45 | ||
|
|
c5128f6227 | ||
|
|
4bf171637b | ||
|
|
249adab427 | ||
|
|
a27ebcce27 | ||
|
|
b5f3a1bd09 | ||
|
|
b306819af9 | ||
|
|
aa29688b4c | ||
|
|
371e6a275b | ||
|
|
0b7e9f8c47 | ||
|
|
680fe915e1 | ||
|
|
1e7ebe4e68 | ||
|
|
1a230d36f7 | ||
|
|
60e1cef84f | ||
|
|
1150a01496 | ||
|
|
e5c9e11b17 | ||
|
|
fb3d64bcc7 | ||
|
|
dec670b7b7 | ||
|
|
ea4dc2358b | ||
|
|
fca7b7a1a7 | ||
|
|
ea0db9533a | ||
|
|
db85be76f7 | ||
|
|
5cb2e1b8f0 | ||
|
|
387f29e395 | ||
|
|
9ae8c85f4e | ||
|
|
c94a35d2c6 | ||
|
|
aacbd7889c | ||
|
|
d8020d06dd | ||
|
|
c556f0e21d | ||
|
|
ff1f62b6ad | ||
|
|
7cb4e0dccf | ||
|
|
3229e3151f | ||
|
|
53103c9ecf | ||
|
|
ac53deb39b | ||
|
|
cab6f486ba | ||
|
|
40e16fcc7e | ||
|
|
9c687f8704 | ||
|
|
340ceff131 | ||
|
|
76a116659e | ||
|
|
7c009080a5 | ||
|
|
61d7c53a58 | ||
|
|
08e65aff0f | ||
|
|
722616e3d0 | ||
|
|
0826f01150 | ||
|
|
fcb9ec1f6a | ||
|
|
5bb1589159 | ||
|
|
60e5cc5d75 | ||
|
|
bc410ad6ba | ||
|
|
35c358de6b | ||
|
|
23955699dc | ||
|
|
742103e4c2 | ||
|
|
6173f4610b | ||
|
|
1217acf264 | ||
|
|
0b8d887cef | ||
|
|
b760f172d0 | ||
|
|
bb8fc78760 | ||
|
|
d2317f725f | ||
|
|
693c3364b0 | ||
|
|
18bfc1abc9 | ||
|
|
717830ee99 | ||
|
|
2b001ec96c | ||
|
|
ebeb5bc520 |
5
.github/FUNDING.yml
vendored
5
.github/FUNDING.yml
vendored
@@ -1,5 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: yattee
|
||||
patreon: arekf
|
||||
custom: https://github.com/yattee/yattee/wiki/Donations
|
||||
74
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
74
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,74 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: "bug"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before You Submit Your Issue**
|
||||
- This is not a right place to ask questions. Use [Discussions](https://github.com/yattee/yattee/discussions) or contact directly via [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org)
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Guidelines
|
||||
description: Please ensure you've completed the following
|
||||
options:
|
||||
- label: I have searched the [issue tracker](https://github.com/yattee/yattee/issues) and I haven't found bug report like this
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: Describe what happened and the steps you have done to encounter the problem
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what you expected to happen
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Device & OS Version
|
||||
description: What device and operating system version are you running? You can find it in device settings
|
||||
placeholder: e.g. iPhone, iOS 16.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: App Build
|
||||
description: What build of the application are you running? You can find this in the bottom of the Settings page
|
||||
placeholder: e.g. 112
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: App Settings
|
||||
description: If the issue is related to some non-default configuration, please provide the settings you have changed
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Crash log
|
||||
description: If you are reporing a crash, attach crash log here
|
||||
placeholder: |
|
||||
1. Go to 'Settings'
|
||||
2. Click on 'Privacy'
|
||||
3. Scroll down and open 'Analytics & Improvements'
|
||||
4. Go to 'Analytics Data'
|
||||
4. See crash log
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots, Videos and other files
|
||||
description: Submit screenshots, video of the issue or other files that can help to understand the problem better. If you are reporting issue with playback in MPV, attach [logs from MPV](https://github.com/yattee/yattee/wiki/Getting-MPV-logs)
|
||||
validations:
|
||||
required: false
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest an new feature or an idea to improve the existing one
|
||||
labels: "enhancement"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before You Submit Your Issue**
|
||||
- This is not a right place to ask questions. Use [Discussions](https://github.com/yattee/yattee/discussions) or contact directly via [Discord](https://yattee.stream/discord) or [Matrix Channel](https://matrix.to/#/#Yattee:matrix.org)
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Guidelines
|
||||
description: Please ensure you've completed the following
|
||||
options:
|
||||
- label: I have searched the [issue tracker](https://github.com/yattee/yattee/issues) and I haven't found feature request like this
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Type
|
||||
description: Please select a type that fits this request. Choose multiple options, if applicable
|
||||
multiple: true
|
||||
options:
|
||||
- New feature
|
||||
- Improvement to existing feature
|
||||
- Usability improvement
|
||||
- Visual improvement
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: Provide a description of the feature that you're requesting to have implemented. You can attach screenshots or mockups to help to understand it better
|
||||
placeholder: The feature should work like this...
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_NAME: Yattee
|
||||
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
|
||||
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
|
||||
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
|
||||
TEAM_ID: ${{ secrets.TEAM_ID }}
|
||||
DEVELOPER_KEY_ID: ${{ secrets.DEVELOPER_KEY_ID }}
|
||||
DEVELOPER_KEY_ISSUER_ID: ${{ secrets.DEVELOPER_KEY_ISSUER_ID }}
|
||||
DEVELOPER_KEY_CONTENT: ${{ secrets.DEVELOPER_KEY_CONTENT }}
|
||||
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
|
||||
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
|
||||
DEVELOPER_APP_IDENTIFIER: ${{ secrets.DEVELOPER_APP_IDENTIFIER }}
|
||||
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
|
||||
|
||||
jobs:
|
||||
mac_notarized:
|
||||
name: Build and notarize macOS app (macOS 15, Xcode 16.4)
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to Direct with Developer ID
|
||||
run: |
|
||||
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '16.4'
|
||||
- name: Clear SPM cache
|
||||
run: |
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData
|
||||
rm -rf .build
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
- run: |
|
||||
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: |
|
||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-notarized-build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
if-no-files-found: error
|
||||
35
.github/workflows/bump-build.yml
vendored
35
.github/workflows/bump-build.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: Bump build number
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_NAME: Yattee
|
||||
|
||||
jobs:
|
||||
bump_build:
|
||||
name: Bump build number
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: bump_build
|
||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
|
||||
base: main
|
||||
title: Bump build number to ${{ env.BUILD_NUMBER }}
|
||||
|
||||
|
||||
108
.github/workflows/release.yml
vendored
108
.github/workflows/release.yml
vendored
@@ -1,108 +0,0 @@
|
||||
name: Build and release to TestFlight and GitHub
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
APP_NAME: Yattee
|
||||
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
|
||||
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
|
||||
ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }}
|
||||
TEAM_ID: ${{ secrets.TEAM_ID }}
|
||||
DEVELOPER_KEY_ID: ${{ secrets.DEVELOPER_KEY_ID }}
|
||||
DEVELOPER_KEY_ISSUER_ID: ${{ secrets.DEVELOPER_KEY_ISSUER_ID }}
|
||||
DEVELOPER_KEY_CONTENT: ${{ secrets.DEVELOPER_KEY_CONTENT }}
|
||||
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
|
||||
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
|
||||
DEVELOPER_APP_IDENTIFIER: ${{ secrets.DEVELOPER_APP_IDENTIFIER }}
|
||||
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
|
||||
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
|
||||
|
||||
jobs:
|
||||
testflight:
|
||||
strategy:
|
||||
matrix:
|
||||
# disabled mac beta lane
|
||||
# lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
lane: ['ios beta', 'tvos beta']
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to AppStore
|
||||
run: |
|
||||
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.0.1'
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.lane }} build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
if-no-files-found: ignore
|
||||
mac_notarized:
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1'
|
||||
bundler-cache: true
|
||||
cache-version: 1
|
||||
- name: Replace signing certificate to Direct with Developer ID
|
||||
run: |
|
||||
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.0.1'
|
||||
- name: Clear SPM cache
|
||||
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
- run: |
|
||||
echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: |
|
||||
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac notarized build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
if-no-files-found: error
|
||||
release:
|
||||
needs: ['testflight', 'mac_notarized']
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip
|
||||
commit: main
|
||||
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
|
||||
prerelease: true
|
||||
bodyFile: CHANGELOG.md
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -81,9 +81,6 @@ fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/builds
|
||||
fastlane/.env
|
||||
fastlane/*.p8
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
@@ -95,9 +92,5 @@ iOSInjectionProject/
|
||||
# SwiftLint Remote Config Cache
|
||||
.swiftlint/RemoteConfigCache
|
||||
|
||||
# User-specific xcconfig files
|
||||
Xcode-config/DEVELOPMENT_TEAM.xcconfig
|
||||
|
||||
# Bundler
|
||||
.bundle/
|
||||
Vendor/bundle/
|
||||
# disable simulator libraries - to be removed when replaced with framework for mpv
|
||||
Vendor/mpv/iOS/lib_sim
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml
|
||||
|
||||
disabled_rules:
|
||||
- conditional_returns_on_newline
|
||||
- identifier_name
|
||||
- opening_brace
|
||||
- number_separator
|
||||
- multiline_arguments
|
||||
- implicit_return
|
||||
- closure_end_indentation
|
||||
- discarded_notification_center_observer # Observer intentionally lives for app lifetime
|
||||
# Disable deprecated rules in favor of their renamed versions
|
||||
- operator_whitespace # renamed to function_name_whitespace
|
||||
- redundant_optional_initialization # renamed to implicit_optional_initialization
|
||||
|
||||
opt_in_rules:
|
||||
- function_name_whitespace
|
||||
- implicit_optional_initialization
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func badge(_ count: Text?) -> some View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
@ViewBuilder func badge(_ count: Text) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.badge(count)
|
||||
} else {
|
||||
HStack {
|
||||
content
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func listRowSeparator(_ visible: Bool) -> some View {
|
||||
#if !os(tvOS)
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 15.0, macOS 13.0, *) {
|
||||
content
|
||||
.listRowSeparator(visible ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func persistentSystemOverlays(_ visible: Bool) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.persistentSystemOverlays(visible ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func refreshable(action: @Sendable @escaping () async -> Void) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.refreshable(action: action)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollContentBackground(_ visibility: Bool) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollContentBackground(visibility ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollDismissesKeyboardImmediately() -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func scrollDismissesKeyboardInteractively() -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func tint(_ color: Color?) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.tint(color)
|
||||
} else {
|
||||
content.foregroundColor(color)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content
|
||||
.toolbarBackground(color, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content
|
||||
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
|
||||
// swiftlint:disable:next deployment_target
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content
|
||||
.toolbarColorScheme(colorScheme, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ import SwiftUI
|
||||
let blurEffect = UIBlurEffect(style: blurStyle)
|
||||
blurView.effect = blurEffect
|
||||
|
||||
if let vibrancyStyle {
|
||||
if let vibrancyStyle = vibrancyStyle {
|
||||
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
|
||||
} else {
|
||||
vibrancyView.effect = nil
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -1,50 +0,0 @@
|
||||
## Build 210
|
||||
|
||||
## What's Changed
|
||||
|
||||
* Trending and Hide Shorts was disabled due to changes in the video apps API
|
||||
* Fix iPad iOS 18 keyboard dismissal issue in search
|
||||
* Fix audio session interrupting other apps on launch
|
||||
* Fix thumbnail loading for video details
|
||||
* Fix thumbnail aspect ratio to prevent stretching and layout jumps
|
||||
* Fix keyboard shortcut conflict for Show Player command
|
||||
|
||||
## Previous builds
|
||||
|
||||
**Build 209:**
|
||||
* Fix Now Playing controls for both MPV and AVPlayer backends
|
||||
* Fix thumbnail sizing and aspect ratio issues in video cells (#896)
|
||||
* Adjust tvOS video cell dimensions for better layout
|
||||
* Fix playing videos from channel view in modal opened in video player
|
||||
* Fix audio track label showing "Original" instead of "Unknown"
|
||||
* Simplify fullscreen handling for iOS
|
||||
* Add macOS-specific entitlements for MPV backend
|
||||
|
||||
**Build 208:**
|
||||
* Enable resizable windows on iPad
|
||||
* Improve iPad UI behavior and settings layout
|
||||
* Fix horizontal content extending behind sidebar on iPad
|
||||
* Add proper padding to player controls and video details in non-fullscreen iPad windows
|
||||
* Hide orientation lock controls on iPad (not applicable for iPad)
|
||||
* Fix video player overlay to respect window fullscreen state
|
||||
* Allow video player to extend into safe areas
|
||||
* Fix iOS Now Playing Info Center integration for AVPlayer backend
|
||||
* Fix button styling and safe area handling
|
||||
* Fix picker label visibility in settings
|
||||
* Improve video layer rendering
|
||||
* Add macOS 26 compatibility for search UI
|
||||
* Improve playback settings UI controls
|
||||
* Add retry mechanism for file load errors (both MPV and AVPlayer)
|
||||
* Fix MPV player vertical positioning in fullscreen mode
|
||||
* Improve player controls visibility and layout
|
||||
* Add nil safety checks for stream resolution and playback time handling
|
||||
* Refactor dirty region handling in MPV video rendering
|
||||
* Remove verbose logging from MPV rendering
|
||||
* Improve layout stability and reduce unwanted animations
|
||||
* Simplify stream description by removing instance info
|
||||
* Update default visible sections from trending to popular
|
||||
* Update MPVKit dependency
|
||||
* Update Ruby dependencies
|
||||
* Fix SwiftLint and SwiftFormat violations
|
||||
* Fix main actor isolation warnings
|
||||
* Update GitHub Actions to latest macOS and Xcode versions
|
||||
@@ -1,11 +0,0 @@
|
||||
import AVKit
|
||||
|
||||
extension AVPlayerViewController {
|
||||
func enterFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
static var debug: Color {
|
||||
#if DEBUG
|
||||
return Color(
|
||||
red: .random(in: 0 ... 1),
|
||||
green: .random(in: 0 ... 1),
|
||||
blue: .random(in: 0 ... 1)
|
||||
)
|
||||
#else
|
||||
return Color(.clear)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func formattedAsPlaybackTime(allowZero: Bool = false, forceHours: Bool = false) -> String? {
|
||||
guard allowZero || !isZero, isFinite else {
|
||||
func formattedAsPlaybackTime() -> String? {
|
||||
guard !isZero, isFinite else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.allowedUnits = self >= (60 * 60) || forceHours ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.allowedUnits = self >= (60 * 60) ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
|
||||
return formatter.string(from: self)
|
||||
@@ -19,9 +19,7 @@ extension Double {
|
||||
let date = Date(timeIntervalSince1970: self)
|
||||
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.dateTimeStyle = .named
|
||||
formatter.unitsStyle = .short
|
||||
formatter.formattingContext = .standalone
|
||||
formatter.unitsStyle = .full
|
||||
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import CoreData
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
|
||||
///
|
||||
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
|
||||
/// - Throws: An error if anything went wrong executing the batch deletion.
|
||||
func executeAndMergeChanges(_ batchDeleteRequest: NSBatchDeleteRequest) throws {
|
||||
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
|
||||
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
extension NSObject {
|
||||
class func swizzle(origSelector: Selector, withSelector: Selector, forClass: AnyClass) {
|
||||
let originalMethod = class_getInstanceMethod(forClass, origSelector)
|
||||
let swizzledMethod = class_getInstanceMethod(forClass, withSelector)
|
||||
method_exchangeImplementations(originalMethod!, swizzledMethod!)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let accountConfigurationComplete = Notification.Name("accountConfigurationComplete")
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Sequence where Iterator.Element: Hashable {
|
||||
func unique() -> [Iterator.Element] {
|
||||
var seen: Set<Iterator.Element> = []
|
||||
return filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
@@ -7,29 +7,4 @@ extension String {
|
||||
}
|
||||
return replacingCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
func replacingMatches(regex: String, replacementStringClosure: (String) -> String?) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: regex) else {
|
||||
return self
|
||||
}
|
||||
|
||||
let results = regex.matches(in: self, range: NSRange(startIndex..., in: self))
|
||||
|
||||
var outputText = self
|
||||
|
||||
for match in results.reversed() {
|
||||
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
|
||||
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
||||
let rangeBounds = match.range(at: rangeIndex)
|
||||
|
||||
guard let range = Range(rangeBounds, in: self) else {
|
||||
continue
|
||||
}
|
||||
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
||||
|
||||
outputText = outputText.replacingOccurrences(of: matchingGroup, with: replacement, range: range)
|
||||
}
|
||||
}
|
||||
return outputText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func localized(_ comment: String = "") -> Self {
|
||||
NSLocalizedString(self, tableName: "Localizable", bundle: .main, comment: comment)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var replacingHTMLEntities: String {
|
||||
do {
|
||||
return try NSAttributedString(data: Data(utf8), options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
], documentAttributes: nil).string
|
||||
} catch {
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension UIDevice {
|
||||
/// A Boolean value indicating whether the device has cellular data capabilities (true) or not (false).
|
||||
var hasCellularCapabilites: Bool {
|
||||
var addrs: UnsafeMutablePointer<ifaddrs>?
|
||||
var cursor: UnsafeMutablePointer<ifaddrs>?
|
||||
|
||||
defer { freeifaddrs(addrs) }
|
||||
|
||||
guard getifaddrs(&addrs) == 0 else { return false }
|
||||
cursor = addrs
|
||||
|
||||
while cursor != nil {
|
||||
guard
|
||||
let utf8String = cursor?.pointee.ifa_name,
|
||||
let name = NSString(utf8String: utf8String),
|
||||
name == "pdp_ip0"
|
||||
else {
|
||||
cursor = cursor?.pointee.ifa_next
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
extension UIViewController {
|
||||
@objc var swizzle_prefersHomeIndicatorAutoHidden: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public class func swizzleHomeIndicatorProperty() {
|
||||
swizzle(
|
||||
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||
forClass: UIViewController.self
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
||||
var urlAbsoluteString = absoluteString
|
||||
|
||||
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
|
||||
return self
|
||||
}
|
||||
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
|
||||
if absoluteString.contains("://") {
|
||||
return URL(string: urlAbsoluteString)
|
||||
}
|
||||
|
||||
return URL(string: "\(urlProtocol)://\(urlAbsoluteString)")
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,12 @@ extension View {
|
||||
}
|
||||
|
||||
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
|
||||
overlay(
|
||||
Rectangle()
|
||||
.frame(width: nil, height: height, alignment: .top)
|
||||
.foregroundColor(color)
|
||||
.ignoresSafeArea(.all, edges: .horizontal),
|
||||
alignment: edge
|
||||
)
|
||||
overlay(Rectangle().frame(width: nil, height: height, alignment: .top)
|
||||
.foregroundColor(color), alignment: edge)
|
||||
}
|
||||
|
||||
private func horizontalEdgeBorder(_ edge: Alignment, width: Double, color: Color) -> some View {
|
||||
overlay(
|
||||
Rectangle()
|
||||
.frame(width: width, height: nil, alignment: .leading)
|
||||
.foregroundColor(color),
|
||||
alignment: edge
|
||||
)
|
||||
overlay(Rectangle().frame(width: width, height: nil, alignment: .leading)
|
||||
.foregroundColor(color), alignment: edge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import Foundation
|
||||
extension ChannelPlaylist {
|
||||
static var fixture: ChannelPlaylist {
|
||||
ChannelPlaylist(
|
||||
id: "fixture-channel-playlist",
|
||||
title: "Playlist with a very long title that will not fit easily in the screen",
|
||||
thumbnailURL: URL(string: "https://i.ytimg.com/vi/hT_nvWreIhg/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLAAD21_-Bo6Td1z3cV-UFyoi1flEg")!,
|
||||
channel: Video.fixture.channel,
|
||||
|
||||
@@ -12,7 +12,7 @@ extension Comment {
|
||||
likeCount: 30032,
|
||||
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut luctus feugiat mi, suscipit pharetra lectus dapibus vel. Vivamus orci erat, sagittis sit amet dui vel, feugiat cursus ante. Pellentesque eget orci tortor. Suspendisse pulvinar orci tortor, eu scelerisque neque consequat nec. Aliquam sit amet turpis et nunc placerat finibus eget sit amet justo. Nullam tincidunt ornare neque. Donec ornare, arcu at elementum pulvinar, urna elit pharetra diam, vel ultrices lacus diam at lorem. Sed vel maximus dolor. Morbi massa est, interdum quis justo sit amet, dapibus bibendum tellus. Integer at purus nec neque tincidunt convallis sit amet eu odio. Duis et ante vitae sem tincidunt facilisis sit amet ac mauris. Quisque varius non nisi vel placerat. Nulla orci metus, imperdiet ac accumsan sed, pellentesque eget nisl. Praesent a suscipit lacus, ut finibus orci. Nulla ut eros commodo, fermentum purus at, porta leo. In finibus luctus nulla, eget posuere eros mollis vel. ",
|
||||
repliesPage: "some url",
|
||||
channel: .init(app: .invidious, id: "", name: "")
|
||||
channel: .init(id: "", name: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import Foundation
|
||||
|
||||
extension Instance {
|
||||
static var fixture: Instance {
|
||||
Instance(app: .invidious, name: "Home", apiURLString: "https://invidious.home.net")
|
||||
Instance(app: .invidious, name: "Home", apiURL: "https://invidious.home.net")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,9 @@ extension Video {
|
||||
static var fixtureChannelID: Channel.ID = "channel-fixture"
|
||||
|
||||
static var fixture: Video {
|
||||
let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
|
||||
|
||||
return Video(
|
||||
app: .invidious,
|
||||
videoID: fixtureID,
|
||||
title: "Relaxing Piano Music to feel good",
|
||||
author: "Fancy Videotuber",
|
||||
@@ -20,14 +17,10 @@ extension Video {
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
app: .invidious,
|
||||
id: fixtureChannelID,
|
||||
name: "The Channel",
|
||||
bannerURL: URL(string: bannerURL)!,
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
description: "The best channel that ever existed.\nThe best channel that ever existed. The best channel that ever existed. The best channel that ever existed. The best channel that ever existed. ",
|
||||
subscriptionsCount: 2300,
|
||||
totalViews: 3_260_378_817,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: [],
|
||||
@@ -36,53 +29,7 @@ extension Video {
|
||||
publishedAt: Date(),
|
||||
likes: 37333,
|
||||
dislikes: 30,
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
|
||||
related: [.otherFixture],
|
||||
chapters: [
|
||||
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
|
||||
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
|
||||
.init(title: "Short", image: chapterImageURL, start: 60)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static var otherFixture: Video {
|
||||
let bannerURL = "https://yt3.ggpht.com/SQiRareBDrV2Z6A30HSD0iUABOGysanmKLtaJq7lJ_ME-MtoLb3O61QdlJfH2KhSOA0eKPr_=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj"
|
||||
let thumbnailURL = "https://yt3.ggpht.com/ytc/AKedOLR-pT_JEsz_hcaA4Gjx8DHcqJ8mS42aTRqcVy6P7w=s88-c-k-c0x00ffffff-no-rj-mo"
|
||||
let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!
|
||||
|
||||
return Video(
|
||||
app: .invidious,
|
||||
videoID: fixtureID + fixtureID,
|
||||
title: "Relaxing Piano Music to feel good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
views: 21534,
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
app: .invidious,
|
||||
id: fixtureChannelID + fixtureChannelID,
|
||||
name: "The Channel",
|
||||
bannerURL: URL(string: bannerURL)!,
|
||||
thumbnailURL: URL(string: thumbnailURL)!,
|
||||
subscriptionsCount: 2300,
|
||||
totalViews: 3_260_378_817,
|
||||
videos: []
|
||||
),
|
||||
thumbnails: [],
|
||||
live: false,
|
||||
upcoming: false,
|
||||
publishedAt: Date(),
|
||||
likes: 37333,
|
||||
dislikes: 30,
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"],
|
||||
chapters: [
|
||||
.init(title: "A good chapter name", image: chapterImageURL, start: 20),
|
||||
.init(title: "Other fine but incredibly too long chapter name, I don't know what else to say", image: chapterImageURL, start: 30),
|
||||
.init(title: "Short", image: chapterImageURL, start: 60)
|
||||
]
|
||||
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,44 @@ import SwiftUI
|
||||
struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environmentObject(AccountsModel())
|
||||
.environmentObject(CommentsModel())
|
||||
.environmentObject(InstancesModel())
|
||||
.environmentObject(invidious)
|
||||
.environmentObject(NavigationModel())
|
||||
.environmentObject(PipedAPI())
|
||||
.environmentObject(player)
|
||||
.environmentObject(PlaylistsModel())
|
||||
.environmentObject(RecentsModel())
|
||||
.environmentObject(SearchModel())
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(ThumbnailsModel())
|
||||
}
|
||||
|
||||
private var invidious: InvidiousAPI {
|
||||
let api = InvidiousAPI()
|
||||
|
||||
api.validInstance = true
|
||||
api.signedIn = true
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
private var player: PlayerModel {
|
||||
let player = PlayerModel()
|
||||
|
||||
player.currentItem = PlayerQueueItem(Video.fixture)
|
||||
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
private var subscriptions: SubscriptionsModel {
|
||||
let subscriptions = SubscriptionsModel()
|
||||
|
||||
subscriptions.channels = Video.allFixtures.map { $0.channel }
|
||||
|
||||
return subscriptions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
Gemfile
6
Gemfile
@@ -1,6 +0,0 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem 'fastlane'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
231
Gemfile.lock
231
Gemfile.lock
@@ -1,231 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1182.0)
|
||||
aws-sdk-core (3.237.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.117.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.203.1)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.3.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.17.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
optparse (0.8.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.2)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
arm64-darwin-23
|
||||
arm64-darwin-24
|
||||
arm64-darwin-25
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-darwin-21
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.22
|
||||
0
ISSUE_TEMPLATE.md
Normal file
0
ISSUE_TEMPLATE.md
Normal file
@@ -5,83 +5,53 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
static var bridge = AccountsBridge()
|
||||
|
||||
let id: String
|
||||
var app: VideosApp?
|
||||
let instanceID: String?
|
||||
var name: String
|
||||
let urlString: String
|
||||
var username: String
|
||||
var password: String?
|
||||
let instanceID: String
|
||||
var name: String?
|
||||
let url: String
|
||||
let username: String
|
||||
let password: String?
|
||||
var token: String?
|
||||
let anonymous: Bool
|
||||
let country: String?
|
||||
let region: String?
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
app: VideosApp? = nil,
|
||||
instanceID: String? = nil,
|
||||
name: String? = nil,
|
||||
urlString: String? = nil,
|
||||
url: String? = nil,
|
||||
username: String? = nil,
|
||||
password: String? = nil,
|
||||
anonymous: Bool = false,
|
||||
country: String? = nil,
|
||||
region: String? = nil
|
||||
token: String? = nil,
|
||||
anonymous: Bool = false
|
||||
) {
|
||||
self.anonymous = anonymous
|
||||
|
||||
self.id = id ?? (anonymous ? "anonymous-\(instanceID ?? urlString ?? UUID().uuidString)" : UUID().uuidString)
|
||||
self.instanceID = instanceID
|
||||
self.name = name ?? ""
|
||||
self.urlString = urlString ?? ""
|
||||
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
|
||||
self.instanceID = instanceID ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url ?? ""
|
||||
self.username = username ?? ""
|
||||
self.token = token
|
||||
self.password = password ?? ""
|
||||
self.country = country
|
||||
self.region = region
|
||||
self.app = app ?? instance.app
|
||||
}
|
||||
|
||||
var url: URL! {
|
||||
URL(string: urlString)
|
||||
}
|
||||
|
||||
var token: String? {
|
||||
KeychainModel.shared.getAccountKey(self, "token")
|
||||
}
|
||||
|
||||
var credentials: (String?, String?) {
|
||||
AccountsModel.getCredentials(self)
|
||||
}
|
||||
|
||||
var instance: Instance! {
|
||||
InstancesModel.shared.find(instanceID) ?? Instance(app: app ?? .invidious, name: urlString, apiURLString: urlString)
|
||||
Defaults[.instances].first { $0.id == instanceID }
|
||||
}
|
||||
|
||||
var isPublic: Bool {
|
||||
instanceID.isNil
|
||||
}
|
||||
var shortUsername: String {
|
||||
guard username.count > 10 else {
|
||||
return username
|
||||
}
|
||||
|
||||
var isPublicAddedToCustom: Bool {
|
||||
InstancesModel.shared.findByURLString(urlString) != nil
|
||||
let index = username.index(username.startIndex, offsetBy: 11)
|
||||
return String(username[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard !isPublic else {
|
||||
return name
|
||||
}
|
||||
|
||||
let (username, _) = credentials
|
||||
return username ?? name
|
||||
}
|
||||
|
||||
var urlHost: String {
|
||||
URLComponents(url: url, resolvingAgainstBaseURL: false)?.host ?? ""
|
||||
(name != nil && name!.isEmpty) ? shortUsername : name!
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(username)
|
||||
}
|
||||
|
||||
var feedCacheKey: String {
|
||||
"feed-\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class AccountValidator: Service {
|
||||
let app: Binding<VideosApp?>
|
||||
let app: Binding<VideosApp>
|
||||
let url: String
|
||||
let account: Account!
|
||||
|
||||
@@ -14,10 +13,8 @@ final class AccountValidator: Service {
|
||||
var isValidating: Binding<Bool>
|
||||
var error: Binding<String?>?
|
||||
|
||||
private var appsToValidateInstance = VideosApp.allCases
|
||||
|
||||
init(
|
||||
app: Binding<VideosApp?>,
|
||||
app: Binding<VideosApp>,
|
||||
url: String,
|
||||
account: Account? = nil,
|
||||
id: Binding<String>,
|
||||
@@ -44,217 +41,113 @@ final class AccountValidator: Service {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure("/api/v1/auth/feed", requestMethods: [.get]) {
|
||||
guard self.account != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
$0.headers["Cookie"] = self.invidiousCookieHeader
|
||||
}
|
||||
|
||||
configure("/login", requestMethods: [.post]) {
|
||||
$0.headers["Content-Type"] = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
func instanceValidationResource(_ app: VideosApp) -> Resource {
|
||||
switch app {
|
||||
case .invidious:
|
||||
return resource("/api/v1/videos/dQw4w9WgXcQ")
|
||||
|
||||
case .piped:
|
||||
return resource("/streams/dQw4w9WgXcQ")
|
||||
|
||||
case .peerTube:
|
||||
// TODO: fixme
|
||||
return resource("")
|
||||
|
||||
case .local:
|
||||
return resource("")
|
||||
}
|
||||
}
|
||||
|
||||
func validateInstance() {
|
||||
reset()
|
||||
|
||||
guard let app = appsToValidateInstance.popLast() else { return }
|
||||
tryValidatingUsing(app)
|
||||
}
|
||||
|
||||
func tryValidatingUsing(_ app: VideosApp) {
|
||||
instanceValidationResource(app)
|
||||
neverGonnaGiveYouUp
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
guard self.url == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !response.json.isEmpty else {
|
||||
if app == .piped {
|
||||
if response.text.contains("property=\"og:title\" content=\"Piped\"") {
|
||||
self.isValid.wrappedValue = false
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
self.error?.wrappedValue = "Trying to use Piped front-end URL, you need to use URL for Piped API instead"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let nextApp = self.appsToValidateInstance.popLast() else {
|
||||
self.isValid.wrappedValue = false
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
return
|
||||
}
|
||||
|
||||
self.tryValidatingUsing(nextApp)
|
||||
return
|
||||
}
|
||||
|
||||
let json = response.json.dictionaryValue
|
||||
let author = app == .invidious ? json["author"] : json["uploader"]
|
||||
let author = self.app.wrappedValue == .invidious ? json["author"] : json["uploader"]
|
||||
|
||||
if author == "Rick Astley" {
|
||||
self.app.wrappedValue = app
|
||||
self.isValid.wrappedValue = true
|
||||
self.error?.wrappedValue = nil
|
||||
} else {
|
||||
self.isValid.wrappedValue = false
|
||||
}
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
.onFailure { error in
|
||||
guard self.url == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.appsToValidateInstance.isEmpty {
|
||||
self.isValidating.wrappedValue = false
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValid.wrappedValue = false
|
||||
self.error?.wrappedValue = error.userMessage
|
||||
} else {
|
||||
guard let app = self.appsToValidateInstance.popLast() else { return }
|
||||
self.tryValidatingUsing(app)
|
||||
}
|
||||
self.isValid.wrappedValue = false
|
||||
self.error?.wrappedValue = error.userMessage
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAccount() {
|
||||
reset()
|
||||
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
validateInvidiousAccount()
|
||||
case .piped:
|
||||
validatePipedAccount()
|
||||
default:
|
||||
setValidationResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
func validateInvidiousAccount() {
|
||||
guard let username = account?.username,
|
||||
let password = account?.password
|
||||
else {
|
||||
setValidationResult(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
AF
|
||||
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
||||
.redirect(using: .doNotFollow)
|
||||
.response { response in
|
||||
guard let headers = response.response?.headers,
|
||||
let cookies = headers["Set-Cookie"]
|
||||
else {
|
||||
self.setValidationResult(false)
|
||||
accountRequest
|
||||
.onSuccess { response in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
||||
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
||||
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
||||
else {
|
||||
self.setValidationResult(false)
|
||||
switch self.app.wrappedValue {
|
||||
case .invidious:
|
||||
self.isValid.wrappedValue = true
|
||||
case .piped:
|
||||
let error = response.json.dictionaryValue["error"]?.string
|
||||
let token = response.json.dictionaryValue["token"]?.string
|
||||
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
|
||||
self.error!.wrappedValue = error
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
let matchRange = match.range(withName: "sid")
|
||||
|
||||
if let substringRange = Range(matchRange, in: cookies) {
|
||||
let sid = String(cookies[substringRange])
|
||||
if !sid.isEmpty {
|
||||
self.setValidationResult(true)
|
||||
}
|
||||
} else {
|
||||
self.setValidationResult(false)
|
||||
}
|
||||
self.isValid.wrappedValue = false
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
func validatePipedAccount() {
|
||||
guard let request = accountRequest else {
|
||||
setValidationResult(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
request.onSuccess { response in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
switch self.app.wrappedValue {
|
||||
case .invidious:
|
||||
self.isValid.wrappedValue = true
|
||||
case .piped:
|
||||
let error = response.json.dictionaryValue["error"]?.string
|
||||
let token = response.json.dictionaryValue["token"]?.string
|
||||
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
|
||||
self.error!.wrappedValue = error
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isValid.wrappedValue = false
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.isValidated.wrappedValue = true
|
||||
self.isValidating.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
func setValidationResult(_ result: Bool) {
|
||||
isValid.wrappedValue = result
|
||||
isValidated.wrappedValue = true
|
||||
isValidating.wrappedValue = false
|
||||
}
|
||||
|
||||
var accountRequest: Siesta.Request? {
|
||||
var accountRequest: Request {
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
guard let password = account.password else { return nil }
|
||||
return login.request(.post, urlEncoded: ["email": account.username, "password": password])
|
||||
return feed.load()
|
||||
case .piped:
|
||||
return login.request(.post, json: ["username": account.username, "password": account.password])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
appsToValidateInstance = VideosApp.allCases
|
||||
app.wrappedValue = nil
|
||||
isValid.wrappedValue = false
|
||||
isValidated.wrappedValue = false
|
||||
isValidating.wrappedValue = false
|
||||
error?.wrappedValue = nil
|
||||
}
|
||||
|
||||
var invidiousCookieHeader: String {
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource("/login")
|
||||
}
|
||||
|
||||
var feed: Resource {
|
||||
resource("/api/v1/auth/feed")
|
||||
}
|
||||
|
||||
var videoResourceBasePath: String {
|
||||
app.wrappedValue == .invidious ? "/api/v1/videos" : "/streams"
|
||||
}
|
||||
|
||||
@@ -6,32 +6,15 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value else {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the urlString to check for embedded username and password
|
||||
var sanitizedUrlString = value.urlString
|
||||
if var urlComponents = URLComponents(string: value.urlString) {
|
||||
if let user = urlComponents.user, let password = urlComponents.password {
|
||||
// Sanitize the embedded username and password
|
||||
let sanitizedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
|
||||
let sanitizedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password
|
||||
|
||||
// Update the URL components with sanitized credentials
|
||||
urlComponents.user = sanitizedUser
|
||||
urlComponents.password = sanitizedPassword
|
||||
|
||||
// Reconstruct the sanitized URL
|
||||
sanitizedUrlString = urlComponents.string ?? value.urlString
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID ?? "",
|
||||
"name": value.name,
|
||||
"apiURL": sanitizedUrlString,
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"apiURL": value.url,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
@@ -39,7 +22,7 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object,
|
||||
let object = object,
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["apiURL"],
|
||||
@@ -51,6 +34,6 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
let name = object["name"] ?? ""
|
||||
let password = object["password"]
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, urlString: url, username: username, password: password)
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,10 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
final class AccountsModel: ObservableObject {
|
||||
static let shared = AccountsModel()
|
||||
|
||||
@Published private(set) var current: Account!
|
||||
|
||||
@Published private var invidious = InvidiousAPI()
|
||||
@Published private var piped = PipedAPI()
|
||||
@Published private var peerTube = PeerTubeAPI()
|
||||
|
||||
@Published var publicAccount: Account?
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
@@ -24,7 +19,7 @@ final class AccountsModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Self.find(id)
|
||||
return AccountsModel.find(id)
|
||||
}
|
||||
|
||||
var any: Account? {
|
||||
@@ -32,18 +27,11 @@ final class AccountsModel: ObservableObject {
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance?.app ?? .local
|
||||
current?.instance?.app ?? .invidious
|
||||
}
|
||||
|
||||
var api: VideosAPI! {
|
||||
switch app {
|
||||
case .piped:
|
||||
return piped
|
||||
case .invidious:
|
||||
return invidious
|
||||
default:
|
||||
return peerTube
|
||||
}
|
||||
var api: VideosAPI {
|
||||
app == .piped ? piped : invidious
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
@@ -64,19 +52,6 @@ final class AccountsModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func find(_ id: Account.ID) -> Account? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
func configureAccount() {
|
||||
if let account = lastUsed ??
|
||||
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
||||
InstancesModel.shared.all.first?.anonymousAccount
|
||||
{
|
||||
setCurrent(account)
|
||||
}
|
||||
}
|
||||
|
||||
func setCurrent(_ account: Account! = nil) {
|
||||
guard account != current else {
|
||||
return
|
||||
@@ -85,63 +60,40 @@ final class AccountsModel: ObservableObject {
|
||||
current = account
|
||||
|
||||
guard !account.isNil else {
|
||||
current = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch account.instance.app {
|
||||
case .local:
|
||||
return
|
||||
case .invidious:
|
||||
invidious.setAccount(account)
|
||||
case .piped:
|
||||
piped.setAccount(account)
|
||||
case .peerTube:
|
||||
peerTube.setAccount(account)
|
||||
}
|
||||
|
||||
Defaults[.lastAccountIsPublic] = account.isPublic
|
||||
|
||||
if !account.isPublic {
|
||||
Defaults[.lastAccountID] = account.anonymous ? nil : account.id
|
||||
Defaults[.lastInstanceID] = account.instanceID
|
||||
}
|
||||
Defaults[.lastAccountID] = account.anonymous ? nil : account.id
|
||||
Defaults[.lastInstanceID] = account.instanceID
|
||||
}
|
||||
|
||||
static func find(_ id: Account.ID) -> Account? {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account {
|
||||
let account = Account(
|
||||
instanceID: instance.id,
|
||||
name: name,
|
||||
url: instance.apiURL,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
setCredentials(account, username: username, password: password)
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
static func remove(_ account: Account) {
|
||||
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
|
||||
let account = Defaults[.accounts][accountIndex]
|
||||
KeychainModel.shared.removeAccountKeys(account)
|
||||
Defaults[.accounts].remove(at: accountIndex)
|
||||
}
|
||||
}
|
||||
|
||||
static func setToken(_ account: Account, _ token: String) {
|
||||
KeychainModel.shared.updateAccountKey(account, "token", token)
|
||||
}
|
||||
|
||||
static func setCredentials(_ account: Account, username: String, password: String) {
|
||||
KeychainModel.shared.updateAccountKey(account, "username", username)
|
||||
KeychainModel.shared.updateAccountKey(account, "password", password)
|
||||
}
|
||||
|
||||
static func getCredentials(_ account: Account) -> (String?, String?) {
|
||||
(
|
||||
KeychainModel.shared.getAccountKey(account, "username"),
|
||||
KeychainModel.shared.getAccountKey(account, "password")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,35 +7,23 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
let app: VideosApp
|
||||
let id: String
|
||||
let name: String
|
||||
let apiURLString: String
|
||||
let apiURL: String
|
||||
var frontendURL: String?
|
||||
var proxiesVideos: Bool
|
||||
var invidiousCompanion: Bool
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
|
||||
init(app: VideosApp, id: String? = nil, name: String, apiURL: String, frontendURL: String? = nil) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name ?? app.rawValue
|
||||
self.apiURLString = apiURLString
|
||||
self.name = name
|
||||
self.apiURL = apiURL
|
||||
self.frontendURL = frontendURL
|
||||
self.proxiesVideos = proxiesVideos
|
||||
self.invidiousCompanion = invidiousCompanion
|
||||
}
|
||||
|
||||
var apiURL: URL! {
|
||||
URL(string: apiURLString)
|
||||
}
|
||||
|
||||
var anonymous: VideosAPI! {
|
||||
var anonymous: VideosAPI {
|
||||
switch app {
|
||||
case .invidious:
|
||||
return InvidiousAPI(account: anonymousAccount)
|
||||
case .piped:
|
||||
return PipedAPI(account: anonymousAccount)
|
||||
case .peerTube:
|
||||
return PeerTubeAPI(account: anonymousAccount)
|
||||
case .local:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,23 +32,23 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
var longDescription: String {
|
||||
name.isEmpty ? "\(app.name) - \(apiURLString)" : "\(app.name) - \(name) (\(apiURLString))"
|
||||
name.isEmpty ? "\(app.name) - \(apiURL)" : "\(app.name) - \(name) (\(apiURL))"
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
name.isEmpty ? apiURLString : name
|
||||
name.isEmpty ? apiURL : name
|
||||
}
|
||||
|
||||
var anonymousAccount: Account {
|
||||
Account(instanceID: id, name: "Anonymous".localized(), urlString: apiURLString, anonymous: true)
|
||||
Account(instanceID: id, name: "Anonymous", url: apiURL, anonymous: true)
|
||||
}
|
||||
|
||||
var urlComponents: URLComponents {
|
||||
URLComponents(url: apiURL, resolvingAgainstBaseURL: false)!
|
||||
URLComponents(string: apiURL)!
|
||||
}
|
||||
|
||||
var frontendHost: String? {
|
||||
guard let url = app == .invidious ? apiURLString : frontendURL else {
|
||||
guard let url = app == .invidious ? apiURL : frontendURL else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -70,8 +58,4 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(apiURL)
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
AccountsModel.shared.all.filter { $0.instanceID == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value else {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,16 +14,14 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
"app": value.app.rawValue,
|
||||
"id": value.id,
|
||||
"name": value.name,
|
||||
"apiURL": value.apiURLString,
|
||||
"frontendURL": value.frontendURL ?? "",
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
|
||||
"apiURL": value.apiURL,
|
||||
"frontendURL": value.frontendURL ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object,
|
||||
let object = object,
|
||||
let app = VideosApp(rawValue: object["app"] ?? ""),
|
||||
let id = object["id"],
|
||||
let apiURL = object["apiURL"]
|
||||
@@ -32,10 +30,8 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||
let invidiousCompanion = object["invidiousCompanion"] == "true"
|
||||
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
return Instance(app: app, id: id, name: name, apiURL: apiURL, frontendURL: frontendURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
final class InstancesModel: ObservableObject {
|
||||
static var shared = InstancesModel()
|
||||
|
||||
var all: [Instance] {
|
||||
static var all: [Instance] {
|
||||
Defaults[.instances]
|
||||
}
|
||||
|
||||
var forPlayer: Instance? {
|
||||
static var forPlayer: Instance? {
|
||||
guard let id = Defaults[.playerInstanceID] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Self.shared.find(id)
|
||||
return InstancesModel.find(id)
|
||||
}
|
||||
|
||||
var lastUsed: Instance? {
|
||||
@@ -21,10 +19,10 @@ final class InstancesModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Self.shared.find(id)
|
||||
return InstancesModel.find(id)
|
||||
}
|
||||
|
||||
func find(_ id: Instance.ID?) -> Instance? {
|
||||
static func find(_ id: Instance.ID?) -> Instance? {
|
||||
guard id != nil else {
|
||||
return nil
|
||||
}
|
||||
@@ -32,34 +30,20 @@ final class InstancesModel: ObservableObject {
|
||||
return Defaults[.instances].first { $0.id == id }
|
||||
}
|
||||
|
||||
func findByURLString(_ urlString: String?) -> Instance? {
|
||||
guard let urlString else { return nil }
|
||||
|
||||
return Defaults[.instances].first { $0.apiURLString == urlString }
|
||||
}
|
||||
|
||||
func accounts(_ id: Instance.ID?) -> [Account] {
|
||||
static func accounts(_ id: Instance.ID?) -> [Account] {
|
||||
Defaults[.accounts].filter { $0.instanceID == id }
|
||||
}
|
||||
|
||||
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
static func add(app: VideosApp, name: String, url: String) -> Instance {
|
||||
let instance = Instance(
|
||||
app: app, id: id, name: name, apiURLString: standardizedURL(url)
|
||||
app: app, id: UUID().uuidString, name: name, apiURL: standardizedURL(url)
|
||||
)
|
||||
Defaults[.instances].append(instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func insert(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
if let instance = Defaults[.instances].first(where: { $0.apiURL.absoluteString == standardizedURL(url) }) {
|
||||
return instance
|
||||
}
|
||||
|
||||
return add(id: id, app: app, name: name, url: url)
|
||||
}
|
||||
|
||||
func setFrontendURL(_ instance: Instance, _ url: String) {
|
||||
static func setFrontendURL(_ instance: Instance, _ url: String) {
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.frontendURL = standardizedURL(url)
|
||||
@@ -68,40 +52,19 @@ final class InstancesModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setProxiesVideos(_ instance: Instance, _ proxiesVideos: Bool) {
|
||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.proxiesVideos = proxiesVideos
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func setInvidiousCompanion(_ instance: Instance, _ invidiousCompanion: Bool) {
|
||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.invidiousCompanion = invidiousCompanion
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func remove(_ instance: Instance) {
|
||||
let accounts = accounts(instance.id)
|
||||
static func remove(_ instance: Instance) {
|
||||
let accounts = Self.accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
Defaults[.instances].remove(at: index)
|
||||
accounts.forEach { AccountsModel.remove($0) }
|
||||
}
|
||||
}
|
||||
|
||||
func standardizedURL(_ url: String) -> String {
|
||||
if url.count > 7, url.last == "/" {
|
||||
static func standardizedURL(_ url: String) -> String {
|
||||
if url.last == "/" {
|
||||
return String(url.dropLast())
|
||||
} else {
|
||||
return url
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
@@ -10,15 +9,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> InvidiousAPI {
|
||||
.init(account: Instance(app: .invidious, apiURLString: url.absoluteString).anonymousAccount)
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else { return false }
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
@Published var validInstance = true
|
||||
@Published var signedIn = false
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
@@ -33,16 +25,56 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
signedIn = false
|
||||
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
|
||||
if !account.anonymous {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
validateInstance()
|
||||
validateSID()
|
||||
}
|
||||
|
||||
func validateInstance() {
|
||||
guard !validInstance else {
|
||||
return
|
||||
}
|
||||
|
||||
home?
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
self.validInstance = true
|
||||
}
|
||||
.onFailure { _ in
|
||||
self.validInstance = false
|
||||
}
|
||||
}
|
||||
|
||||
func validateSID() {
|
||||
guard !signedIn else {
|
||||
return
|
||||
}
|
||||
|
||||
feed?
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
self.signedIn = true
|
||||
}
|
||||
.onFailure { _ in
|
||||
self.signedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
if let cookie = self.cookieHeader {
|
||||
$0.headers["Cookie"] = cookie
|
||||
if !self.account.username.isEmpty {
|
||||
$0.headers["Cookie"] = self.cookieHeader
|
||||
}
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
@@ -61,15 +93,13 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
|
||||
let type = json.dictionaryValue["type"]?.string
|
||||
let type = json.dictionaryValue["type"]?.stringValue
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
}
|
||||
if type == "playlist" {
|
||||
} else if type == "playlist" {
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
}
|
||||
if type == "video" {
|
||||
} else if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
@@ -81,7 +111,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities)
|
||||
return suggestions.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -96,7 +126,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
|
||||
// hacky, to verify if possible to get it in easier way
|
||||
Playlist(JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
@@ -111,22 +142,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.arrayValue.map(self.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json, forceNotLast: true)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
@@ -136,99 +157,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["continuation"]?.string
|
||||
let disabled = !details["error"].isNil
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
if account.token.isNil || account.token!.isEmpty {
|
||||
updateToken()
|
||||
} else {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateToken(force: Bool = false) {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let username,
|
||||
let password,
|
||||
!username.isEmpty,
|
||||
!password.isEmpty
|
||||
else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
||||
)
|
||||
}
|
||||
|
||||
AF
|
||||
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
||||
.redirect(using: .doNotFollow)
|
||||
.response { response in
|
||||
guard let headers = response.response?.headers,
|
||||
let cookies = headers["Set-Cookie"]
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(response, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
||||
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
||||
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
return
|
||||
}
|
||||
|
||||
let matchRange = match.range(withName: "sid")
|
||||
|
||||
if let substringRange = Range(matchRange, in: cookies) {
|
||||
let sid = String(cookies[substringRange])
|
||||
AccountsModel.setToken(self.account, sid)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
@@ -239,9 +167,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
"\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String? {
|
||||
guard let token = account?.token, !token.isEmpty else { return nil }
|
||||
return "SID=\(token)"
|
||||
private var cookieHeader: String {
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
@@ -250,7 +177,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
.withParam("type", category?.type)
|
||||
.withParam("type", category?.name)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
@@ -258,53 +185,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.url, path: "/feed/subscriptions")
|
||||
}
|
||||
|
||||
func feed(_ page: Int?) -> Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
.withParam("page", String(page ?? 1))
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page: String?) -> Resource {
|
||||
if page.isNil, contentType == .videos {
|
||||
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
||||
}
|
||||
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("channels/\(id)/\(contentType.invidiousID)"))
|
||||
|
||||
if let page, !page.isEmpty {
|
||||
resource = resource.withParam("continuation", page)
|
||||
}
|
||||
|
||||
return resource
|
||||
}
|
||||
|
||||
func channelByName(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelByUsername(_: String) -> Resource? {
|
||||
nil
|
||||
func channel(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
@@ -320,11 +224,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
@@ -413,7 +317,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource = resource.withParam("duration", duration.rawValue)
|
||||
}
|
||||
|
||||
if let page {
|
||||
if let page = page {
|
||||
resource = resource.withParam("page", page)
|
||||
}
|
||||
|
||||
@@ -425,12 +329,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
|
||||
guard let page else { return resource }
|
||||
|
||||
return resource.withParam("continuation", page)
|
||||
}
|
||||
func comments(_: Video.ID, page _: String?) -> Resource? { nil }
|
||||
|
||||
private func searchQuery(_ query: String) -> String {
|
||||
var searchQuery = query
|
||||
@@ -452,14 +351,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
|
||||
guard let instanceURLComponents = URLComponents(url: instance.apiURL, resolvingAgainstBaseURL: false),
|
||||
guard let instanceURLComponents = URLComponents(string: instance.apiURL),
|
||||
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
urlComponents.scheme = instanceURLComponents.scheme
|
||||
urlComponents.host = instanceURLComponents.host
|
||||
urlComponents.user = instanceURLComponents.user
|
||||
urlComponents.password = instanceURLComponents.password
|
||||
urlComponents.port = instanceURLComponents.port
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
@@ -471,12 +367,10 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
func extractVideo(from json: JSON) -> Video {
|
||||
let indexID: String?
|
||||
var id: Video.ID
|
||||
var published = json["publishedText"].stringValue
|
||||
var publishedAt: Date?
|
||||
|
||||
if let publishedInterval = json["published"].double {
|
||||
publishedAt = Date(timeIntervalSince1970: publishedInterval)
|
||||
published = ""
|
||||
}
|
||||
|
||||
let videoID = json["videoId"].stringValue
|
||||
@@ -489,313 +383,103 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
id = videoID
|
||||
}
|
||||
|
||||
let description = json["description"].stringValue
|
||||
let length = json["lengthSeconds"].doubleValue
|
||||
|
||||
return Video(
|
||||
instanceID: account.instanceID,
|
||||
app: .invidious,
|
||||
instanceURL: account.instance.apiURL,
|
||||
id: id,
|
||||
videoID: videoID,
|
||||
title: json["title"].stringValue,
|
||||
author: json["author"].stringValue,
|
||||
length: length,
|
||||
published: published,
|
||||
length: json["lengthSeconds"].doubleValue,
|
||||
published: json["publishedText"].stringValue,
|
||||
views: json["viewCount"].intValue,
|
||||
description: description,
|
||||
description: json["description"].stringValue,
|
||||
genre: json["genre"].stringValue,
|
||||
channel: extractChannel(from: json),
|
||||
thumbnails: extractThumbnails(from: json),
|
||||
indexID: indexID,
|
||||
live: json["liveNow"].boolValue,
|
||||
upcoming: json["isUpcoming"].boolValue,
|
||||
short: length <= Video.shortLength && length != 0.0,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likeCount"].int,
|
||||
dislikes: json["dislikeCount"].int,
|
||||
keywords: json["keywords"].arrayValue.compactMap(\.string),
|
||||
keywords: json["keywords"].arrayValue.map { $0.stringValue },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: createChapters(from: description, thumbnails: json),
|
||||
captions: extractCaptions(from: json)
|
||||
related: extractRelated(from: json)
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannel(from json: JSON) -> Channel {
|
||||
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? ""
|
||||
|
||||
// append protocol to unproxied thumbnail URL if it's missing
|
||||
// append https protocol to unproxied thumbnail URL if it's missing
|
||||
if thumbnailURL.count > 2,
|
||||
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//",
|
||||
let accountUrlComponents = URLComponents(string: account.urlString)
|
||||
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//"
|
||||
{
|
||||
thumbnailURL = "\(accountUrlComponents.scheme ?? "https"):\(thumbnailURL)"
|
||||
}
|
||||
|
||||
let tabs = json["tabs"].arrayValue.compactMap { name in
|
||||
if let name = name.string, let type = Channel.ContentType.from(name) {
|
||||
return Channel.Tab(contentType: type, data: "")
|
||||
}
|
||||
|
||||
return nil
|
||||
thumbnailURL = "https:\(thumbnailURL)"
|
||||
}
|
||||
|
||||
return Channel(
|
||||
app: .invidious,
|
||||
id: json["authorId"].stringValue,
|
||||
name: json["author"].stringValue,
|
||||
bannerURL: json["authorBanners"].arrayValue.first?.dictionaryValue["url"]?.url,
|
||||
thumbnailURL: URL(string: thumbnailURL),
|
||||
description: json["description"].stringValue,
|
||||
subscriptionsCount: json["subCount"].int,
|
||||
subscriptionsText: json["subCountText"].string,
|
||||
totalViews: json["totalViews"].int,
|
||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [],
|
||||
tabs: tabs
|
||||
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
let details = json.dictionaryValue
|
||||
return ChannelPlaylist(
|
||||
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
|
||||
title: details["title"]?.stringValue ?? "",
|
||||
id: details["playlistId"]!.stringValue,
|
||||
title: details["title"]!.stringValue,
|
||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||
channel: extractChannel(from: json),
|
||||
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
|
||||
videosCount: details["videoCount"]?.int
|
||||
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
// Determines if the request requires Basic Auth credentials to be removed
|
||||
private func needsBasicAuthRemoval(for path: String) -> Bool {
|
||||
return path.hasPrefix("\(Self.basePath)/auth/")
|
||||
}
|
||||
|
||||
// Creates a resource URL with consideration for removing Basic Auth credentials
|
||||
private func createResourceURL(baseURL: URL, path: String) -> URL {
|
||||
var resourceURL = baseURL
|
||||
|
||||
// Remove Basic Auth credentials if required
|
||||
if needsBasicAuthRemoval(for: path), var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) {
|
||||
urlComponents.user = nil
|
||||
urlComponents.password = nil
|
||||
resourceURL = urlComponents.url ?? baseURL
|
||||
}
|
||||
|
||||
return resourceURL.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
func resourceWithAuthCheck(baseURL: URL, path: String) -> Resource {
|
||||
let sanitizedURL = createResourceURL(baseURL: baseURL, path: path)
|
||||
return super.resource(absoluteURL: sanitizedURL)
|
||||
}
|
||||
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.compactMap { json in
|
||||
guard let url = json["url"].url,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let quality = json["quality"].string,
|
||||
let accountUrlComponents = URLComponents(string: account.urlString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some instances are not configured properly and return thumbnail links
|
||||
// with an incorrect scheme or a missing port.
|
||||
components.scheme = accountUrlComponents.scheme
|
||||
components.port = accountUrlComponents.port
|
||||
|
||||
// If basic HTTP authentication is used,
|
||||
// the username and password need to be prepended to the URL.
|
||||
components.user = accountUrlComponents.user
|
||||
components.password = accountUrlComponents.password
|
||||
|
||||
guard let thumbnailUrl = components.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
|
||||
details["videoThumbnails"].arrayValue.map { json in
|
||||
Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!)
|
||||
}
|
||||
}
|
||||
|
||||
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
||||
var chapters = extractChapters(from: description)
|
||||
|
||||
if !chapters.isEmpty {
|
||||
let thumbnailsData = extractThumbnails(from: thumbnails)
|
||||
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
||||
|
||||
for chapter in chapters.indices {
|
||||
if let url = thumbnailURL {
|
||||
chapters[chapter].image = url
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
||||
|
||||
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||
let nextPage = json.dictionaryValue["continuation"]?.string
|
||||
var contentItems = [ContentItem]()
|
||||
|
||||
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
|
||||
let items = json.dictionaryValue[key]
|
||||
{
|
||||
contentItems = extractContentItems(from: items)
|
||||
}
|
||||
|
||||
var last = false
|
||||
if !forceNotLast {
|
||||
last = nextPage?.isEmpty ?? true
|
||||
}
|
||||
|
||||
return ChannelPage(
|
||||
results: contentItems,
|
||||
channel: extractChannel(from: json),
|
||||
nextPage: nextPage,
|
||||
last: last
|
||||
)
|
||||
}
|
||||
|
||||
private func extractStreams(from json: JSON) -> [Stream] {
|
||||
let hls = extractHLSStreams(from: json)
|
||||
if json["liveNow"].boolValue {
|
||||
return hls
|
||||
}
|
||||
let videoId = json["videoId"].stringValue
|
||||
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue, videoId: videoId) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue, videoId: videoId) +
|
||||
hls
|
||||
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
streams.compactMap { stream in
|
||||
guard let streamURL = stream["url"].url else {
|
||||
return nil
|
||||
}
|
||||
let finalURL: URL
|
||||
if let videoId, let itag = stream["itag"].string, account.instance.invidiousCompanion {
|
||||
let companionURLString = "\(account.instance.apiURLString)/companion/latest_version?id=\(videoId)&itag=\(itag)"
|
||||
finalURL = URL(string: companionURLString) ?? streamURL
|
||||
} else {
|
||||
finalURL = streamURL
|
||||
}
|
||||
|
||||
return SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: AVURLAsset(url: finalURL),
|
||||
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
||||
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||
streams.map {
|
||||
SingleAssetStream(
|
||||
avAsset: AVURLAsset(url: $0["url"].url!),
|
||||
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
|
||||
kind: .stream,
|
||||
encoding: stream["encoding"].string ?? ""
|
||||
encoding: $0["encoding"].stringValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func extractXTags(from urlString: String) -> [String: String] {
|
||||
guard let urlComponents = URLComponents(string: urlString),
|
||||
let queryItems = urlComponents.queryItems,
|
||||
let xtagsValue = queryItems.first(where: { $0.name == "xtags" })?.value
|
||||
else {
|
||||
return [:]
|
||||
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
guard audioAssetURL != nil else {
|
||||
return []
|
||||
}
|
||||
|
||||
guard let decoded = xtagsValue.removingPercentEncoding else { return [:] }
|
||||
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
|
||||
// Parse key-value pairs (format: key1=value1:key2=value2)
|
||||
// Example: "acont=dubbed-auto:lang=en-US"
|
||||
let pairs = decoded.split(separator: ":")
|
||||
var result: [String: String] = [:]
|
||||
for pair in pairs {
|
||||
let parts = pair.split(separator: "=", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
result[String(parts[0])] = String(parts[1])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
let audioTracks = streams
|
||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
}
|
||||
.compactMap { audioStream -> Stream.AudioTrack? in
|
||||
guard let url = audioStream["url"].url,
|
||||
let audioItag = audioStream["itag"].string
|
||||
else { return nil }
|
||||
|
||||
let finalURL: URL
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let audioCompanionURLString = "\(account.instance.apiURLString)/companion/latest_version?id=\(videoId)&itag=\(audioItag)"
|
||||
finalURL = URL(string: audioCompanionURLString) ?? url
|
||||
} else {
|
||||
finalURL = url
|
||||
}
|
||||
|
||||
let xTags = extractXTags(from: url.absoluteString)
|
||||
|
||||
return Stream.AudioTrack(
|
||||
url: finalURL,
|
||||
content: xTags["acont"],
|
||||
language: xTags["lang"]
|
||||
)
|
||||
}
|
||||
.sorted {
|
||||
/// Always prefer original audio streams over dubbed ones
|
||||
!$0.isDubbed && $1.isDubbed
|
||||
}
|
||||
|
||||
guard !audioTracks.isEmpty else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
|
||||
return videoStreams.compactMap { videoStream in
|
||||
guard let videoAssetURL = videoStream["url"].url,
|
||||
let videoItag = videoStream["itag"].string
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let finalVideoURL: URL
|
||||
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let videoCompanionURLString = "\(account.instance.apiURLString)/companion/latest_version?id=\(videoId)&itag=\(videoItag)"
|
||||
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
||||
} else {
|
||||
finalVideoURL = videoAssetURL
|
||||
}
|
||||
|
||||
return Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: AVURLAsset(url: audioTracks[0].url),
|
||||
videoAsset: AVURLAsset(url: finalVideoURL),
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
return videoAssetsURLs.map {
|
||||
Stream(
|
||||
audioAsset: AVURLAsset(url: audioAssetURL!["url"].url!),
|
||||
videoAsset: AVURLAsset(url: $0["url"].url!),
|
||||
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int,
|
||||
requestRange: videoStream["init"].string ?? videoStream["index"].string,
|
||||
audioTracks: audioTracks
|
||||
encoding: $0["encoding"].stringValue,
|
||||
videoFormat: $0["type"].stringValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractHLSStreams(from content: JSON) -> [Stream] {
|
||||
if let hlsURL = content.dictionaryValue["hlsUrl"]?.url {
|
||||
return [Stream(instance: account.instance, hlsURL: hlsURL)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
@@ -804,91 +488,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
let id = content["playlistId"].stringValue
|
||||
return Playlist(
|
||||
id: id,
|
||||
.init(
|
||||
id: content["playlistId"].stringValue,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
editable: id.starts(with: "IV"),
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
||||
let decodedContent = decodeHtml(htmlContent)
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: authorAvatarURL,
|
||||
time: details["publishedText"]?.string ?? "",
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: decodedContent,
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .invidious, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
||||
if let data = htmlEncodedString.data(using: .utf8) {
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
return htmlEncodedString
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
|
||||
return Captions(
|
||||
label: details["label"].stringValue,
|
||||
code: details["language_code"].stringValue,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractContentItems(from json: JSON) -> [ContentItem] {
|
||||
json.arrayValue.compactMap { extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
private func extractContentItem(from json: JSON) -> ContentItem? {
|
||||
let type = json.dictionaryValue["type"]?.string
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: extractChannel(from: json))
|
||||
}
|
||||
if type == "playlist" {
|
||||
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: extractVideo(from: json))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Channel.ContentType {
|
||||
var invidiousID: String {
|
||||
switch self {
|
||||
case .livestreams:
|
||||
return "streams"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
import Alamofire
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
static let basePath = "/api/v1"
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
@Published var validInstance = true
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else { return false }
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PeerTubeAPI {
|
||||
.init(account: Instance(app: .peerTube, apiURLString: url.absoluteString).anonymousAccount)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard !account.isNil else {
|
||||
self.account = .init(name: "Empty")
|
||||
return
|
||||
}
|
||||
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
validInstance = account.anonymous
|
||||
|
||||
configure()
|
||||
|
||||
if !account.anonymous {
|
||||
validate()
|
||||
}
|
||||
}
|
||||
|
||||
func validate() {
|
||||
validateInstance()
|
||||
validateSID()
|
||||
}
|
||||
|
||||
func validateInstance() {
|
||||
guard !validInstance else {
|
||||
return
|
||||
}
|
||||
|
||||
home?
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
self.validInstance = true
|
||||
}
|
||||
.onFailure { _ in
|
||||
self.validInstance = false
|
||||
}
|
||||
}
|
||||
|
||||
func validateSID() {
|
||||
guard signedIn, !(account.token?.isEmpty ?? true) else {
|
||||
return
|
||||
}
|
||||
|
||||
feed(1)?
|
||||
.load()
|
||||
.onFailure { _ in
|
||||
self.updateToken(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
if let cookie = self.cookieHeader {
|
||||
$0.headers["Cookie"] = cookie
|
||||
}
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure("**", requestMethods: [.post]) {
|
||||
$0.pipeline[.parsing].removeTransformers()
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.dictionaryValue["data"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
||||
let results = content.json.dictionaryValue["data"]?.arrayValue.compactMap { json -> ContentItem in .init(video: self.extractVideo(from: json)) } ?? []
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.map(self.extractPlaylist)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
||||
self.extractPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
||||
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
if let feedVideos = content.json.dictionaryValue["videos"] {
|
||||
return feedVideos.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map(self.extractChannel)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Channel in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map(self.extractVideo)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
let playlists = (content.json.dictionaryValue["playlists"]?.arrayValue ?? []).compactMap { self.extractChannelPlaylist(from: $0) }
|
||||
return ContentItem.array(of: playlists)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["continuation"]?.string
|
||||
let disabled = !details["error"].isNil
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
updateToken()
|
||||
}
|
||||
|
||||
func updateToken(force: Bool = false) {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let username,
|
||||
let password,
|
||||
!username.isEmpty,
|
||||
!password.isEmpty
|
||||
else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Remove and add your account again in Settings."
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
||||
)
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
|
||||
AF
|
||||
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
||||
.redirect(using: .doNotFollow)
|
||||
.response { response in
|
||||
guard let headers = response.response?.headers,
|
||||
let cookies = headers["Set-Cookie"]
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(response, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
||||
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
||||
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
||||
else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
return
|
||||
}
|
||||
|
||||
let matchRange = match.range(withName: "sid")
|
||||
|
||||
if let substringRange = Range(matchRange, in: cookies) {
|
||||
let sid = String(cookies[substringRange])
|
||||
AccountsModel.setToken(self.account, sid)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(Self.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private var cookieHeader: String? {
|
||||
guard let token = account?.token, !token.isEmpty else { return nil }
|
||||
return "SID=\(token)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country _: Country, category _: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/videos")
|
||||
.withParam("isLocal", "true")
|
||||
// .withParam("type", category?.name)
|
||||
// .withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
var home: Resource? {
|
||||
resource(baseURL: account.url, path: "/feed/subscriptions")
|
||||
}
|
||||
|
||||
func feed(_ page: Int?) -> Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
.withParam("page", String(page ?? 1))
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page _: String?) -> Resource {
|
||||
if contentType == .playlists {
|
||||
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists"))
|
||||
}
|
||||
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
||||
}
|
||||
|
||||
func channelByName(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelByUsername(_: String) -> Resource? {
|
||||
nil
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
|
||||
}
|
||||
|
||||
func video(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
|
||||
}
|
||||
|
||||
var playlists: Resource? {
|
||||
if account.isNil || account.anonymous {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
playlist(id)?.child("videos")
|
||||
}
|
||||
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = playlistVideos(playlistID)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = playlistVideo(playlistID, index)
|
||||
|
||||
resource?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["title": name, "privacy": visibility]
|
||||
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
||||
|
||||
resource?
|
||||
.request(!playlist.isNil ? .patch : .post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
self.playlist(playlist.id)?
|
||||
.request(.delete)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery, page _: String?) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/videos"))
|
||||
.withParam("search", query.query)
|
||||
// .withParam("sort_by", query.sortBy.parameter)
|
||||
// .withParam("type", "all")
|
||||
//
|
||||
// if let date = query.date, date != .any {
|
||||
// resource = resource.withParam("date", date.rawValue)
|
||||
// }
|
||||
//
|
||||
// if let duration = query.duration, duration != .any {
|
||||
// resource = resource.withParam("duration", duration.rawValue)
|
||||
// }
|
||||
//
|
||||
// if let page {
|
||||
// resource = resource.withParam("page", page)
|
||||
// }
|
||||
|
||||
// return resource
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
|
||||
.withParam("q", query.lowercased())
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
|
||||
guard let page else { return resource }
|
||||
|
||||
return resource.withParam("continuation", page)
|
||||
}
|
||||
|
||||
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
|
||||
guard let instanceURLComponents = URLComponents(string: instance.apiURLString),
|
||||
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
|
||||
|
||||
urlComponents.scheme = instanceURLComponents.scheme
|
||||
urlComponents.host = instanceURLComponents.host
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
|
||||
func extractVideo(from json: JSON) -> Video {
|
||||
let id = json["uuid"].stringValue
|
||||
let url = json["url"].url
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let publishedAt = dateFormatter.date(from: json["publishedAt"].stringValue)
|
||||
|
||||
return Video(
|
||||
instanceID: account.instanceID,
|
||||
app: .peerTube,
|
||||
instanceURL: account.instance.apiURL,
|
||||
id: id,
|
||||
videoID: id,
|
||||
videoURL: url,
|
||||
title: json["name"].stringValue,
|
||||
author: json["channel"].dictionaryValue["name"]?.stringValue ?? "",
|
||||
length: json["duration"].doubleValue,
|
||||
views: json["views"].intValue,
|
||||
description: json["description"].stringValue,
|
||||
channel: extractChannel(from: json["channel"]),
|
||||
thumbnails: extractThumbnails(from: json),
|
||||
live: json["isLive"].boolValue,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likes"].int,
|
||||
dislikes: json["dislikes"].int,
|
||||
streams: extractStreams(from: json)
|
||||
// related: extractRelated(from: json),
|
||||
// chapters: extractChapters(from: description),
|
||||
// captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannel(from json: JSON) -> Channel {
|
||||
Channel(
|
||||
app: .peerTube,
|
||||
id: json["id"].stringValue,
|
||||
name: json["name"].stringValue
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
||||
let details = json.dictionaryValue
|
||||
return ChannelPlaylist(
|
||||
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
|
||||
title: details["title"]?.stringValue ?? "",
|
||||
thumbnailURL: details["playlistThumbnail"]?.url,
|
||||
channel: extractChannel(from: json),
|
||||
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
|
||||
videosCount: details["videoCount"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
if let thumbnailPath = details["thumbnailPath"].string {
|
||||
return [Thumbnail(url: URL(string: thumbnailPath, relativeTo: account.url)!, quality: .medium)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func extractStreams(from json: JSON) -> [Stream] {
|
||||
let hls = extractHLSStreams(from: json)
|
||||
|
||||
if json["isLive"].boolValue {
|
||||
return hls
|
||||
}
|
||||
|
||||
return extractFormatStreams(from: json) +
|
||||
extractAdaptiveFormats(from: json) +
|
||||
hls
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from json: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
if let fileURL = json.dictionaryValue["streamingPlaylists"]?.arrayValue.first?
|
||||
.dictionaryValue["files"]?.arrayValue.first?
|
||||
.dictionaryValue["fileUrl"]?.url
|
||||
{
|
||||
let resolution = Stream.Resolution.predefined(.hd720p30)
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from json: JSON) -> [Stream] {
|
||||
json.dictionaryValue["files"]?.arrayValue.compactMap { file in
|
||||
if let resolution = file.dictionaryValue["resolution"]?.dictionaryValue["label"]?.stringValue, let url = file.dictionaryValue["fileUrl"]?.url {
|
||||
return SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: url), resolution: Stream.Resolution.from(resolution: resolution), kind: .adaptive, videoFormat: "mp4")
|
||||
}
|
||||
|
||||
return nil
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private func extractHLSStreams(from content: JSON) -> [Stream] {
|
||||
if let hlsURL = content.dictionaryValue["streamingPlaylists"]?.arrayValue.first?.dictionaryValue["playlistUrl"]?.url {
|
||||
return [Stream(instance: account.instance, hlsURL: hlsURL)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private func extractRelated(from content: JSON) -> [Video] {
|
||||
content
|
||||
.dictionaryValue["recommendedVideos"]?
|
||||
.arrayValue
|
||||
.compactMap(extractVideo(from:)) ?? []
|
||||
}
|
||||
|
||||
private func extractPlaylist(from content: JSON) -> Playlist {
|
||||
let id = content["playlistId"].stringValue
|
||||
return Playlist(
|
||||
id: id,
|
||||
title: content["title"].stringValue,
|
||||
visibility: content["isListed"].boolValue ? .public : .private,
|
||||
editable: id.starts(with: "IV"),
|
||||
updated: content["updated"].doubleValue,
|
||||
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: authorAvatarURL,
|
||||
time: details["publishedText"]?.string ?? "",
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: details["content"]?.string ?? "",
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .peerTube, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { _ in
|
||||
nil
|
||||
// let baseURL = account.url
|
||||
// guard let url = URL(string: baseURL + details["url"].stringValue) else { return nil }
|
||||
//
|
||||
// return Captions(
|
||||
// label: details["label"].stringValue,
|
||||
// code: details["language_code"].stringValue,
|
||||
// url: url
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import Alamofire
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var disallowedVideoCodecs = ["av01"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
|
||||
static var contentItemsKeys = ["items", "content", "relatedStreams"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PipedAPI {
|
||||
.init(account: Instance(app: .piped, apiURLString: url.absoluteString).anonymousAccount)
|
||||
}
|
||||
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
@@ -42,36 +35,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
$0.headers["Authorization"] = self.account.token
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> ChannelPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.string
|
||||
let channel = self.extractChannel(from: content.json)
|
||||
return ChannelPage(
|
||||
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
|
||||
channel: channel,
|
||||
nextPage: nextPage,
|
||||
last: nextPage.isNil
|
||||
)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("/nextpage/channel/*")) { (content: Entity<JSON>) -> ChannelPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.string
|
||||
return ChannelPage(
|
||||
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
|
||||
channel: self.extractChannel(from: content.json),
|
||||
nextPage: nextPage,
|
||||
last: nextPage.isNil
|
||||
)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channels/tabs*")) { (content: Entity<JSON>) -> [ContentItem] in
|
||||
(content.json.dictionaryValue["content"]?.arrayValue ?? []).compactMap { self.extractContentItem(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("c/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
self.extractChannel(from: content.json)
|
||||
}
|
||||
|
||||
@@ -93,7 +57,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.string
|
||||
let nextPage = content.json.dictionaryValue["nextpage"]?.stringValue
|
||||
return SearchPage(
|
||||
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
|
||||
nextPage: nextPage,
|
||||
@@ -106,39 +70,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.compactMap { self.extractChannel(from: $0) }
|
||||
content.json.arrayValue.map { self.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||
content.json.arrayValue.map { self.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
||||
guard let details = content?.json.dictionaryValue else {
|
||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
||||
}
|
||||
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? []
|
||||
let nextPage = details["nextpage"]?.stringValue
|
||||
let disabled = details["disabled"]?.boolValue ?? false
|
||||
|
||||
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
|
||||
content.json.arrayValue.compactMap { self.extractUserPlaylist(from: $0) }
|
||||
content.json.arrayValue.map { self.extractUserPlaylist(from: $0)! }
|
||||
}
|
||||
|
||||
if account.token.isNil || account.token!.isEmpty {
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
} else {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,59 +100,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
|
||||
guard !account.anonymous,
|
||||
let username,
|
||||
let password
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
guard !account.anonymous else {
|
||||
return
|
||||
}
|
||||
|
||||
AF.request(
|
||||
login.url,
|
||||
method: .post,
|
||||
parameters: ["username": username, "password": password],
|
||||
encoding: JSONEncoding.default
|
||||
account.token = nil
|
||||
|
||||
login.request(
|
||||
.post,
|
||||
json: ["username": account.username, "password": account.password]
|
||||
)
|
||||
.responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
let json = JSON(value)
|
||||
let token = json.dictionaryValue["token"]?.string ?? ""
|
||||
if let error = json.dictionaryValue["error"]?.string {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error
|
||||
)
|
||||
} else if !token.isEmpty {
|
||||
AccountsModel.setToken(self.account, token)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Could not update your token."
|
||||
)
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
|
||||
case let .failure(error):
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
NotificationCenter.default.post(name: .accountConfigurationComplete, object: nil)
|
||||
}
|
||||
.onSuccess { response in
|
||||
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,35 +120,12 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource {
|
||||
let path = page.isNil ? "channel" : "nextpage/channel"
|
||||
|
||||
var channel: Siesta.Resource
|
||||
|
||||
if contentType == .videos || data.isNil {
|
||||
channel = resource(baseURL: account.url, path: "\(path)/\(id)")
|
||||
} else {
|
||||
channel = resource(baseURL: account.url, path: "channels/tabs")
|
||||
.withParam("data", data)
|
||||
}
|
||||
|
||||
if let page, !page.isEmpty {
|
||||
channel = channel.withParam("nextpage", page)
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
func channelByName(_ name: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: "c/\(name)")
|
||||
}
|
||||
|
||||
func channelByUsername(_ username: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: "user/\(username)")
|
||||
func channel(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: "channel/\(id)")
|
||||
}
|
||||
|
||||
func channelVideos(_ id: String) -> Resource {
|
||||
channel(id, contentType: .videos)
|
||||
channel(id)
|
||||
}
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource? {
|
||||
@@ -271,7 +161,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else {
|
||||
guard let account = account else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -282,7 +172,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscriptions")
|
||||
}
|
||||
|
||||
func feed(_: Int?) -> Resource? {
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "feed")
|
||||
.withParam("authToken", account.token)
|
||||
}
|
||||
@@ -395,10 +285,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
private func extractContentItem(from content: JSON) -> ContentItem? {
|
||||
let details = content.dictionaryValue
|
||||
let url: String! = details["url"]?.string
|
||||
|
||||
let contentType: ContentItem.ContentType
|
||||
|
||||
if let url = details["url"]?.string {
|
||||
if !url.isNil {
|
||||
if url.contains("/playlist") {
|
||||
contentType = .playlist
|
||||
} else if url.contains("/channel") {
|
||||
@@ -425,7 +316,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -439,113 +329,62 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
private func extractChannel(from content: JSON) -> Channel? {
|
||||
let attributes = content.dictionaryValue
|
||||
guard let id = attributes["id"]?.string ??
|
||||
(attributes["url"] ?? attributes["uploaderUrl"])?.string?.components(separatedBy: "/").last
|
||||
guard let id = attributes["id"]?.stringValue ??
|
||||
(attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let subscriptionsCount = attributes["subscriberCount"]?.int ?? attributes["subscribers"]?.int
|
||||
let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue
|
||||
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = attributes["relatedStreams"] {
|
||||
videos = extractVideos(from: relatedStreams)
|
||||
}
|
||||
|
||||
let name = attributes["name"]?.string ??
|
||||
attributes["uploaderName"]?.string ??
|
||||
attributes["uploader"]?.string ?? ""
|
||||
|
||||
let thumbnailURL = attributes["avatarUrl"]?.url ??
|
||||
attributes["uploaderAvatar"]?.url ??
|
||||
attributes["avatar"]?.url ??
|
||||
attributes["thumbnail"]?.url
|
||||
|
||||
let tabs = attributes["tabs"]?.arrayValue.compactMap { tab in
|
||||
let name = tab["name"].string
|
||||
let data = tab["data"].string
|
||||
if let name, let data, let type = Channel.ContentType(rawValue: name) {
|
||||
return Channel.Tab(contentType: type, data: data)
|
||||
}
|
||||
|
||||
return nil
|
||||
} ?? [Channel.Tab]()
|
||||
let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? ""
|
||||
let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url
|
||||
|
||||
return Channel(
|
||||
app: .piped,
|
||||
id: id,
|
||||
name: name,
|
||||
bannerURL: attributes["bannerUrl"]?.url,
|
||||
thumbnailURL: thumbnailURL,
|
||||
subscriptionsCount: subscriptionsCount,
|
||||
verified: attributes["verified"]?.bool,
|
||||
videos: videos,
|
||||
tabs: tabs
|
||||
videos: videos
|
||||
)
|
||||
}
|
||||
|
||||
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
|
||||
let details = json.dictionaryValue
|
||||
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last
|
||||
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString
|
||||
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
|
||||
var videos = [Video]()
|
||||
if let relatedStreams = details["relatedStreams"] {
|
||||
videos = extractVideos(from: relatedStreams)
|
||||
}
|
||||
return ChannelPlaylist(
|
||||
id: id ?? UUID().uuidString,
|
||||
title: details["name"]?.string ?? "",
|
||||
id: id,
|
||||
title: details["name"]?.stringValue ?? "",
|
||||
thumbnailURL: thumbnailURL,
|
||||
channel: extractChannel(from: json),
|
||||
channel: extractChannel(from: json)!,
|
||||
videos: videos,
|
||||
videosCount: details["videos"]?.int
|
||||
)
|
||||
}
|
||||
|
||||
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
|
||||
let hostValue = hostItem.value
|
||||
else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
urlComponents.host = hostValue
|
||||
|
||||
guard let newUrl = urlComponents.url else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
completion(AVURLAsset(url: newUrl))
|
||||
}
|
||||
|
||||
// Overload used for hlsURLS
|
||||
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
nonProxiedAsset(asset: asset, completion: completion)
|
||||
}
|
||||
|
||||
private func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
let url = details["url"]?.string
|
||||
|
||||
if let url = details["url"]?.string {
|
||||
guard url.contains("/watch") else {
|
||||
if !url.isNil {
|
||||
guard url!.contains("/watch") else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
|
||||
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
||||
|
||||
let qualities = [
|
||||
Thumbnail.Quality.maxresdefault, .high, .medium, .default, .start, .middle, .end
|
||||
]
|
||||
let thumbnails: [Thumbnail] = qualities.compactMap {
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
@@ -553,133 +392,86 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.string ?? details["uploader"]?.string ?? ""
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
|
||||
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
|
||||
|
||||
let uploaded = details["uploaded"]?.double
|
||||
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
var publishedAt: Date?
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||
|
||||
if published.isNil,
|
||||
let date = details["uploadDate"]?.string,
|
||||
let formattedDate = dateFormatter.date(from: date)
|
||||
{
|
||||
publishedAt = formattedDate
|
||||
published = ""
|
||||
} else if published.isNil {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
|
||||
let uploaded = details["uploaded"]?.doubleValue
|
||||
var published = uploaded.isNil ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
if published.isNil {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? ""
|
||||
}
|
||||
|
||||
let live = details["livestream"]?.bool ?? (details["duration"]?.int == -1)
|
||||
|
||||
let description = extractDescription(from: content) ?? ""
|
||||
|
||||
var chapters = extractChapters(from: content)
|
||||
if chapters.isEmpty, !description.isEmpty {
|
||||
chapters = extractChapters(from: description)
|
||||
}
|
||||
|
||||
let length = details["duration"]?.double ?? 0
|
||||
let live = details["livestream"]?.boolValue ?? (details["duration"]?.intValue == -1)
|
||||
|
||||
return Video(
|
||||
instanceID: account.instanceID,
|
||||
app: .piped,
|
||||
instanceURL: account.instance.apiURL,
|
||||
videoID: extractID(from: content),
|
||||
title: details["title"]?.string ?? "",
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: length,
|
||||
published: published ?? "",
|
||||
views: details["views"]?.int ?? 0,
|
||||
description: description,
|
||||
channel: Channel(app: .piped, id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: published!,
|
||||
views: details["views"]!.intValue,
|
||||
description: extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
|
||||
publishedAt: publishedAt,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
related: extractRelated(from: content),
|
||||
chapters: extractChapters(from: content),
|
||||
captions: extractCaptions(from: content)
|
||||
related: extractRelated(from: content)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractID(from content: JSON) -> Video.ID {
|
||||
content.dictionaryValue["url"]?.string?.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(from: content)?.relativeString.components(separatedBy: "/")[4] ?? ""
|
||||
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4]
|
||||
}
|
||||
|
||||
private func extractThumbnailURL(from content: JSON) -> URL? {
|
||||
content.dictionaryValue["thumbnail"]?.url ?? content.dictionaryValue["thumbnailUrl"]?.url
|
||||
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
||||
}
|
||||
|
||||
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||
guard let thumbnailURL = extractThumbnailURL(from: content) else {
|
||||
let thumbnailURL = extractThumbnailURL(from: content)
|
||||
guard !thumbnailURL.isNil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(
|
||||
string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)
|
||||
return URL(string: thumbnailURL!
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)!
|
||||
}
|
||||
|
||||
private func extractUserPlaylist(from json: JSON) -> Playlist? {
|
||||
let id = json["id"].string ?? ""
|
||||
let title = json["name"].string ?? ""
|
||||
let id = json["id"].stringValue
|
||||
let title = json["name"].stringValue
|
||||
let visibility = Playlist.Visibility.private
|
||||
|
||||
return Playlist(id: id, title: title, visibility: visibility)
|
||||
}
|
||||
|
||||
private func extractDescription(from content: JSON) -> String? {
|
||||
guard let description = content.dictionaryValue["description"]?.string else { return nil }
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return replaceHTML(description)
|
||||
}
|
||||
|
||||
private func replaceHTML(_ string: String) -> String {
|
||||
var string = string.replacingOccurrences(
|
||||
description = description.replacingOccurrences(
|
||||
of: "<br/>|<br />|<br>",
|
||||
with: "\n",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
let linkRegex = #"(<a\s+(?:[^>]*?\s+)?href=\"[^"]*\">[^<]*<\/a>)"#
|
||||
let hrefRegex = #"href=\"([^"]*)\">"#
|
||||
guard let hrefRegex = try? NSRegularExpression(pattern: hrefRegex) else { return string }
|
||||
string = string.replacingMatches(regex: linkRegex) { matchingGroup in
|
||||
let results = hrefRegex.matches(in: matchingGroup, range: NSRange(matchingGroup.startIndex..., in: matchingGroup))
|
||||
description = description.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: "",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
if let result = results.first {
|
||||
if let swiftRange = Range(result.range(at: 1), in: matchingGroup) {
|
||||
return String(matchingGroup[swiftRange])
|
||||
}
|
||||
}
|
||||
|
||||
return matchingGroup
|
||||
}
|
||||
|
||||
string = string
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: "",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
return string
|
||||
return description
|
||||
}
|
||||
|
||||
private func extractVideos(from content: JSON) -> [Video] {
|
||||
@@ -690,125 +482,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(instance: account.instance, hlsURL: hlsURL))
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
// Extract all M4A audio streams, sorted by bitrate (highest first)
|
||||
let allAudioStreams = content
|
||||
let audioStreams = content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
|
||||
} ?? []
|
||||
|
||||
// Group audio streams by track type and language, keeping highest bitrate for each
|
||||
var audioTracksByType = [String: JSON]()
|
||||
for audioStream in allAudioStreams {
|
||||
let trackType = audioStream.dictionaryValue["audioTrackType"]?.string
|
||||
let trackLocale = audioStream.dictionaryValue["audioTrackLocale"]?.string
|
||||
|
||||
// Create a unique key for this audio track combination
|
||||
let key = "\(trackType ?? "ORIGINAL")_\(trackLocale ?? "")"
|
||||
|
||||
// Only keep the first (highest bitrate) stream for each unique track type/locale combination
|
||||
if audioTracksByType[key] == nil {
|
||||
audioTracksByType[key] = audioStream
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Stream.AudioTrack array
|
||||
let audioTracks: [Stream.AudioTrack] = audioTracksByType.values.compactMap { audioStream in
|
||||
guard let url = audioStream.dictionaryValue["url"]?.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let trackType = audioStream.dictionaryValue["audioTrackType"]?.string
|
||||
let trackLocale = audioStream.dictionaryValue["audioTrackLocale"]?.string
|
||||
|
||||
return Stream.AudioTrack(
|
||||
url: url,
|
||||
content: trackType,
|
||||
language: trackLocale
|
||||
)
|
||||
}
|
||||
.sorted { track1, track2 in
|
||||
// Sort: ORIGINAL first, then DUBBED, then others
|
||||
if track1.content == "ORIGINAL", track2.content != "ORIGINAL" {
|
||||
return true
|
||||
}
|
||||
if track1.content != "ORIGINAL", track2.content == "ORIGINAL" {
|
||||
return false
|
||||
}
|
||||
// If both are same type, sort by language
|
||||
return (track1.language ?? "") < (track2.language ?? "")
|
||||
}
|
||||
|
||||
// Fallback to first audio stream if no tracks were extracted
|
||||
guard !audioTracks.isEmpty else {
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return streams
|
||||
}
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
for videoStream in videoStreams {
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
continue
|
||||
}
|
||||
videoStreams.forEach { videoStream in
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
||||
|
||||
guard let videoAssetUrl = videoStream.dictionaryValue["url"]?.url else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the first (ORIGINAL) audio track as default
|
||||
let defaultAudioAsset = AVURLAsset(url: audioTracks[0].url)
|
||||
let videoAsset = AVURLAsset(url: videoAssetUrl)
|
||||
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.bool ?? true
|
||||
let quality = videoStream.dictionaryValue["quality"]?.string ?? "unknown"
|
||||
let qualityComponents = quality.components(separatedBy: "p")
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
var requestRange: String?
|
||||
|
||||
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
|
||||
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
|
||||
{
|
||||
requestRange = "\(initStart)-\(initEnd)"
|
||||
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
|
||||
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
|
||||
{
|
||||
requestRange = "\(indexStart)-\(indexEnd)"
|
||||
} else {
|
||||
requestRange = nil
|
||||
}
|
||||
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
|
||||
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.stringValue
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: defaultAudioAsset,
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate,
|
||||
requestRange: requestRange,
|
||||
audioTracks: audioTracks
|
||||
)
|
||||
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive, videoFormat: videoFormat)
|
||||
)
|
||||
} else {
|
||||
streams.append(
|
||||
SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .stream
|
||||
)
|
||||
SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -825,80 +530,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
private func extractComment(from content: JSON) -> Comment? {
|
||||
let details = content.dictionaryValue
|
||||
let author = details["author"]?.string ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.string
|
||||
let author = details["author"]?.stringValue ?? ""
|
||||
let commentorUrl = details["commentorUrl"]?.stringValue
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
|
||||
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
|
||||
let commentId = details["commentId"]?.string ?? UUID().uuidString
|
||||
|
||||
// Sanity checks: return nil if required data is missing
|
||||
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Comment(
|
||||
id: commentId,
|
||||
id: details["commentId"]?.stringValue ?? UUID().uuidString,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||
time: details["commentedTime"]?.string ?? "",
|
||||
pinned: details["pinned"]?.bool ?? false,
|
||||
hearted: details["hearted"]?.bool ?? false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: commentText,
|
||||
repliesPage: details["repliesPage"]?.string,
|
||||
channel: Channel(app: .piped, id: channelId, name: author)
|
||||
authorAvatarURL: details["thumbnail"]?.stringValue ?? "",
|
||||
time: details["commentedTime"]?.stringValue ?? "",
|
||||
pinned: details["pinned"]?.boolValue ?? false,
|
||||
hearted: details["hearted"]?.boolValue ?? false,
|
||||
likeCount: details["likeCount"]?.intValue ?? 0,
|
||||
text: details["commentText"]?.stringValue ?? "",
|
||||
repliesPage: details["repliesPage"]?.stringValue,
|
||||
channel: Channel(id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractCommentText(from string: String?) -> String {
|
||||
guard let string, !string.isEmpty else { return "" }
|
||||
|
||||
return replaceHTML(string)
|
||||
}
|
||||
|
||||
private func extractChapters(from content: JSON) -> [Chapter] {
|
||||
guard let chapters = content.dictionaryValue["chapters"]?.array else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
return chapters.compactMap { chapter in
|
||||
guard let title = chapter["title"].string,
|
||||
let image = chapter["image"].url,
|
||||
let start = chapter["start"].double
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Chapter(title: title, image: image, start: start)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["subtitles"].arrayValue.compactMap { details in
|
||||
guard let url = details["url"].url,
|
||||
let code = details["code"].string,
|
||||
let label = details["name"].string,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return nil }
|
||||
|
||||
components.queryItems = components.queryItems?.map { item in
|
||||
item.name == "fmt" ? URLQueryItem(name: "fmt", value: "srt") : item
|
||||
}
|
||||
|
||||
guard let newUrl = components.url else { return nil }
|
||||
|
||||
return Captions(label: label, code: code, url: newUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private func contentItemsDictionary(from content: JSON) -> JSON {
|
||||
if let key = Self.contentItemsKeys.first(where: { content.dictionaryValue.keys.contains($0) }),
|
||||
let items = content.dictionaryValue[key]
|
||||
{
|
||||
return items
|
||||
}
|
||||
|
||||
return .null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,7 @@ protocol VideosAPI {
|
||||
var account: Account! { get }
|
||||
var signedIn: Bool { get }
|
||||
|
||||
static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self
|
||||
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource
|
||||
func channelByName(_ name: String) -> Resource?
|
||||
func channelByUsername(_ username: String) -> Resource?
|
||||
func channel(_ id: String) -> Resource
|
||||
func channelVideos(_ id: String) -> Resource
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource
|
||||
func search(_ query: SearchQuery, page: String?) -> Resource
|
||||
@@ -18,8 +14,8 @@ protocol VideosAPI {
|
||||
|
||||
func video(_ id: Video.ID) -> Resource
|
||||
|
||||
func feed(_ page: Int?) -> Resource?
|
||||
var subscriptions: Resource? { get }
|
||||
var feed: Resource? { get }
|
||||
var home: Resource? { get }
|
||||
var popular: Resource? { get }
|
||||
var playlists: Resource? { get }
|
||||
@@ -61,67 +57,40 @@ protocol VideosAPI {
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(
|
||||
_ item: PlayerQueueItem,
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
|
||||
extension VideosAPI {
|
||||
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource {
|
||||
channel(id, contentType: contentType, data: data, page: page)
|
||||
}
|
||||
|
||||
func loadDetails(
|
||||
_ item: PlayerQueueItem,
|
||||
failureHandler: ((RequestError) -> Void)? = nil,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }
|
||||
) {
|
||||
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }) {
|
||||
guard (item.video?.streams ?? []).isEmpty else {
|
||||
completionHandler(item)
|
||||
return
|
||||
}
|
||||
|
||||
if let video = item.video, video.isLocal {
|
||||
completionHandler(item)
|
||||
return
|
||||
}
|
||||
|
||||
video(item.videoID).load()
|
||||
.onSuccess { response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
VideosCacheModel.shared.storeVideo(video)
|
||||
|
||||
var newItem = item
|
||||
newItem.id = UUID()
|
||||
newItem.video = video
|
||||
|
||||
completionHandler(newItem)
|
||||
video(item.videoID).load().onSuccess { response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
.onFailure { failureHandler?($0) }
|
||||
|
||||
var newItem = item
|
||||
newItem.video = video
|
||||
|
||||
completionHandler(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString)
|
||||
{
|
||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
guard var urlComponents else {
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
urlComponents.host = frontendHost
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
switch item.contentType {
|
||||
@@ -147,101 +116,4 @@ extension VideosAPI {
|
||||
|
||||
return urlComponents.url
|
||||
}
|
||||
|
||||
func extractChapters(from description: String) -> [Chapter] {
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
||||
3) "index. title - start" / "index. title start"
|
||||
4) "title: (start)"
|
||||
5) "(start) title"
|
||||
|
||||
These represent:
|
||||
|
||||
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
||||
- "title" is the name of the chapter
|
||||
- "index" is the chapter's position in a list
|
||||
|
||||
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
||||
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
||||
*/
|
||||
let patterns = [
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
||||
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
||||
]
|
||||
|
||||
let extractChaptersGroup = DispatchGroup()
|
||||
var capturedChapters: [Int: [Chapter]] = [:]
|
||||
let lock = NSLock()
|
||||
|
||||
for (index, pattern) in patterns.enumerated() {
|
||||
extractChaptersGroup.enter()
|
||||
DispatchQueue.global().async {
|
||||
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return Chapter(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
|
||||
if !extractedChapters.isEmpty {
|
||||
lock.lock()
|
||||
capturedChapters[index] = extractedChapters
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
extractChaptersGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
extractChaptersGroup.wait()
|
||||
|
||||
// Now we sort the keys of the capturedChapters dictionary.
|
||||
// These keys correspond to the priority of each pattern.
|
||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
||||
|
||||
// Return first non-empty result in the order of patterns
|
||||
for key in sortedKeys {
|
||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
enum VideosApp: String, CaseIterable {
|
||||
enum AppType: String {
|
||||
case local
|
||||
case youTube
|
||||
case peerTube
|
||||
}
|
||||
|
||||
case local
|
||||
case invidious
|
||||
case piped
|
||||
case peerTube
|
||||
case invidious, piped
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .peerTube:
|
||||
return "PeerTube"
|
||||
default:
|
||||
return rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
var appType: AppType {
|
||||
switch self {
|
||||
case .local:
|
||||
return .local
|
||||
case .invidious:
|
||||
return .youTube
|
||||
case .piped:
|
||||
return .youTube
|
||||
case .peerTube:
|
||||
return .peerTube
|
||||
}
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
self != .local
|
||||
true
|
||||
}
|
||||
|
||||
var accountsUsePassword: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var supportsPopular: Bool {
|
||||
@@ -46,34 +23,22 @@ enum VideosApp: String, CaseIterable {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSearchSuggestions: Bool {
|
||||
self != .peerTube
|
||||
}
|
||||
|
||||
var supportsSubscriptions: Bool {
|
||||
supportsAccounts
|
||||
}
|
||||
|
||||
var paginatesSubscriptions: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsTrendingCategories: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
self != .local
|
||||
true
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsUseChannelPlaylistEndpoint: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
@@ -86,19 +51,11 @@ enum VideosApp: String, CaseIterable {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningChannelsByName: Bool {
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious || self == .piped
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
self != .local
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct BaseCacheModel {
|
||||
static var shared = Self()
|
||||
|
||||
static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() }
|
||||
static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) }
|
||||
static let jsonTransformer = Transformer(toData: jsonToDataTransformer, fromData: jsonFromDataTransformer)
|
||||
|
||||
static let imageCache = URLCache(memoryCapacity: 512 * 1000 * 1000, diskCapacity: 10 * 1000 * 1000 * 1000)
|
||||
|
||||
var models: [CacheModel] {
|
||||
[
|
||||
FeedCacheModel.shared,
|
||||
VideosCacheModel.shared,
|
||||
ChannelsCacheModel.shared,
|
||||
PlaylistsCacheModel.shared,
|
||||
ChannelPlaylistsCacheModel.shared,
|
||||
SubscribedChannelsModel.shared
|
||||
]
|
||||
}
|
||||
|
||||
func clear() {
|
||||
models.forEach { $0.clear() }
|
||||
|
||||
Self.imageCache.removeAllCachedResponses()
|
||||
}
|
||||
|
||||
var totalSize: Int {
|
||||
models.compactMap { $0.storage?.totalDiskStorageSize }.reduce(0, +) + Self.imageCache.currentDiskUsage
|
||||
}
|
||||
|
||||
var totalSizeFormatted: String {
|
||||
byteCountFormatter.string(fromByteCount: Int64(totalSize))
|
||||
}
|
||||
|
||||
private var byteCountFormatter: ByteCountFormatter { .init() }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct BookmarksCacheModel {
|
||||
static var shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache")
|
||||
|
||||
static let bookmarksGroup = "group.stream.yattee.app.bookmarks"
|
||||
let defaults = UserDefaults(suiteName: Self.bookmarksGroup)
|
||||
|
||||
func clear() {
|
||||
guard let defaults else { return }
|
||||
defaults.dictionaryRepresentation().keys.forEach(defaults.removeObject(forKey:))
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
protocol CacheModel {
|
||||
var storage: Storage<String, JSON>? { get }
|
||||
|
||||
func clear()
|
||||
}
|
||||
|
||||
extension CacheModel {
|
||||
func clear() {
|
||||
try? storage?.removeAll()
|
||||
}
|
||||
|
||||
func getFormattedDate(_ date: Date?) -> String {
|
||||
guard let date else { return "unknown" }
|
||||
|
||||
let isSameDay = Calendar(identifier: .iso8601).isDate(date, inSameDayAs: Date())
|
||||
let formatter = isSameDay ? dateFormatterForTimeOnly : dateFormatter
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
var dateFormatterForTimeOnly: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
var iso8601DateFormatter: ISO8601DateFormatter { .init() }
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelPlaylistsCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.channel-playlists")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channel-playlists")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
var storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storePlaylist(playlist: ChannelPlaylist) {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("STORE \(playlist.cacheKey) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let playlistObject: JSON = ["playlist": playlist.json.object]
|
||||
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(playlist.cacheKey))
|
||||
try? storage?.setObject(playlistObject, forKey: playlist.cacheKey)
|
||||
}
|
||||
|
||||
func retrievePlaylist(_ playlist: ChannelPlaylist) -> ChannelPlaylist? {
|
||||
logger.info("RETRIEVE \(playlist.cacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: playlist.cacheKey).dictionaryValue["playlist"] {
|
||||
return ChannelPlaylist.from(json)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPlaylistsTime(_ id: ChannelPlaylist.ID) -> Date? {
|
||||
if let json = try? storage?.object(forKey: playlistTimeCacheKey(id)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFormattedPlaylistTime(_ id: ChannelPlaylist.ID) -> String {
|
||||
getFormattedDate(getPlaylistsTime(id))
|
||||
}
|
||||
|
||||
private func playlistTimeCacheKey(_ cacheKey: ChannelPlaylist.ID) -> String {
|
||||
"\(cacheKey)-time"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelsCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.channels")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channels")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func store(_ channel: Channel) {
|
||||
guard channel.hasExtendedDetails else {
|
||||
logger.debug("not caching \(channel.cacheKey)")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("caching \(channel.cacheKey)")
|
||||
try? storage?.setObject(channel.json, forKey: channel.cacheKey)
|
||||
}
|
||||
|
||||
func storeIfMissing(_ channel: Channel) {
|
||||
guard let storage, !storage.objectExists(forKey: channel.cacheKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
store(channel)
|
||||
}
|
||||
|
||||
func retrieve(_ cacheKey: String) -> ChannelPage? {
|
||||
logger.debug("retrieving cache for \(cacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: cacheKey) {
|
||||
return ChannelPage(channel: Channel.from(json))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import Cache
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct FeedCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.feed")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "feed")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storeFeed(account: Account, videos: [Video]) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map(\.json.object)]
|
||||
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
|
||||
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveFeed(account: Account) -> [Video] {
|
||||
logger.debug("retrieving cache for \(account.feedCacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: account.feedCacheKey),
|
||||
let videos = json.dictionaryValue["videos"]
|
||||
{
|
||||
return videos.arrayValue.map { Video.from($0) }
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func getFeedTime(account: Account) -> Date? {
|
||||
if let json = try? storage?.object(forKey: feedTimeCacheKey(account.feedCacheKey)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var cacheLimit: Int {
|
||||
let setting = Int(Defaults[.feedCacheSize]) ?? 0
|
||||
if setting > 0 {
|
||||
return setting
|
||||
}
|
||||
|
||||
return 50
|
||||
}
|
||||
|
||||
private func feedTimeCacheKey(_ feedCacheKey: String) -> String {
|
||||
"\(feedCacheKey)-feedTime"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlaylistsCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
static let limit = 30
|
||||
let logger = Logger(label: "stream.yattee.cache.playlists")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "playlists")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storePlaylist(account: Account, playlists: [Playlist]) {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching \(playlistCacheKey(account)) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let playlistsObject: JSON = ["playlists": playlists.map(\.json.object)]
|
||||
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
|
||||
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
|
||||
}
|
||||
|
||||
func retrievePlaylists(account: Account) -> [Playlist] {
|
||||
logger.debug("retrieving cache for \(playlistCacheKey(account))")
|
||||
|
||||
if let json = try? storage?.object(forKey: playlistCacheKey(account)),
|
||||
let playlists = json.dictionaryValue["playlists"]
|
||||
{
|
||||
return playlists.arrayValue.map { Playlist.from($0) }
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func getPlaylistsTime(account: Account) -> Date? {
|
||||
if let json = try? storage?.object(forKey: playlistTimeCacheKey(account)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFormattedPlaylistTime(account: Account) -> String {
|
||||
getFormattedDate(getPlaylistsTime(account: account))
|
||||
}
|
||||
|
||||
private func playlistCacheKey(_ account: Account) -> String {
|
||||
"playlists-\(account.id)"
|
||||
}
|
||||
|
||||
private func playlistTimeCacheKey(_ account: Account) -> String {
|
||||
"\(playlistCacheKey(account))-time"
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
static var shared = SubscribedChannelsModel()
|
||||
let logger = Logger(label: "stream.yattee.cache.channels")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "channels")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: SubscribedChannelsModel.diskConfig,
|
||||
memoryConfig: SubscribedChannelsModel.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var channels = [Channel]()
|
||||
@Published var error: RequestError?
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
var unwatchedFeedCount: UnwatchedFeedCountModel { .shared }
|
||||
|
||||
var resource: Resource? {
|
||||
accounts.api.subscriptions
|
||||
}
|
||||
|
||||
var all: [Channel] {
|
||||
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
var allByUnwatchedCount: [Channel] {
|
||||
if let account = accounts.current {
|
||||
return all.sorted { c1, c2 in
|
||||
let c1HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c1.id] ?? -1) > 0
|
||||
let c2HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c2.id] ?? -1) > 0
|
||||
let nameIncreasing = c1.name.lowercased() < c2.name.lowercased()
|
||||
|
||||
return c1HasUnwatched ? (c2HasUnwatched ? nameIncreasing : true) : (c2HasUnwatched ? false : nameIncreasing)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.subscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.unsubscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func isSubscribing(_ channelID: String) -> Bool {
|
||||
channels.contains { $0.id == channelID }
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsSubscriptions, !isLoading, accounts.signedIn, let account = accounts.current else {
|
||||
channels = []
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let request = force ? self.resource?.load() : self.resource?.loadIfNeeded()
|
||||
guard request != nil else { return }
|
||||
|
||||
self.loadCachedChannels(account)
|
||||
|
||||
self.isLoading = true
|
||||
|
||||
request?
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
}
|
||||
.onSuccess { resource in
|
||||
self.error = nil
|
||||
if let channels: [Channel] = resource.typedContent() {
|
||||
self.channels = channels
|
||||
self.storeChannels(account: account, channels: channels)
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
.onFailure { self.error = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
func loadCachedChannels(_ account: Account) {
|
||||
let cache = getChannels(account: account)
|
||||
if !cache.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.channels = cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func storeChannels(account: Account, channels: [Channel]) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let date = self.iso8601DateFormatter.string(from: Date())
|
||||
self.logger.info("caching channels \(self.channelsDateCacheKey(account)) -- \(date)")
|
||||
|
||||
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||
|
||||
let dateObject: JSON = ["date": date]
|
||||
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
|
||||
|
||||
try? self.storage?.setObject(dateObject, forKey: self.channelsDateCacheKey(account))
|
||||
try? self.storage?.setObject(channelsObject, forKey: self.channelsCacheKey(account))
|
||||
}
|
||||
}
|
||||
|
||||
func getChannels(account: Account) -> [Channel] {
|
||||
logger.info("getting channels \(channelsDateCacheKey(account))")
|
||||
|
||||
if let json = try? storage?.object(forKey: channelsCacheKey(account)),
|
||||
let channels = json.dictionaryValue["channels"]
|
||||
{
|
||||
return channels.arrayValue.compactMap { json in
|
||||
let channel = Channel.from(json)
|
||||
if !channel.hasExtendedDetails,
|
||||
let cache = ChannelsCacheModel.shared.retrieve(channel.cacheKey)
|
||||
{
|
||||
return cache.channel
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private func scheduleLoad(onSuccess: @escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
private func channelsCacheKey(_ account: Account) -> String {
|
||||
"channels-\(account.id)"
|
||||
}
|
||||
|
||||
private func channelsDateCacheKey(_ account: Account) -> String {
|
||||
"channels-\(account.id)-date"
|
||||
}
|
||||
|
||||
func getChannelsTime(account: Account) -> Date? {
|
||||
if let json = try? storage?.object(forKey: channelsDateCacheKey(account)),
|
||||
let string = json.dictionaryValue["date"]?.string,
|
||||
let date = iso8601DateFormatter.date(from: string)
|
||||
{
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var channelsTime: Date? {
|
||||
if let account = accounts.current {
|
||||
return getChannelsTime(account: account)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var formattedCacheTime: String {
|
||||
getFormattedDate(channelsTime)
|
||||
}
|
||||
|
||||
func onAccountChange() {
|
||||
channels = []
|
||||
load(force: true)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import Cache
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
struct VideosCacheModel: CacheModel {
|
||||
static let shared = Self()
|
||||
let logger = Logger(label: "stream.yattee.cache.videos")
|
||||
|
||||
static let diskConfig = DiskConfig(name: "videos")
|
||||
static let memoryConfig = MemoryConfig()
|
||||
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storeVideo(_ video: Video) {
|
||||
logger.info("caching \(video.cacheKey)")
|
||||
try? storage?.setObject(video.json, forKey: video.cacheKey)
|
||||
|
||||
ChannelsCacheModel.shared.storeIfMissing(video.channel)
|
||||
}
|
||||
|
||||
func retrieveVideo(_ cacheKey: String) -> Video? {
|
||||
logger.debug("retrieving cache for \(cacheKey)")
|
||||
|
||||
if let json = try? storage?.object(forKey: cacheKey) {
|
||||
return Video.from(json)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Captions: Hashable, Identifiable {
|
||||
var id = UUID().uuidString
|
||||
let label: String
|
||||
let code: String
|
||||
let url: URL
|
||||
|
||||
var description: String {
|
||||
"\(label) (\(code))"
|
||||
}
|
||||
}
|
||||
@@ -4,170 +4,43 @@ import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Channel: Identifiable, Hashable {
|
||||
enum ContentType: String, Identifiable, CaseIterable {
|
||||
case videos
|
||||
case playlists
|
||||
case livestreams
|
||||
case shorts
|
||||
case channels
|
||||
case releases
|
||||
case podcasts
|
||||
|
||||
static func from(_ name: String) -> Self? {
|
||||
let rawValueMatch = allCases.first { $0.rawValue == name }
|
||||
guard rawValueMatch.isNil else { return rawValueMatch! }
|
||||
|
||||
if name == "streams" { return .livestreams }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .livestreams:
|
||||
return "Live Streams".localized()
|
||||
default:
|
||||
return rawValue.capitalized.localized()
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .videos:
|
||||
return "video"
|
||||
case .playlists:
|
||||
return "list.and.film"
|
||||
case .livestreams:
|
||||
return "dot.radiowaves.left.and.right"
|
||||
case .shorts:
|
||||
return "1.square"
|
||||
case .channels:
|
||||
return "person.3"
|
||||
case .releases:
|
||||
return "square.stack"
|
||||
case .podcasts:
|
||||
return "radio"
|
||||
}
|
||||
}
|
||||
|
||||
var alwaysAvailable: Bool {
|
||||
self == .videos || self == .playlists
|
||||
}
|
||||
}
|
||||
|
||||
struct Tab: Identifiable, Hashable {
|
||||
var contentType: ContentType
|
||||
var data: String
|
||||
|
||||
var id: String {
|
||||
contentType.id
|
||||
}
|
||||
}
|
||||
|
||||
var app: VideosApp
|
||||
var instanceID: Instance.ID?
|
||||
var instanceURL: URL?
|
||||
|
||||
var id: String
|
||||
var name: String
|
||||
var bannerURL: URL?
|
||||
var thumbnailURL: URL?
|
||||
var description = ""
|
||||
|
||||
var subscriptionsCount: Int?
|
||||
var subscriptionsText: String?
|
||||
|
||||
var totalViews: Int?
|
||||
// swiftlint:disable discouraged_optional_boolean
|
||||
var verified: Bool?
|
||||
// swiftlint:enable discouraged_optional_boolean
|
||||
|
||||
var videos = [Video]()
|
||||
var tabs = [Tab]()
|
||||
|
||||
private var subscriptionsCount: Int?
|
||||
private var subscriptionsText: String?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
thumbnailURL: URL? = nil,
|
||||
subscriptionsCount: Int? = nil,
|
||||
subscriptionsText: String? = nil,
|
||||
videos: [Video] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.thumbnailURL = thumbnailURL
|
||||
self.subscriptionsCount = subscriptionsCount
|
||||
self.subscriptionsText = subscriptionsText
|
||||
self.videos = videos
|
||||
}
|
||||
|
||||
var detailsLoaded: Bool {
|
||||
!subscriptionsString.isNil
|
||||
}
|
||||
|
||||
var subscriptionsString: String? {
|
||||
if let subscriptionsCount, subscriptionsCount > 0 {
|
||||
return subscriptionsCount.formattedAsAbbreviation()
|
||||
if subscriptionsCount != nil, subscriptionsCount! > 0 {
|
||||
return subscriptionsCount!.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
return subscriptionsText
|
||||
}
|
||||
|
||||
var totalViewsString: String? {
|
||||
guard let totalViews, totalViews > 0 else { return nil }
|
||||
|
||||
return totalViews.formattedAsAbbreviation()
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
var contentItem: ContentItem {
|
||||
ContentItem(channel: self)
|
||||
}
|
||||
|
||||
func hasData(for contentType: ContentType) -> Bool {
|
||||
tabs.contains { $0.contentType == contentType }
|
||||
}
|
||||
|
||||
var cacheKey: String {
|
||||
switch app {
|
||||
case .local:
|
||||
return id
|
||||
case .invidious:
|
||||
return "youtube-\(id)"
|
||||
case .piped:
|
||||
return "youtube-\(id)"
|
||||
case .peerTube:
|
||||
return "peertube-\(instanceURL?.absoluteString ?? "unknown-instance")-\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
var hasExtendedDetails: Bool {
|
||||
thumbnailURL != nil
|
||||
}
|
||||
|
||||
var thumbnailURLOrCached: URL? {
|
||||
thumbnailURL ?? ChannelsCacheModel.shared.retrieve(cacheKey)?.channel?.thumbnailURL
|
||||
}
|
||||
|
||||
var json: JSON {
|
||||
[
|
||||
"app": app.rawValue,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"bannerURL": bannerURL?.absoluteString as Any,
|
||||
"thumbnailURL": thumbnailURL?.absoluteString as Any,
|
||||
"description": description,
|
||||
"subscriptionsCount": subscriptionsCount as Any,
|
||||
"subscriptionsText": subscriptionsText as Any,
|
||||
"totalViews": totalViews as Any,
|
||||
"verified": verified as Any,
|
||||
"videos": videos.map(\.json.object)
|
||||
]
|
||||
}
|
||||
|
||||
static func from(_ json: JSON) -> Self {
|
||||
.init(
|
||||
app: VideosApp(rawValue: json["app"].stringValue) ?? .local,
|
||||
id: json["id"].stringValue,
|
||||
name: json["name"].stringValue,
|
||||
bannerURL: json["bannerURL"].url,
|
||||
thumbnailURL: json["thumbnailURL"].url,
|
||||
description: json["description"].stringValue,
|
||||
subscriptionsCount: json["subscriptionsCount"].int,
|
||||
subscriptionsText: json["subscriptionsText"].string,
|
||||
totalViews: json["totalViews"].int,
|
||||
videos: json["videos"].arrayValue.map { Video.from($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct ChannelPage {
|
||||
var results = [ContentItem]()
|
||||
var channel: Channel?
|
||||
var nextPage: String?
|
||||
var last = false
|
||||
}
|
||||
@@ -1,37 +1,10 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct ChannelPlaylist: Identifiable {
|
||||
var id: String
|
||||
var id: String = UUID().uuidString
|
||||
var title: String
|
||||
var thumbnailURL: URL?
|
||||
var channel: Channel?
|
||||
var videos = [Video]()
|
||||
var videosCount: Int?
|
||||
|
||||
var cacheKey: String {
|
||||
"channelplaylists-\(id)"
|
||||
}
|
||||
|
||||
var json: JSON {
|
||||
[
|
||||
"id": id,
|
||||
"title": title,
|
||||
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
|
||||
"channel": channel?.json.object ?? "",
|
||||
"videos": videos.map(\.json.object),
|
||||
"videosCount": String(videosCount ?? 0)
|
||||
]
|
||||
}
|
||||
|
||||
static func from(_ json: JSON) -> Self {
|
||||
Self(
|
||||
id: json["id"].stringValue,
|
||||
title: json["title"].stringValue,
|
||||
thumbnailURL: json["thumbnailURL"].url,
|
||||
channel: Channel.from(json["channel"]),
|
||||
videos: json["videos"].arrayValue.map { Video.from($0) },
|
||||
videosCount: json["videosCount"].int
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct Chapter: Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var title: String
|
||||
var image: URL?
|
||||
var start: Double
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class CommentsModel: ObservableObject {
|
||||
static let shared = CommentsModel()
|
||||
|
||||
@Published var all = [Comment]()
|
||||
|
||||
@Published var nextPage: String?
|
||||
@@ -17,41 +15,56 @@ final class CommentsModel: ObservableObject {
|
||||
@Published var repliesPageID: String?
|
||||
@Published var repliesLoaded = false
|
||||
|
||||
var player = PlayerModel.shared
|
||||
var accounts = AccountsModel.shared
|
||||
var player: PlayerModel!
|
||||
|
||||
var instance: Instance? {
|
||||
accounts.current?.instance
|
||||
static var instance: Instance? {
|
||||
InstancesModel.find(Defaults[.commentsInstanceID])
|
||||
}
|
||||
|
||||
var api: VideosAPI? {
|
||||
Self.instance.isNil ? nil : PipedAPI(account: Self.instance!.anonymousAccount)
|
||||
}
|
||||
|
||||
static var enabled: Bool {
|
||||
!instance.isNil
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
static var placement: CommentsPlacement {
|
||||
Defaults[.commentsPlacement]
|
||||
}
|
||||
#endif
|
||||
|
||||
var nextPageAvailable: Bool {
|
||||
!(nextPage?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func loadIfNeeded() {
|
||||
guard !loaded else { return }
|
||||
load()
|
||||
}
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard let video = player.currentVideo else { return }
|
||||
guard firstPage || nextPageAvailable else { return }
|
||||
guard Self.enabled else {
|
||||
return
|
||||
}
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
guard !Self.instance.isNil,
|
||||
!(player?.currentVideo.isNil ?? true)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
guard let self else { return }
|
||||
if let commentsPage: CommentsPage = response.typedContent() {
|
||||
self.all += commentsPage.comments
|
||||
self.nextPage = commentsPage.nextPage
|
||||
self.disabled = commentsPage.disabled
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] _ in
|
||||
self?.disabled = true
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
}
|
||||
@@ -65,7 +78,6 @@ final class CommentsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard nextPageAvailable else { return }
|
||||
load(page: nextPage)
|
||||
}
|
||||
|
||||
@@ -82,7 +94,7 @@ final class CommentsModel: ObservableObject {
|
||||
repliesPageID = page
|
||||
repliesLoaded = false
|
||||
|
||||
accounts.api.comments(player.currentVideo!.videoID, page: page)?
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
|
||||
@@ -15,51 +15,26 @@ struct ContentItem: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
static func < (lhs: ContentType, rhs: ContentType) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
static var placeholders: [Self] {
|
||||
(0 ..< 9).map { i in .init(id: String(i)) }
|
||||
}
|
||||
|
||||
var video: Video!
|
||||
var playlist: ChannelPlaylist!
|
||||
var channel: Channel!
|
||||
|
||||
var id: String = UUID().uuidString
|
||||
|
||||
static func array(of videos: [Video]) -> [Self] {
|
||||
videos.map { Self(video: $0) }
|
||||
static func array(of videos: [Video]) -> [ContentItem] {
|
||||
videos.map { ContentItem(video: $0) }
|
||||
}
|
||||
|
||||
static func array(of playlists: [ChannelPlaylist]) -> [Self] {
|
||||
playlists.map { Self(playlist: $0) }
|
||||
}
|
||||
|
||||
static func array(of channels: [Channel]) -> [Self] {
|
||||
channels.map { Self(channel: $0) }
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
|
||||
lhs.contentType < rhs.contentType
|
||||
}
|
||||
|
||||
var contentType: ContentType {
|
||||
video.isNil ? (channel.isNil ? (playlist.isNil ? .placeholder : .playlist) : .channel) : .video
|
||||
}
|
||||
|
||||
var cacheKey: String {
|
||||
switch contentType {
|
||||
case .video:
|
||||
return video.cacheKey
|
||||
case .playlist:
|
||||
return playlist.cacheKey
|
||||
case .channel:
|
||||
return channel.cacheKey
|
||||
case .placeholder:
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ extension Country {
|
||||
case .lk: return "Sri Lanka"
|
||||
case .se: return "Sweden"
|
||||
case .ch: return "Switzerland"
|
||||
case .tw: return "Taiwan"
|
||||
case .tw: return "Taiwan, Province of China[a]"
|
||||
case .tz: return "Tanzania, United Republic of"
|
||||
case .th: return "Thailand"
|
||||
case .tn: return "Tunisia"
|
||||
@@ -234,8 +234,6 @@ extension Country {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable switch_case_on_newline
|
||||
|
||||
var flag: String {
|
||||
let unicodeScalars = rawValue
|
||||
.unicodeScalars
|
||||
@@ -274,7 +272,7 @@ extension Country {
|
||||
|
||||
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
||||
Country.allCases
|
||||
.map(\.name)
|
||||
.map { $0.name }
|
||||
.filter(predicate)
|
||||
.compactMap { string in Country.allCases.first { $0.name == string } }
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class DocumentsModel: ObservableObject {
|
||||
static var shared = DocumentsModel()
|
||||
|
||||
@Published private(set) var refreshID = UUID()
|
||||
|
||||
typealias AreInIncreasingOrder = (URL, URL) -> Bool
|
||||
|
||||
private var fileManager: FileManager {
|
||||
.default
|
||||
}
|
||||
|
||||
var sortPredicates: [AreInIncreasingOrder] {
|
||||
[
|
||||
{ self.isDirectory($0) && !self.isDirectory($1) },
|
||||
{ $0.lastPathComponent.caseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }
|
||||
]
|
||||
}
|
||||
|
||||
func sortedDirectoryContents(_ directoryURL: URL) -> [URL] {
|
||||
directoryContents(directoryURL).sorted { lhs, rhs in
|
||||
for predicate in sortPredicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func directoryContents(_ directoryURL: URL) -> [URL] {
|
||||
contents(of: directoryURL)
|
||||
}
|
||||
|
||||
var documentsDirectory: URL? {
|
||||
if let url = try? fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
|
||||
return standardizedURL(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recentDocuments(_ limit: Int = 10) -> [URL] {
|
||||
guard let documentsDirectory else { return [] }
|
||||
|
||||
return Array(
|
||||
contents(of: documentsDirectory)
|
||||
.filter { !isDirectory($0) }
|
||||
.sorted {
|
||||
((try? $0.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date()) >
|
||||
((try? $1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date())
|
||||
}
|
||||
.prefix(limit)
|
||||
)
|
||||
}
|
||||
|
||||
func isDocument(_ video: Video) -> Bool {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return false }
|
||||
return isDocument(url)
|
||||
}
|
||||
|
||||
func isDocument(_ url: URL) -> Bool {
|
||||
guard let url = standardizedURL(url), let documentsDirectory else { return false }
|
||||
return url.absoluteString.starts(with: documentsDirectory.absoluteString)
|
||||
}
|
||||
|
||||
func isDirectory(_ url: URL) -> Bool {
|
||||
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
}
|
||||
|
||||
var creationDateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
formatter.setLocalizedDateFormatFromTemplate("YYMMddHHmm")
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
func creationDate(_ video: Video) -> Date? {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
|
||||
return creationDate(url)
|
||||
}
|
||||
|
||||
func creationDate(_ url: URL) -> Date? {
|
||||
try? url.resourceValues(forKeys: [.creationDateKey]).creationDate
|
||||
}
|
||||
|
||||
func formattedCreationDate(_ video: Video) -> String? {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
|
||||
return formattedCreationDate(url)
|
||||
}
|
||||
|
||||
func formattedCreationDate(_ url: URL) -> String? {
|
||||
if let date = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate {
|
||||
return creationDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var sizeFormatter: ByteCountFormatter {
|
||||
let formatter = ByteCountFormatter()
|
||||
|
||||
formatter.allowedUnits = .useAll
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.isAdaptive = true
|
||||
|
||||
return formatter
|
||||
}
|
||||
|
||||
func size(_ video: Video) -> Int? {
|
||||
guard video.isLocal, let url = video.localStream?.localURL, let url = standardizedURL(url) else { return nil }
|
||||
return size(url)
|
||||
}
|
||||
|
||||
func size(_ url: URL) -> Int? {
|
||||
try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]).fileAllocatedSize
|
||||
}
|
||||
|
||||
func formattedSize(_ video: Video) -> String? {
|
||||
guard let size = size(video) else { return nil }
|
||||
return sizeFormatter.string(fromByteCount: Int64(size))
|
||||
}
|
||||
|
||||
func formattedSize(_ url: URL) -> String? {
|
||||
guard let size = size(url) else { return nil }
|
||||
return sizeFormatter.string(fromByteCount: Int64(size))
|
||||
}
|
||||
|
||||
func removeDocument(_ url: URL) throws {
|
||||
guard isDocument(url) else { return }
|
||||
try fileManager.removeItem(at: url)
|
||||
URLBookmarkModel.shared.removeBookmark(url)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func contents(of directory: URL) -> [URL] {
|
||||
(try? fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.creationDateKey, .fileAllocatedSizeKey, .isDirectoryKey],
|
||||
options: [.includesDirectoriesPostOrder, .skipsHiddenFiles]
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
func displayLabelForDocument(_ file: URL) -> String {
|
||||
let components = file.absoluteString.components(separatedBy: "/Documents/")
|
||||
if components.count == 2 {
|
||||
let component = components[1]
|
||||
return component.isEmpty ? "Documents" : component.removingPercentEncoding ?? component
|
||||
}
|
||||
return "Document"
|
||||
}
|
||||
|
||||
func standardizedURL(_ url: URL) -> URL? {
|
||||
let standardizedURL = NSString(string: url.absoluteString).standardizingPath
|
||||
return URL(string: standardizedURL)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
refreshID = UUID()
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,16 @@ import Foundation
|
||||
|
||||
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
enum Section: Codable, Equatable, Defaults.Serializable {
|
||||
case history
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending(String, String?)
|
||||
case channel(String, String, String)
|
||||
case playlist(String, String)
|
||||
case channelPlaylist(String, String, String)
|
||||
case channel(String, String)
|
||||
case playlist(String)
|
||||
case channelPlaylist(String, String)
|
||||
case searchQuery(String, String, String, String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .history:
|
||||
return "History"
|
||||
case .subscriptions:
|
||||
return "Subscriptions"
|
||||
case .popular:
|
||||
@@ -24,9 +21,9 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
let trendingCountry = Country(rawValue: country)!
|
||||
let trendingCategory = category.isNil ? nil : TrendingCategory(rawValue: category!)
|
||||
return "\(trendingCountry.flag) \(trendingCountry.id) \(trendingCategory?.name ?? "Trending")"
|
||||
case let .channel(_, _, name):
|
||||
case let .channel(_, name):
|
||||
return name
|
||||
case let .channelPlaylist(_, _, name):
|
||||
case let .channelPlaylist(_, name):
|
||||
return name
|
||||
case let .searchQuery(text, date, duration, order):
|
||||
var label = "Search: \"\(text)\""
|
||||
@@ -47,14 +44,10 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
static func == (lhs: FavoriteItem, rhs: FavoriteItem) -> Bool {
|
||||
lhs.section == rhs.section
|
||||
}
|
||||
|
||||
var id = UUID().uuidString
|
||||
var section: Section
|
||||
|
||||
var widgetSettingsKey: String {
|
||||
"favorites-\(id)"
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = Self()
|
||||
|
||||
@Default(.showFavoritesInHome) var showFavoritesInHome
|
||||
@Default(.favorites) var all
|
||||
@Default(.widgetsSettings) var widgetsSettings
|
||||
|
||||
var isEnabled: Bool {
|
||||
showFavoritesInHome
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
}
|
||||
|
||||
func toggle(_ item: FavoriteItem) {
|
||||
if contains(item) {
|
||||
remove(item)
|
||||
} else {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
if contains(item) { return }
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
func remove(_ item: FavoriteItem) {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
all.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func canMoveUp(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index > all.startIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canMoveDown(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index < all.endIndex - 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func moveUp(_ item: FavoriteItem) {
|
||||
guard canMoveUp(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func moveDown(_ item: FavoriteItem) {
|
||||
guard canMoveDown(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func addableItems() -> [FavoriteItem] {
|
||||
let allItems = [
|
||||
FavoriteItem(section: .subscriptions),
|
||||
FavoriteItem(section: .popular),
|
||||
FavoriteItem(section: .history)
|
||||
]
|
||||
|
||||
return allItems.filter { item in !all.contains { $0.section == item.section } }
|
||||
}
|
||||
|
||||
func listingStyle(_ item: FavoriteItem) -> WidgetListingStyle {
|
||||
widgetSettings(item).listingStyle
|
||||
}
|
||||
|
||||
func limit(_ item: FavoriteItem) -> Int {
|
||||
min(WidgetSettings.maxLimit(listingStyle(item)), widgetSettings(item).limit)
|
||||
}
|
||||
|
||||
func setListingStyle(_ style: WidgetListingStyle, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
settings.listingStyle = style
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
let settings = WidgetSettings(id: item.widgetSettingsKey, listingStyle: style)
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func setLimit(_ limit: Int, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
var settings = WidgetSettings(id: item.widgetSettingsKey, limit: limit)
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||
}
|
||||
|
||||
func updateWidgetSettings(_ settings: WidgetSettings) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Model/FavoritesModel.swift
Normal file
82
Model/FavoritesModel.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = FavoritesModel()
|
||||
|
||||
@Default(.favorites) var all
|
||||
@Default(.visibleSections) var visibleSections
|
||||
|
||||
var isEnabled: Bool {
|
||||
visibleSections.contains(.favorites)
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
}
|
||||
|
||||
func toggle(_ item: FavoriteItem) {
|
||||
contains(item) ? remove(item) : add(item)
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
func remove(_ item: FavoriteItem) {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
all.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func canMoveUp(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index > all.startIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canMoveDown(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index < all.endIndex - 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func moveUp(_ item: FavoriteItem) {
|
||||
guard canMoveUp(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func moveDown(_ item: FavoriteItem) {
|
||||
guard canMoveDown(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func addableItems() -> [FavoriteItem] {
|
||||
let allItems = [
|
||||
FavoriteItem(section: .subscriptions),
|
||||
FavoriteItem(section: .popular)
|
||||
]
|
||||
|
||||
return allItems.filter { item in !all.contains { $0.section == item.section } }
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import Cache
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class FeedModel: ObservableObject, CacheModel {
|
||||
static let shared = FeedModel()
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var videos = [Video]()
|
||||
@Published private var page = 1
|
||||
@Published var watchedUUID = UUID()
|
||||
|
||||
private var feedCount = UnwatchedFeedCountModel.shared
|
||||
private var cacheModel = FeedCacheModel.shared
|
||||
private var accounts = AccountsModel.shared
|
||||
|
||||
var storage: Storage<String, JSON>?
|
||||
|
||||
@Published var error: RequestError?
|
||||
|
||||
private var backgroundContext = PersistenceController.shared.container.newBackgroundContext()
|
||||
|
||||
var feed: Resource? {
|
||||
accounts.api.feed(page)
|
||||
}
|
||||
|
||||
func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if force || self.videos.isEmpty {
|
||||
self.loadCachedFeed()
|
||||
}
|
||||
|
||||
if self.accounts.app == .invidious {
|
||||
// Invidious for some reason won't refresh feed until homepage is loaded
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let home = self.accounts.api.home else { return }
|
||||
self.request(home, force: force)?
|
||||
.onCompletion { _ in
|
||||
self.loadFeed(force: force, onCompletion: onCompletion)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.loadFeed(force: force, onCompletion: onCompletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFeed(force: Bool = false, paginating: Bool = false, onCompletion: @escaping () -> Void = {}) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
!self.isLoading,
|
||||
let account = self.accounts.current
|
||||
else {
|
||||
self?.isLoading = false
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if paginating {
|
||||
self.page += 1
|
||||
} else {
|
||||
self.page = 1
|
||||
}
|
||||
|
||||
let feedBeforeLoad = self.feed
|
||||
var request: Request?
|
||||
if let feedBeforeLoad {
|
||||
request = self.request(feedBeforeLoad, force: force)
|
||||
}
|
||||
if request != nil {
|
||||
self.isLoading = true
|
||||
}
|
||||
|
||||
request?
|
||||
.onCompletion { _ in
|
||||
self.isLoading = false
|
||||
onCompletion()
|
||||
}
|
||||
.onSuccess { response in
|
||||
self.error = nil
|
||||
if let videos: [Video] = response.typedContent() {
|
||||
if paginating {
|
||||
self.videos.append(contentsOf: videos)
|
||||
} else {
|
||||
self.videos = videos
|
||||
self.cacheModel.storeFeed(account: account, videos: self.videos)
|
||||
self.calculateUnwatchedFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { self.error = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
videos.removeAll()
|
||||
page = 1
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard accounts.app.paginatesSubscriptions, !isLoading else { return }
|
||||
|
||||
loadFeed(force: true, paginating: true)
|
||||
}
|
||||
|
||||
func onAccountChange() {
|
||||
reset()
|
||||
error = nil
|
||||
loadResources(force: true)
|
||||
calculateUnwatchedFeed()
|
||||
}
|
||||
|
||||
func calculateUnwatchedFeed() {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let feed = cacheModel.retrieveFeed(account: account)
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter(\.finished)
|
||||
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
|
||||
let unwatchedCount = max(0, feed.count - watched.count)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if unwatchedCount != self.feedCount.unwatched[account] {
|
||||
self.feedCount.unwatched[account] = unwatchedCount
|
||||
}
|
||||
|
||||
let byChannel = Dictionary(grouping: unwatched) { $0.channel.id }.mapValues(\.count)
|
||||
self.feedCount.unwatchedByChannel[account] = byChannel
|
||||
self.watchedUUID = UUID()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markAllFeedAsWatched() {
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos, watched: true, watchedAt: Date(timeIntervalSince1970: 0))
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
var canMarkAllFeedAsWatched: Bool {
|
||||
guard let account = accounts.current, accounts.signedIn else { return false }
|
||||
return (feedCount.unwatched[account] ?? 0) > 0
|
||||
}
|
||||
|
||||
func canMarkChannelAsWatched(_ channelID: Channel.ID) -> Bool {
|
||||
guard let account = accounts.current, accounts.signedIn else { return false }
|
||||
|
||||
return feedCount.unwatchedByChannel[account]?.keys.contains(channelID) ?? false
|
||||
}
|
||||
|
||||
func markChannelAsWatched(_ channelID: Channel.ID) {
|
||||
guard accounts.signedIn else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: true)
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
func markChannelAsUnwatched(_ channelID: Channel.ID) {
|
||||
guard accounts.signedIn else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos.filter { $0.channel.id == channelID }, watched: false)
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
func markAllFeedAsUnwatched() {
|
||||
guard accounts.current != nil else { return }
|
||||
|
||||
let mark = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.markVideos(self.videos, watched: false)
|
||||
}
|
||||
|
||||
if videos.isEmpty {
|
||||
loadCachedFeed { mark() }
|
||||
} else {
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
func markVideos(_ videos: [Video], watched: Bool, watchedAt: Date? = nil) {
|
||||
guard accounts.signedIn, let account = accounts.current else { return }
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if watched {
|
||||
videos.forEach { Watch.markAsWatched(videoID: $0.videoID, account: account, duration: $0.length, watchedAt: watchedAt, context: self.backgroundContext) }
|
||||
} else {
|
||||
let watches = self.watchFetchRequestResult(videos, context: self.backgroundContext)
|
||||
watches.forEach { self.backgroundContext.delete($0) }
|
||||
}
|
||||
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
self.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func playUnwatchedFeed() {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let videos = cacheModel.retrieveFeed(account: account)
|
||||
guard !videos.isEmpty else { return }
|
||||
|
||||
let watches = watchFetchRequestResult(videos, context: backgroundContext)
|
||||
let watchesIDs = watches.map(\.videoID)
|
||||
let unwatched = videos.filter { video in
|
||||
if FeatureFlags.hideShortsEnabled, Defaults[.hideShorts], video.short {
|
||||
return false
|
||||
}
|
||||
|
||||
if !watchesIDs.contains(video.videoID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let watch = watches.first(where: { $0.videoID == video.videoID }),
|
||||
watch.finished
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
guard !unwatched.isEmpty else { return }
|
||||
PlayerModel.shared.play(unwatched)
|
||||
}
|
||||
|
||||
var canPlayUnwatchedFeed: Bool {
|
||||
guard let account = accounts.current, accounts.signedIn else { return false }
|
||||
return (feedCount.unwatched[account] ?? 0) > 0
|
||||
}
|
||||
|
||||
var watchedId: String {
|
||||
watchedUUID.uuidString
|
||||
}
|
||||
|
||||
var feedTime: Date? {
|
||||
if let account = accounts.current {
|
||||
return cacheModel.getFeedTime(account: account)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var formattedFeedTime: String {
|
||||
getFormattedDate(feedTime)
|
||||
}
|
||||
|
||||
private func loadCachedFeed(_ onCompletion: @escaping () -> Void = {}) {
|
||||
guard let account = accounts.current, accounts.signedIn else { return }
|
||||
let cache = cacheModel.retrieveFeed(account: account)
|
||||
if !cache.isEmpty {
|
||||
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
||||
self?.videos = cache
|
||||
onCompletion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func request(_ resource: Resource, force: Bool = false) -> Request? {
|
||||
if force {
|
||||
return resource.load()
|
||||
}
|
||||
|
||||
return resource.loadIfNeeded()
|
||||
}
|
||||
|
||||
private func watchFetchRequestResult(_ videos: [Video], context: NSManagedObjectContext) -> [Watch] {
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID IN %@", videos.map(\.videoID) as [String])
|
||||
return (try? context.fetch(watchFetchRequest)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -2,127 +2,80 @@ import CoreData
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
extension PlayerModel {
|
||||
func historyVideo(_ id: String) -> Video? {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ watch: Watch, onCompletion: @escaping () -> Void = {}) {
|
||||
guard historyVideo(watch.videoID).isNil else {
|
||||
onCompletion()
|
||||
func loadHistoryVideoDetails(_ id: Video.ID) {
|
||||
guard historyVideo(id).isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) {
|
||||
historyVideos.append(.local(url))
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if let video = VideosCacheModel.shared.retrieveVideo(watch.video.cacheKey) {
|
||||
historyVideos.append(video)
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
guard let api = playerAPI(watch.video) else { return }
|
||||
|
||||
api.video(watch.videoID)
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
if let video: Video = response.typedContent() {
|
||||
VideosCacheModel.shared.storeVideo(video)
|
||||
self.historyVideos.append(video)
|
||||
onCompletion()
|
||||
}
|
||||
}
|
||||
.onCompletion { _ in
|
||||
self.logger.info("LOADED history details: \(watch.videoID)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
|
||||
guard let currentVideo, saveHistory, isPlaying else { return }
|
||||
|
||||
let id = currentVideo.videoID
|
||||
let time = time ?? backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
if seconds < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
|
||||
|
||||
let results = try? backgroundContext.fetch(watchFetchRequest)
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self, finished || time != nil || self.backend.isPlaying else {
|
||||
accounts.api.video(id).load().onSuccess { [weak self] response in
|
||||
guard let video: Video = response.typedContent() else {
|
||||
return
|
||||
}
|
||||
|
||||
let watch: Watch!
|
||||
|
||||
let duration = self.activeBackend == .mpv ? self.playerTime.duration.seconds : self.avPlayerBackend.playerItemDuration?.seconds ?? 0
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
watch = Watch(context: self.backgroundContext)
|
||||
watch.videoID = id
|
||||
watch.appName = currentVideo.app.rawValue
|
||||
watch.instanceURL = currentVideo.instanceURL
|
||||
} else {
|
||||
watch = results?.first
|
||||
}
|
||||
|
||||
if duration.isFinite, duration > 0 {
|
||||
watch.videoDuration = duration
|
||||
}
|
||||
|
||||
if watch.finished {
|
||||
if !finished, self.resetWatchedStatusOnPlaying, seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
} else if seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
|
||||
watch.watchedAt = Date()
|
||||
|
||||
try? self.backgroundContext.save()
|
||||
self?.historyVideos.append(video)
|
||||
}
|
||||
}
|
||||
|
||||
func removeHistory() {
|
||||
removeAllWatches()
|
||||
BookmarksCacheModel.shared.clear()
|
||||
func updateWatch(finished: Bool = false) {
|
||||
guard let id = currentVideo?.videoID,
|
||||
Defaults[.saveHistory]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let time = backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
|
||||
let watch: Watch!
|
||||
let watchFetchRequest = Watch.fetchRequest()
|
||||
watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", id as String)
|
||||
|
||||
let results = try? context.fetch(watchFetchRequest)
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
if seconds < 1 {
|
||||
return
|
||||
}
|
||||
watch = Watch(context: context)
|
||||
watch.videoID = id
|
||||
} else {
|
||||
watch = results?.first
|
||||
|
||||
if !Defaults[.resetWatchedStatusOnPlaying], watch.finished {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let seconds = playerItemDuration?.seconds {
|
||||
watch.videoDuration = seconds
|
||||
}
|
||||
|
||||
if finished {
|
||||
watch.stoppedAt = watch.videoDuration
|
||||
} else if seconds.isFinite, seconds > 0 {
|
||||
watch.stoppedAt = seconds
|
||||
}
|
||||
|
||||
watch.watchedAt = Date()
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeWatch(_ watch: Watch) {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.delete(watch)
|
||||
|
||||
try? self.context.save()
|
||||
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
context.delete(watch)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
|
||||
do {
|
||||
try context.executeAndMergeChanges(deleteRequest)
|
||||
try context.save()
|
||||
} catch let error as NSError {
|
||||
logger.info(.init(stringLiteral: error.localizedDescription))
|
||||
}
|
||||
_ = try? context.execute(deleteRequest)
|
||||
_ = try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"mpvHWdec": Defaults[.mpvHWdec],
|
||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
||||
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
|
||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showHome": Defaults[.showHome],
|
||||
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
|
||||
"showQueueInHome": Defaults[.showQueueInHome],
|
||||
"showFavoritesInHome": Defaults[.showFavoritesInHome],
|
||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
||||
"startupSection": Defaults[.startupSection].rawValue,
|
||||
"showSearchSuggestions": Defaults[.showSearchSuggestions],
|
||||
"visibleSections": Defaults[.visibleSections].compactMap(\.rawValue),
|
||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
||||
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
|
||||
"expandChannelDescription": Defaults[.expandChannelDescription],
|
||||
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
|
||||
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
|
||||
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
|
||||
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
|
||||
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
|
||||
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
|
||||
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
|
||||
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
|
||||
"channelOnThumbnail": Defaults[.channelOnThumbnail],
|
||||
"timeOnThumbnail": Defaults[.timeOnThumbnail],
|
||||
"roundedThumbnails": Defaults[.roundedThumbnails],
|
||||
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if os(iOS)
|
||||
export["showDocuments"].bool = Defaults[.showDocuments]
|
||||
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
|
||||
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
||||
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
|
||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
|
||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
||||
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
|
||||
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
|
||||
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
|
||||
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
|
||||
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
|
||||
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
|
||||
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
|
||||
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
|
||||
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
|
||||
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
|
||||
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
|
||||
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
|
||||
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
|
||||
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
|
||||
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
|
||||
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
|
||||
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
|
||||
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
|
||||
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
|
||||
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
|
||||
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
|
||||
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount],
|
||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
||||
|
||||
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
|
||||
"watchedThreshold": Defaults[.watchedThreshold],
|
||||
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
|
||||
var includePublicInstances = true
|
||||
var includeInstances = true
|
||||
var includeAccounts = true
|
||||
var includeAccountsUnencryptedPasswords = false
|
||||
|
||||
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
|
||||
self.includePublicInstances = includePublicInstances
|
||||
self.includeInstances = includeInstances
|
||||
self.includeAccounts = includeAccounts
|
||||
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
|
||||
}
|
||||
|
||||
override var globalJSON: JSON {
|
||||
var json = JSON()
|
||||
|
||||
if includePublicInstances {
|
||||
json["instancesManifest"].string = Defaults[.instancesManifest]
|
||||
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
|
||||
}
|
||||
|
||||
if includeInstances {
|
||||
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
|
||||
}
|
||||
|
||||
if includeAccounts {
|
||||
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
|
||||
var account = account
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
account.username = username ?? ""
|
||||
if includeAccountsUnencryptedPasswords {
|
||||
account.password = password ?? ""
|
||||
}
|
||||
|
||||
return accountJSON(account).dictionaryObject
|
||||
}
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
private func instanceJSON(_ instance: Instance) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = InstancesBridge().serialize(instance)
|
||||
return json
|
||||
}
|
||||
|
||||
private func accountJSON(_ account: Account) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = AccountsBridge().serialize(account)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"lastAccountID": Defaults[.lastAccountID] ?? "",
|
||||
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
|
||||
|
||||
"playerRate": Defaults[.playerRate],
|
||||
|
||||
"trendingCategory": Defaults[.trendingCategory].rawValue,
|
||||
"trendingCountry": Defaults[.trendingCountry].rawValue,
|
||||
|
||||
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
|
||||
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
|
||||
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
|
||||
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
|
||||
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
|
||||
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
|
||||
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
|
||||
|
||||
"hideShorts": Defaults[.hideShorts],
|
||||
"hideWatched": Defaults[.hideWatched]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
||||
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"showChapterThumbnails": Defaults[.showChapterThumbnails],
|
||||
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
|
||||
"expandChapters": Defaults[.expandChapters],
|
||||
"showRelated": Defaults[.showRelated],
|
||||
"showInspector": Defaults[.showInspector].rawValue,
|
||||
"playerSidebar": Defaults[.playerSidebar].rawValue,
|
||||
"showKeywords": Defaults[.showKeywords],
|
||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
|
||||
"captionsAutoShow": Defaults[.captionsAutoShow],
|
||||
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
|
||||
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
|
||||
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
|
||||
"captionsFontColor": Defaults[.captionsFontColor]
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if !os(macOS)
|
||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
||||
#endif
|
||||
|
||||
export["showComments"].bool = Defaults[.showComments]
|
||||
|
||||
#if !os(tvOS)
|
||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
|
||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class QualitySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"batteryCellularProfile": Defaults[.batteryCellularProfile],
|
||||
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
|
||||
"chargingCellularProfile": Defaults[.chargingCellularProfile],
|
||||
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
|
||||
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
|
||||
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = QualityProfileBridge().serialize(profile)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class RecentlyOpenedExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
class SettingsGroupExporter { // swiftlint:disable:this final_class
|
||||
var globalJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var platformJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var exportJSON: JSON {
|
||||
var json = globalJSON
|
||||
|
||||
if !platformJSON.isEmpty {
|
||||
try? json.merge(with: platformJSON)
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
func jsonFromString(_ string: String?) -> JSON? {
|
||||
if let data = string?.data(using: .utf8, allowLossyConversion: false),
|
||||
let json = try? JSON(data: data)
|
||||
{
|
||||
return json
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
|
||||
"sponsorBlockColors": Defaults[.sponsorBlockColors],
|
||||
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
|
||||
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
|
||||
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportExportSettingsModel: ObservableObject {
|
||||
static let shared = ImportExportSettingsModel()
|
||||
|
||||
static var exportFile: URL {
|
||||
YatteeApp.settingsExportDirectory
|
||||
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
|
||||
}
|
||||
|
||||
static var settingsExtension: String {
|
||||
"yatteesettings"
|
||||
}
|
||||
|
||||
enum ExportGroup: String, Identifiable, CaseIterable {
|
||||
case browsingSettings
|
||||
case playerSettings
|
||||
case controlsSettings
|
||||
case qualitySettings
|
||||
case historySettings
|
||||
case sponsorBlockSettings
|
||||
case advancedSettings
|
||||
|
||||
case locationsSettings
|
||||
case instances
|
||||
case accounts
|
||||
case accountsUnencryptedPasswords
|
||||
|
||||
case recentlyOpened
|
||||
case otherData
|
||||
|
||||
static var settingsGroups: [Self] {
|
||||
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
|
||||
}
|
||||
|
||||
static var locationsGroups: [Self] {
|
||||
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
|
||||
}
|
||||
|
||||
static var otherGroups: [Self] {
|
||||
[.recentlyOpened, .otherData]
|
||||
}
|
||||
|
||||
var id: RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .browsingSettings:
|
||||
return "Browsing"
|
||||
case .playerSettings:
|
||||
return "Player"
|
||||
case .controlsSettings:
|
||||
return "Controls"
|
||||
case .qualitySettings:
|
||||
return "Quality"
|
||||
case .historySettings:
|
||||
return "History"
|
||||
case .sponsorBlockSettings:
|
||||
return "SponsorBlock"
|
||||
case .locationsSettings:
|
||||
return "Public Locations"
|
||||
case .instances:
|
||||
return "Custom Locations"
|
||||
case .accounts:
|
||||
return "Accounts"
|
||||
case .accountsUnencryptedPasswords:
|
||||
return "Accounts passwords (unencrypted)"
|
||||
case .advancedSettings:
|
||||
return "Advanced"
|
||||
case .recentlyOpened:
|
||||
return "Recents"
|
||||
case .otherData:
|
||||
return "Other data"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedExportGroups = Set<ExportGroup>()
|
||||
static var defaultExportGroups = Set<ExportGroup>([
|
||||
.browsingSettings,
|
||||
.playerSettings,
|
||||
.controlsSettings,
|
||||
.qualitySettings,
|
||||
.historySettings,
|
||||
.sponsorBlockSettings,
|
||||
.locationsSettings,
|
||||
.instances,
|
||||
.accounts,
|
||||
.advancedSettings
|
||||
])
|
||||
|
||||
@Published var isExportInProgress = false
|
||||
|
||||
private var navigation = NavigationModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
func toggleExportGroupSelection(_ group: ExportGroup) {
|
||||
if isGroupSelected(group) {
|
||||
selectedExportGroups.remove(group)
|
||||
} else {
|
||||
selectedExportGroups.insert(group)
|
||||
}
|
||||
|
||||
removeNotEnabledSelectedGroups()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isExportInProgress = false
|
||||
selectedExportGroups = Self.defaultExportGroups
|
||||
}
|
||||
|
||||
func reset(_ model: ImportSettingsFileModel? = nil) {
|
||||
reset()
|
||||
|
||||
guard let model else { return }
|
||||
|
||||
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
|
||||
}
|
||||
|
||||
func exportAction() {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
var writingOptions: JSONSerialization.WritingOptions = []
|
||||
#if DEBUG
|
||||
writingOptions.insert(.prettyPrinted)
|
||||
writingOptions.insert(.sortedKeys)
|
||||
#endif
|
||||
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.isExportInProgress = false
|
||||
}
|
||||
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var jsonForExport: JSON? {
|
||||
[
|
||||
"metadata": metadataJSON,
|
||||
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
|
||||
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
|
||||
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
|
||||
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
|
||||
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
|
||||
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
|
||||
"locationsSettings": LocationsSettingsGroupExporter(
|
||||
includePublicInstances: isGroupSelected(.locationsSettings),
|
||||
includeInstances: isGroupSelected(.instances),
|
||||
includeAccounts: isGroupSelected(.accounts),
|
||||
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
|
||||
).exportJSON,
|
||||
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
|
||||
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
|
||||
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
|
||||
]
|
||||
}
|
||||
|
||||
private var metadataJSON: JSON {
|
||||
[
|
||||
"build": YatteeApp.build,
|
||||
"timestamp": "\(Date().timeIntervalSince1970)",
|
||||
"platform": Constants.platform
|
||||
]
|
||||
}
|
||||
|
||||
func isGroupSelected(_ group: ExportGroup) -> Bool {
|
||||
selectedExportGroups.contains(group)
|
||||
}
|
||||
|
||||
func isGroupEnabled(_ group: ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .accounts:
|
||||
return selectedExportGroups.contains(.instances)
|
||||
case .accountsUnencryptedPasswords:
|
||||
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotEnabledSelectedGroups() {
|
||||
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
|
||||
}
|
||||
|
||||
var isExportAvailable: Bool {
|
||||
!selectedExportGroups.isEmpty && !isExportInProgress
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportSettingsFileModel: ObservableObject {
|
||||
static let shared = ImportSettingsFileModel()
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
if let locationsSettings = json.dictionaryValue["locationsSettings"] {
|
||||
return LocationsSettingsGroupImporter(
|
||||
json: locationsSettings,
|
||||
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
|
||||
includedInstancesIDs: sheetViewModel.selectedInstances,
|
||||
includedAccountsIDs: sheetViewModel.selectedAccounts,
|
||||
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var importExportModel = ImportExportSettingsModel.shared
|
||||
var sheetViewModel = ImportSettingsSheetViewModel.shared
|
||||
|
||||
var loadTask: URLSessionTask?
|
||||
|
||||
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .locationsSettings:
|
||||
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
|
||||
default:
|
||||
return !groupJSON(group).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var isPublicInstancesSettingsGroupInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
|
||||
}
|
||||
|
||||
var instancesOrAccountsInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
|
||||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
|
||||
}
|
||||
|
||||
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
|
||||
json.dictionaryValue[group.rawValue] ?? .init()
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
|
||||
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
|
||||
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
|
||||
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
|
||||
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
|
||||
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
|
||||
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
|
||||
}
|
||||
|
||||
locationsSettingsGroupImporter?.performImport()
|
||||
|
||||
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
|
||||
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
|
||||
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
|
||||
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var json = JSON()
|
||||
|
||||
func loadData(_ url: URL) {
|
||||
json = JSON()
|
||||
loadTask?.cancel()
|
||||
|
||||
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let data else { return }
|
||||
|
||||
if let json = try? JSON(data: data) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.json = json
|
||||
|
||||
self.sheetViewModel.reset(locationsSettingsGroupImporter)
|
||||
self.importExportModel.reset(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadTask?.resume()
|
||||
}
|
||||
|
||||
func filename(_ url: URL) -> String {
|
||||
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
|
||||
}
|
||||
|
||||
var metadataBuild: String? {
|
||||
if let build = json.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
|
||||
return build
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataPlatform: String? {
|
||||
if let platform = json.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
|
||||
return platform
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataDate: String? {
|
||||
if let timestamp = json.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
|
||||
let date = Date(timeIntervalSince1970: timestamp)
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct AdvancedSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
|
||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
||||
}
|
||||
|
||||
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
|
||||
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
||||
}
|
||||
|
||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||
}
|
||||
|
||||
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
|
||||
Defaults[.mpvEnableLogging] = mpvEnableLogging
|
||||
}
|
||||
|
||||
if let mpvCacheSecs = json["mpvCacheSecs"].string {
|
||||
Defaults[.mpvCacheSecs] = mpvCacheSecs
|
||||
}
|
||||
|
||||
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
|
||||
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
|
||||
}
|
||||
|
||||
if let mpvCachePauseInital = json["mpvCachePauseInital"].bool {
|
||||
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
|
||||
}
|
||||
|
||||
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
|
||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
||||
}
|
||||
|
||||
if let mpvHWdec = json["mpvHWdec"].string {
|
||||
Defaults[.mpvHWdec] = mpvHWdec
|
||||
}
|
||||
|
||||
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
|
||||
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
||||
}
|
||||
|
||||
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
|
||||
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
|
||||
}
|
||||
|
||||
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
||||
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
||||
}
|
||||
|
||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
||||
Defaults[.showCacheStatus] = showCacheStatus
|
||||
}
|
||||
|
||||
if let feedCacheSize = json["feedCacheSize"].string {
|
||||
Defaults[.feedCacheSize] = feedCacheSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct BrowsingSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showHome = json["showHome"].bool {
|
||||
Defaults[.showHome] = showHome
|
||||
}
|
||||
|
||||
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
|
||||
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
|
||||
}
|
||||
|
||||
if let showQueueInHome = json["showQueueInHome"].bool {
|
||||
Defaults[.showQueueInHome] = showQueueInHome
|
||||
}
|
||||
|
||||
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
|
||||
Defaults[.showFavoritesInHome] = showFavoritesInHome
|
||||
}
|
||||
|
||||
if let favorites = json["favorites"].array {
|
||||
for favoriteJSON in favorites {
|
||||
if let jsonString = favoriteJSON.rawString(options: []),
|
||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
||||
{
|
||||
FavoritesModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
||||
for widgetJSON in widgetsFavorites {
|
||||
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = WidgetSettingsBridge().deserialize(dict) {
|
||||
FavoritesModel.shared.updateWidgetSettings(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let startupSectionString = json["startupSection"].string,
|
||||
let startupSection = StartupSection(rawValue: startupSectionString)
|
||||
{
|
||||
Defaults[.startupSection] = startupSection
|
||||
}
|
||||
|
||||
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
|
||||
Defaults[.showSearchSuggestions] = showSearchSuggestions
|
||||
}
|
||||
|
||||
if let visibleSections = json["visibleSections"].array {
|
||||
let sections = visibleSections.compactMap { visibleSectionJSON in
|
||||
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
||||
let section = VisibleSection(rawValue: visibleSectionString)
|
||||
{
|
||||
return section
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Defaults[.visibleSections] = Set(sections)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
|
||||
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
|
||||
}
|
||||
|
||||
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
|
||||
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
|
||||
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
|
||||
}
|
||||
#endif
|
||||
|
||||
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
|
||||
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
|
||||
}
|
||||
|
||||
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
|
||||
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
|
||||
}
|
||||
|
||||
if let expandChannelDescription = json["expandChannelDescription"].bool {
|
||||
Defaults[.expandChannelDescription] = expandChannelDescription
|
||||
}
|
||||
|
||||
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
|
||||
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
|
||||
}
|
||||
|
||||
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
|
||||
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
|
||||
}
|
||||
|
||||
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
|
||||
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
|
||||
}
|
||||
|
||||
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
|
||||
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
|
||||
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
|
||||
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
|
||||
}
|
||||
|
||||
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
|
||||
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
|
||||
}
|
||||
|
||||
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
|
||||
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
|
||||
}
|
||||
|
||||
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
|
||||
Defaults[.channelOnThumbnail] = channelOnThumbnail
|
||||
}
|
||||
|
||||
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
|
||||
Defaults[.timeOnThumbnail] = timeOnThumbnail
|
||||
}
|
||||
|
||||
if let roundedThumbnails = json["roundedThumbnails"].bool {
|
||||
Defaults[.roundedThumbnails] = roundedThumbnails
|
||||
}
|
||||
|
||||
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
|
||||
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
|
||||
{
|
||||
Defaults[.thumbnailsQuality] = thumbnailsQuality
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct ConstrolsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
|
||||
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
|
||||
}
|
||||
|
||||
if let fullscreenPlayerGestureEnabled = json["fullscreenPlayerGestureEnabled"].bool {
|
||||
Defaults[.fullscreenPlayerGestureEnabled] = fullscreenPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
|
||||
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
|
||||
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
|
||||
}
|
||||
|
||||
if let seekGestureSpeed = json["seekGestureSpeed"].double {
|
||||
Defaults[.seekGestureSpeed] = seekGestureSpeed
|
||||
}
|
||||
|
||||
if let playerControlsLayoutString = json["playerControlsLayout"].string,
|
||||
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
|
||||
{
|
||||
Defaults[.playerControlsLayout] = playerControlsLayout
|
||||
}
|
||||
|
||||
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
|
||||
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
|
||||
{
|
||||
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
||||
}
|
||||
|
||||
if let playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
|
||||
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
|
||||
}
|
||||
|
||||
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
||||
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
||||
{
|
||||
Defaults[.systemControlsCommands] = systemControlsCommands
|
||||
}
|
||||
|
||||
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
|
||||
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
|
||||
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
|
||||
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
|
||||
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
|
||||
}
|
||||
|
||||
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
|
||||
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
|
||||
}
|
||||
|
||||
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
|
||||
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
|
||||
}
|
||||
|
||||
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
|
||||
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
|
||||
}
|
||||
|
||||
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
|
||||
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
|
||||
}
|
||||
|
||||
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
|
||||
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
|
||||
}
|
||||
|
||||
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
|
||||
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
|
||||
}
|
||||
|
||||
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
|
||||
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
|
||||
}
|
||||
|
||||
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
|
||||
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
|
||||
{
|
||||
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
|
||||
}
|
||||
|
||||
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
|
||||
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
|
||||
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
|
||||
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
|
||||
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
|
||||
}
|
||||
|
||||
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
|
||||
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
|
||||
}
|
||||
|
||||
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
|
||||
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
|
||||
}
|
||||
|
||||
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
|
||||
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
|
||||
}
|
||||
|
||||
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
|
||||
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
|
||||
}
|
||||
|
||||
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
|
||||
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
|
||||
}
|
||||
|
||||
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
|
||||
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
|
||||
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
|
||||
}
|
||||
|
||||
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
|
||||
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct HistorySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let saveRecents = json["saveRecents"].bool {
|
||||
Defaults[.saveRecents] = saveRecents
|
||||
}
|
||||
|
||||
if let saveHistory = json["saveHistory"].bool {
|
||||
Defaults[.saveHistory] = saveHistory
|
||||
}
|
||||
|
||||
if let showRecents = json["showRecents"].bool {
|
||||
Defaults[.showRecents] = showRecents
|
||||
}
|
||||
|
||||
if let limitRecents = json["limitRecents"].bool {
|
||||
Defaults[.limitRecents] = limitRecents
|
||||
}
|
||||
|
||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
||||
}
|
||||
|
||||
if let showWatchingProgress = json["showWatchingProgress"].bool {
|
||||
Defaults[.showWatchingProgress] = showWatchingProgress
|
||||
}
|
||||
|
||||
if let saveLastPlayed = json["saveLastPlayed"].bool {
|
||||
Defaults[.saveLastPlayed] = saveLastPlayed
|
||||
}
|
||||
|
||||
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
|
||||
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
|
||||
{
|
||||
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
|
||||
}
|
||||
|
||||
if let watchedThreshold = json["watchedThreshold"].int {
|
||||
Defaults[.watchedThreshold] = watchedThreshold
|
||||
}
|
||||
|
||||
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
|
||||
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
|
||||
}
|
||||
|
||||
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
|
||||
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
|
||||
{
|
||||
Defaults[.watchedVideoStyle] = watchedVideoStyle
|
||||
}
|
||||
|
||||
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
|
||||
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
|
||||
{
|
||||
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
|
||||
}
|
||||
|
||||
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
|
||||
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct LocationsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
var includePublicLocations = true
|
||||
var includedInstancesIDs = Set<Instance.ID>()
|
||||
var includedAccountsIDs = Set<Account.ID>()
|
||||
var includedAccountsPasswords = [Account.ID: String]()
|
||||
|
||||
init(
|
||||
json: JSON,
|
||||
includePublicLocations: Bool = true,
|
||||
includedInstancesIDs: Set<Instance.ID> = [],
|
||||
includedAccountsIDs: Set<Account.ID> = [],
|
||||
includedAccountsPasswords: [Account.ID: String] = [:]
|
||||
) {
|
||||
self.json = json
|
||||
self.includePublicLocations = includePublicLocations
|
||||
self.includedInstancesIDs = includedInstancesIDs
|
||||
self.includedAccountsIDs = includedAccountsIDs
|
||||
self.includedAccountsPasswords = includedAccountsPasswords
|
||||
}
|
||||
|
||||
var instances: [Instance] {
|
||||
if let instances = json["instances"].array {
|
||||
return instances.compactMap { instanceJSON in
|
||||
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return InstancesBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
if let accounts = json["accounts"].array {
|
||||
return accounts.compactMap { accountJSON in
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return AccountsBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if includePublicLocations {
|
||||
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
|
||||
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
|
||||
}
|
||||
|
||||
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
|
||||
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
|
||||
}
|
||||
|
||||
if let accounts = json["accounts"].array {
|
||||
for accountJSON in accounts {
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let account = AccountsBridge().deserialize(dict),
|
||||
includedAccountsIDs.contains(account.id)
|
||||
{
|
||||
var password = account.password
|
||||
if password?.isEmpty ?? true {
|
||||
password = includedAccountsPasswords[account.id]
|
||||
}
|
||||
if let password,
|
||||
!password.isEmpty,
|
||||
let instanceID = account.instanceID,
|
||||
let instance = InstancesModel.shared.find(instanceID) ?? InstancesModel.shared.findByURLString(account.urlString)
|
||||
{
|
||||
if !instance.accounts.contains(where: { instanceAccount in
|
||||
let (username, _) = instanceAccount.credentials
|
||||
return username == account.username
|
||||
}) {
|
||||
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct OtherDataSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let lastAccountID = json["lastAccountID"].string {
|
||||
Defaults[.lastAccountID] = lastAccountID
|
||||
}
|
||||
|
||||
if let lastInstanceID = json["lastInstanceID"].string {
|
||||
Defaults[.lastInstanceID] = lastInstanceID
|
||||
}
|
||||
|
||||
if let playerRate = json["playerRate"].double {
|
||||
Defaults[.playerRate] = playerRate
|
||||
}
|
||||
|
||||
if let trendingCategoryString = json["trendingCategory"].string,
|
||||
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
|
||||
{
|
||||
Defaults[.trendingCategory] = trendingCategory
|
||||
}
|
||||
|
||||
if let trendingCountryString = json["trendingCountry"].string,
|
||||
let trendingCountry = Country(rawValue: trendingCountryString)
|
||||
{
|
||||
Defaults[.trendingCountry] = trendingCountry
|
||||
}
|
||||
|
||||
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
|
||||
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
|
||||
{
|
||||
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
|
||||
}
|
||||
|
||||
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
|
||||
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let popularListingStyle = json["popularListingStyle"].string {
|
||||
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let trendingListingStyle = json["trendingListingStyle"].string {
|
||||
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let playlistListingStyle = json["playlistListingStyle"].string {
|
||||
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
|
||||
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let searchListingStyle = json["searchListingStyle"].string {
|
||||
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let hideShorts = json["hideShorts"].bool {
|
||||
Defaults[.hideShorts] = hideShorts
|
||||
}
|
||||
|
||||
if let hideWatched = json["hideWatched"].bool {
|
||||
Defaults[.hideWatched] = hideWatched
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlayerSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let playerInstanceID = json["playerInstanceID"].string {
|
||||
Defaults[.playerInstanceID] = playerInstanceID
|
||||
}
|
||||
|
||||
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
|
||||
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
|
||||
}
|
||||
|
||||
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
|
||||
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
|
||||
}
|
||||
|
||||
if let exitFullscreenOnEOF = json["exitFullscreenOnEOF"].bool {
|
||||
Defaults[.exitFullscreenOnEOF] = exitFullscreenOnEOF
|
||||
}
|
||||
|
||||
if let expandVideoDescription = json["expandVideoDescription"].bool {
|
||||
Defaults[.expandVideoDescription] = expandVideoDescription
|
||||
}
|
||||
|
||||
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
|
||||
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
|
||||
}
|
||||
|
||||
if let showChapters = json["showChapters"].bool {
|
||||
Defaults[.showChapters] = showChapters
|
||||
}
|
||||
|
||||
if let showChapterThumbnails = json["showChapterThumbnails"].bool {
|
||||
Defaults[.showChapterThumbnails] = showChapterThumbnails
|
||||
}
|
||||
|
||||
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
|
||||
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
|
||||
}
|
||||
|
||||
if let expandChapters = json["expandChapters"].bool {
|
||||
Defaults[.expandChapters] = expandChapters
|
||||
}
|
||||
|
||||
if let showRelated = json["showRelated"].bool {
|
||||
Defaults[.showRelated] = showRelated
|
||||
}
|
||||
|
||||
if let showInspectorString = json["showInspector"].string,
|
||||
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
|
||||
{
|
||||
Defaults[.showInspector] = showInspector
|
||||
}
|
||||
|
||||
if let playerSidebarString = json["playerSidebar"].string,
|
||||
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
|
||||
{
|
||||
Defaults[.playerSidebar] = playerSidebar
|
||||
}
|
||||
|
||||
if let showKeywords = json["showKeywords"].bool {
|
||||
Defaults[.showKeywords] = showKeywords
|
||||
}
|
||||
|
||||
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
|
||||
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
|
||||
}
|
||||
|
||||
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
|
||||
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
|
||||
}
|
||||
|
||||
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
|
||||
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
|
||||
}
|
||||
|
||||
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
|
||||
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
|
||||
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
|
||||
}
|
||||
#endif
|
||||
|
||||
if let showComments = json["showComments"].bool {
|
||||
Defaults[.showComments] = showComments
|
||||
}
|
||||
#if !os(tvOS)
|
||||
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
|
||||
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if let isOrientationLocked = json["isOrientationLocked"].bool {
|
||||
Defaults[.isOrientationLocked] = isOrientationLocked
|
||||
}
|
||||
|
||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
||||
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
|
||||
}
|
||||
|
||||
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
|
||||
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
|
||||
{
|
||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
||||
}
|
||||
#endif
|
||||
|
||||
if let captionsAutoShow = json["captionsAutoShow"].bool {
|
||||
Defaults[.captionsAutoShow] = captionsAutoShow
|
||||
}
|
||||
|
||||
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
|
||||
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
|
||||
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
|
||||
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
|
||||
}
|
||||
|
||||
if let captionsFontColor = json["captionsFontColor"].string {
|
||||
Defaults[.captionsFontColor] = captionsFontColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct QualitySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
|
||||
Defaults[.batteryCellularProfile] = batteryCellularProfileString
|
||||
}
|
||||
|
||||
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
|
||||
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
|
||||
Defaults[.chargingCellularProfile] = chargingCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
|
||||
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
|
||||
}
|
||||
|
||||
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
|
||||
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
|
||||
}
|
||||
|
||||
if let qualityProfiles = json["qualityProfiles"].array {
|
||||
for qualityProfileJSON in qualityProfiles {
|
||||
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = QualityProfileBridge().deserialize(dict) {
|
||||
QualityProfilesModel.shared.update(item, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct RecentlyOpenedImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let recentlyOpened = json["recentlyOpened"].array {
|
||||
for recentlyOpenedJSON in recentlyOpened {
|
||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = RecentItemBridge().deserialize(dict) {
|
||||
RecentsModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct SponsorBlockSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
|
||||
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
|
||||
}
|
||||
|
||||
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
|
||||
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap(\.string))
|
||||
}
|
||||
|
||||
if let sponsorBlockColors = json["sponsorBlockColors"].dictionary {
|
||||
let colors = sponsorBlockColors.mapValues { json in json.stringValue }
|
||||
Defaults[.sponsorBlockColors] = colors
|
||||
}
|
||||
|
||||
if let sponsorBlockShowTimeWithSkipsRemoved = json["sponsorBlockShowTimeWithSkipsRemoved"].bool {
|
||||
Defaults[.sponsorBlockShowTimeWithSkipsRemoved] = sponsorBlockShowTimeWithSkipsRemoved
|
||||
}
|
||||
|
||||
if let sponsorBlockShowCategoriesInTimeline = json["sponsorBlockShowCategoriesInTimeline"].bool {
|
||||
Defaults[.sponsorBlockShowCategoriesInTimeline] = sponsorBlockShowCategoriesInTimeline
|
||||
}
|
||||
|
||||
if let sponsorBlockShowNoticeAfterSkip = json["sponsorBlockShowNoticeAfterSkip"].bool {
|
||||
Defaults[.sponsorBlockShowNoticeAfterSkip] = sponsorBlockShowNoticeAfterSkip
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user