mirror of
https://github.com/yattee/yattee.git
synced 2025-12-13 03:28:14 +00:00
Compare commits
14 Commits
1.5.2-180
...
v1.4-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574c58b6e4 | ||
|
|
c1f3fecfe7 | ||
|
|
78834b1548 | ||
|
|
cf68c6c69f | ||
|
|
d7c8dce994 | ||
|
|
c431c20bc0 | ||
|
|
a9b057505c | ||
|
|
93943ecd83 | ||
|
|
a17cbf0085 | ||
|
|
8e74c3ec0a | ||
|
|
cc5f41807b | ||
|
|
a60a2a6744 | ||
|
|
0a36870480 | ||
|
|
48ab8b27c8 |
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
|
||||
34
.github/workflows/bump-build.yml
vendored
34
.github/workflows/bump-build.yml
vendored
@@ -1,34 +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@v3
|
||||
- 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.0'
|
||||
bundler-cache: true
|
||||
- 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@v4
|
||||
with:
|
||||
token: ${{ secrets.GIT_AUTHORIZATION }}
|
||||
branch: actions/bump-build-to-${{ env.BUILD_NUMBER }}
|
||||
base: main
|
||||
title: Bump build number to ${{ env.BUILD_NUMBER }}
|
||||
|
||||
|
||||
102
.github/workflows/release.yml
vendored
102
.github/workflows/release.yml
vendored
@@ -1,102 +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-13
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
- 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: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
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-13
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
- 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: latest-stable
|
||||
- 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@v3
|
||||
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@v3
|
||||
- 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@v3
|
||||
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,12 +1,11 @@
|
||||
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
|
||||
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func badge(_ count: Text?) -> some View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.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,15 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func listRowSeparator(_ visible: Bool) -> some View {
|
||||
if #available(iOS 15, macOS 13, *) {
|
||||
content
|
||||
#if !os(tvOS)
|
||||
.listRowSeparator(visible ? .visible : .hidden)
|
||||
#endif
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func persistentSystemOverlays(_ visible: Bool) -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.persistentSystemOverlays(visible ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func refreshable(action: @Sendable @escaping () async -> Void) -> some View {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
content.refreshable(action: action)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollContentBackground(_ visibility: Bool) -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollContentBackground(visibility ? .visible : .hidden)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func scrollDismissesKeyboardImmediately() -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func scrollDismissesKeyboardInteractively() -> some View {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) {
|
||||
content.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(color, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
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
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,22 +0,0 @@
|
||||
## Build 180
|
||||
* Add help text to all header buttons (by @rickykresslein)
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Updated localizations
|
||||
* Updated dependencies
|
||||
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
* Added Settings Import/Export
|
||||
* Export all settings, instances and accounts
|
||||
* Import selected elements from the file
|
||||
* Include unencrypted passwords in the export or provide them during the import
|
||||
* Import via URL for tvOS
|
||||
* Added Controls setting "Action button labels" icon or icon and text
|
||||
* Added Advanced setting for MPV: "deinterlace"
|
||||
* Updated dependencies (mpvkit 0.37.0)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
* Fixed reported crash
|
||||
* Other minor changes and improvements
|
||||
|
||||
**Big thanks to the current, past and future project contributors!**
|
||||
@@ -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!)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@ import AppKit
|
||||
extension NSTextField {
|
||||
override open var focusRingType: NSFocusRingType {
|
||||
get { .none }
|
||||
set {}
|
||||
set {} // swiftlint:disable:this unused_setter_value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
results.reversed().forEach { match in
|
||||
(1 ..< match.numberOfRanges).reversed().forEach { rangeIndex in
|
||||
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 {
|
||||
return
|
||||
}
|
||||
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,13 +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(Constants.yatteeProtocol) else {
|
||||
return self
|
||||
}
|
||||
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Constants.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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ extension Thumbnail {
|
||||
}
|
||||
|
||||
private static var fixturesHost: String {
|
||||
"https://invidious.snopyta.org"
|
||||
"https://invidious.home.arekf.net"
|
||||
}
|
||||
|
||||
private static func fixtureUrl(videoId: String, quality: Thumbnail.Quality) -> URL {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
static var fixtureID: Video.ID = "video-fixture"
|
||||
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 id = "D2sxamzaHkM"
|
||||
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",
|
||||
videoID: UUID().uuidString,
|
||||
title: "Relaxing Piano Music that will make you feel amazingly good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
published: "7 years ago",
|
||||
@@ -20,69 +15,19 @@ extension Video {
|
||||
description: "Some relaxing live piano music",
|
||||
genre: "Music",
|
||||
channel: Channel(
|
||||
app: .invidious,
|
||||
id: fixtureChannelID,
|
||||
id: "AbCdEFgHI",
|
||||
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: [],
|
||||
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
|
||||
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"],
|
||||
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', git: 'https://github.com/nekrich/fastlane.git', branch: 'fix/match-tvos-devices-fetch'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
226
Gemfile.lock
226
Gemfile.lock
@@ -1,226 +0,0 @@
|
||||
GIT
|
||||
remote: https://github.com/nekrich/fastlane.git
|
||||
revision: d2d51a9af37f9b04a157e78fd25d147cecc89980
|
||||
branch: fix/match-tvos-devices-fetch
|
||||
specs:
|
||||
fastlane (2.219.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)
|
||||
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.3)
|
||||
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.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.894.0)
|
||||
aws-sdk-core (3.191.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
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.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.3)
|
||||
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.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
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.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.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.6.1)
|
||||
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.3.1)
|
||||
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.5)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.1)
|
||||
jwt (2.8.0)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.4)
|
||||
rake (13.1.0)
|
||||
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.2.6)
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
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.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.24.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (~> 3.2.4)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane!
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.6
|
||||
@@ -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,15 +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
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID ?? "",
|
||||
"name": value.name,
|
||||
"apiURL": value.urlString,
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"apiURL": value.url,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
@@ -22,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"],
|
||||
@@ -34,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,33 +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
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,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
|
||||
}
|
||||
|
||||
@@ -68,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,15 +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"
|
||||
"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"]
|
||||
@@ -31,9 +30,8 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos)
|
||||
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,29 +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 remove(_ instance: Instance) {
|
||||
let accounts = accounts(instance.id)
|
||||
static func remove(_ instance: Instance) {
|
||||
let accounts = InstancesModel.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"])
|
||||
}
|
||||
@@ -60,20 +92,15 @@ 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 results = content.json.arrayValue.compactMap { json -> ContentItem in
|
||||
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" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
return nil
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
return SearchPage(results: results, last: results.isEmpty)
|
||||
@@ -81,7 +108,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 { $0.stringValue.replacingHTMLEntities }
|
||||
return suggestions.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -96,7 +123,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 +139,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) ?? []
|
||||
}
|
||||
|
||||
["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"].forEach { type in
|
||||
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,109 +154,27 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
func updateToken(force: Bool = false) {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
guard !account.anonymous,
|
||||
(account.token?.isEmpty ?? true) || force
|
||||
else {
|
||||
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."
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(Self.basePath)/\(path)"
|
||||
"**\(InvidiousAPI.basePath)/\(path)"
|
||||
}
|
||||
|
||||
private func basePathAppending(_ path: String) -> String {
|
||||
"\(Self.basePath)/\(path)"
|
||||
"\(InvidiousAPI.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? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
.withParam("type", category?.type)
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
|
||||
.withParam("type", category?.name)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
@@ -246,13 +182,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
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 feed: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
@@ -273,26 +204,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
.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 {
|
||||
@@ -323,66 +236,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
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)"))
|
||||
}
|
||||
@@ -401,7 +254,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)
|
||||
}
|
||||
|
||||
@@ -413,12 +266,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
|
||||
@@ -440,7 +288,7 @@ 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
|
||||
@@ -456,12 +304,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
|
||||
@@ -474,199 +320,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,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likeCount"].int,
|
||||
dislikes: json["dislikeCount"].int,
|
||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||
keywords: json["keywords"].arrayValue.map { $0.stringValue },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: extractChapters(from: description),
|
||||
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) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
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 of instances are not configured properly and return thumbnails links
|
||||
// with incorrect scheme
|
||||
components.scheme = accountUrlComponents.scheme
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) +
|
||||
hls
|
||||
extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue)
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||
streams.compactMap { stream in
|
||||
guard let streamURL = stream["url"].url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: AVURLAsset(url: streamURL),
|
||||
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
let audioStreams = streams
|
||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
}
|
||||
guard let audioStream = audioStreams.first else {
|
||||
return .init()
|
||||
let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
guard audioAssetURL != nil else {
|
||||
return []
|
||||
}
|
||||
|
||||
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||
|
||||
return videoStreams.compactMap { videoStream in
|
||||
guard let audioAssetURL = audioStream["url"].url,
|
||||
let videoAssetURL = videoStream["url"].url
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: AVURLAsset(url: audioAssetURL),
|
||||
videoAsset: AVURLAsset(url: videoAssetURL),
|
||||
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
|
||||
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"]?
|
||||
@@ -675,76 +425,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 ?? ""
|
||||
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: .invidious, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
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,593 +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 {
|
||||
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."
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, 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"]
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@@ -79,11 +43,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
self.extractChannelPlaylist(from: content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
|
||||
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(from: content.json)
|
||||
}
|
||||
@@ -93,7 +52,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,32 +65,24 @@ 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
|
||||
let details = content.json.dictionaryValue
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
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) }
|
||||
}
|
||||
|
||||
if account.token.isNil || account.token!.isEmpty {
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
} else {
|
||||
FeedModel.shared.onAccountChange()
|
||||
SubscribedChannelsModel.shared.onAccountChange()
|
||||
PlaylistsModel.shared.onAccountChange()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,53 +91,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func updateToken() {
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
|
||||
guard !account.anonymous,
|
||||
let username,
|
||||
let password
|
||||
else {
|
||||
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()
|
||||
|
||||
case let .failure(error):
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
.onSuccess { response in
|
||||
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,35 +111,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? {
|
||||
@@ -258,27 +152,21 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
guard let account else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
!account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
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)
|
||||
}
|
||||
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "user/playlists")
|
||||
}
|
||||
var playlists: Resource? { nil }
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
@@ -292,79 +180,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
channelPlaylist(id)
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void = { _ in },
|
||||
onSuccess: @escaping () -> Void = {}
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
|
||||
let body = ["videoId": videoID, "playlistId": playlistID]
|
||||
|
||||
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 = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
|
||||
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
) {
|
||||
let body = ["name": name]
|
||||
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
|
||||
|
||||
resource?
|
||||
.request(.post, json: body)
|
||||
.onSuccess { response in
|
||||
if let modifiedPlaylist: Playlist = response.typedContent() {
|
||||
onSuccess(modifiedPlaylist)
|
||||
} else {
|
||||
onSuccess(nil)
|
||||
}
|
||||
}
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
) {
|
||||
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
|
||||
let body = ["playlistId": playlist.id]
|
||||
|
||||
resource
|
||||
.request(.post, json: body)
|
||||
.onSuccess { _ in onSuccess() }
|
||||
.onFailure(onFailure)
|
||||
}
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
||||
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
|
||||
let resource = resource(baseURL: account.url, path: path)
|
||||
@@ -382,10 +201,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") {
|
||||
@@ -412,8 +232,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -425,64 +243,44 @@ 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
|
||||
)
|
||||
@@ -490,14 +288,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
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 thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = buildThumbnailURL(from: content, quality: $0) {
|
||||
@@ -507,130 +306,77 @@ 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
|
||||
} else {
|
||||
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),
|
||||
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)
|
||||
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
|
||||
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 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] {
|
||||
@@ -641,20 +387,15 @@ 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))
|
||||
}
|
||||
|
||||
let audioStreams = content
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
||||
.filter { stream in
|
||||
let type = stream.dictionaryValue["audioTrackType"]?.string
|
||||
return type == nil || type == "ORIGINAL"
|
||||
}
|
||||
.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
|
||||
} ?? []
|
||||
|
||||
guard let audioStream = audioStreams.first else {
|
||||
@@ -664,46 +405,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
return
|
||||
}
|
||||
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
|
||||
let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!)
|
||||
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
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 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: audioAsset,
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat
|
||||
)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -720,54 +435,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] ?? ""
|
||||
|
||||
return Comment(
|
||||
id: details["commentId"]?.string ?? UUID().uuidString,
|
||||
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: extractCommentText(from: details["commentText"]?.stringValue),
|
||||
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 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 }
|
||||
@@ -31,81 +27,31 @@ protocol VideosAPI {
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
|
||||
func addVideoToPlaylist(
|
||||
_ videoID: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func removeVideoFromPlaylist(
|
||||
_ index: String,
|
||||
_ playlistID: String,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func playlistForm(
|
||||
_ name: String,
|
||||
_ visibility: String,
|
||||
playlist: Playlist?,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping (Playlist?) -> Void
|
||||
)
|
||||
|
||||
func deletePlaylist(
|
||||
_ playlist: Playlist,
|
||||
onFailure: @escaping (RequestError) -> Void,
|
||||
onSuccess: @escaping () -> Void
|
||||
)
|
||||
|
||||
func channelPlaylist(_ id: String) -> Resource?
|
||||
|
||||
func loadDetails(
|
||||
_ item: PlayerQueueItem,
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
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, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
@@ -128,8 +74,6 @@ extension VideosAPI {
|
||||
case .playlist:
|
||||
urlComponents.path = "/playlist"
|
||||
queryItems.append(.init(name: "list", value: item.playlist.id))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if !time.isNil, time!.seconds.isFinite {
|
||||
@@ -142,65 +86,4 @@ extension VideosAPI {
|
||||
|
||||
return urlComponents.url
|
||||
}
|
||||
|
||||
func extractChapters(from description: String) -> [Chapter] {
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
start - end - title / start - end: Title / start - end title
|
||||
start - title / start: title / start title / [start] - title / [start]: title / [start] title
|
||||
index. title - start / index. title start
|
||||
title: (start)
|
||||
|
||||
The order is important!
|
||||
*/
|
||||
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|$)"
|
||||
]
|
||||
|
||||
for pattern in patterns {
|
||||
guard let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue }
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
|
||||
if !chapterLines.isEmpty {
|
||||
return chapterLines.compactMap { line 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 .init(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
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,39 +23,15 @@ 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
|
||||
}
|
||||
|
||||
var userPlaylistsEndpointIncludesVideos: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsUseChannelPlaylistEndpoint: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var userPlaylistsHaveVisibility: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var userPlaylistsAreEditable: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
@@ -86,19 +39,11 @@ enum VideosApp: String, CaseIterable {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningChannelsByName: Bool {
|
||||
var supportsComments: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
var searchUsesIndexedPages: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
self != .local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,56 +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,
|
||||
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,46 +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,
|
||||
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,66 +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,
|
||||
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 { $0.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,63 +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,
|
||||
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 { $0.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,183 +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,
|
||||
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,35 +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,
|
||||
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 { $0.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 { $0.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,24 +15,40 @@ 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 Self.enabled else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !Self.instance.isNil,
|
||||
!(player?.currentVideo.isNil ?? true)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
@@ -42,9 +56,7 @@ final class CommentsModel: ObservableObject {
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
api?.comments(player.currentVideo!.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
@@ -53,9 +65,6 @@ final class CommentsModel: ObservableObject {
|
||||
self?.disabled = page.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] requestError in
|
||||
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
}
|
||||
@@ -69,7 +78,6 @@ final class CommentsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard nextPageAvailable else { return }
|
||||
load(page: nextPage)
|
||||
}
|
||||
|
||||
@@ -86,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() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
struct ContentItem: Identifiable {
|
||||
enum ContentType: String {
|
||||
case video, playlist, channel, placeholder
|
||||
case video, playlist, channel
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@@ -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
|
||||
}
|
||||
video.isNil ? (channel.isNil ? .playlist : .channel) : .video
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { $0.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 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 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,17 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,54 +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,
|
||||
"visibleSections": Defaults[.visibleSections].compactMap { $0.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,40 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||
"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,21 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"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,44 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"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]
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if !os(macOS)
|
||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
export["honorSystemOrientationLock"].bool = Defaults[.honorSystemOrientationLock]
|
||||
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,11 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories])
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,40 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct AdvancedSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
|
||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
||||
}
|
||||
|
||||
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 mpvDeinterlace = json["mpvDeinterlace"].bool {
|
||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
||||
}
|
||||
|
||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
||||
Defaults[.showCacheStatus] = showCacheStatus
|
||||
}
|
||||
|
||||
if let feedCacheSize = json["feedCacheSize"].string {
|
||||
Defaults[.feedCacheSize] = feedCacheSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +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 {
|
||||
favorites.forEach { favoriteJSON in
|
||||
if let jsonString = favoriteJSON.rawString(options: []),
|
||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
||||
{
|
||||
FavoritesModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
||||
widgetsFavorites.forEach { widgetJSON in
|
||||
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 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,140 +0,0 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct ConstrolsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
|
||||
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
|
||||
}
|
||||
|
||||
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 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,54 +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 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 {
|
||||
accounts.forEach { accountJSON in
|
||||
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)
|
||||
{
|
||||
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,100 +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 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 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 !os(tvOS)
|
||||
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
|
||||
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if let honorSystemOrientationLock = json["honorSystemOrientationLock"].bool {
|
||||
Defaults[.honorSystemOrientationLock] = honorSystemOrientationLock
|
||||
}
|
||||
|
||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
||||
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
|
||||
}
|
||||
|
||||
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
|
||||
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
|
||||
{
|
||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
qualityProfiles.forEach { qualityProfileJSON in
|
||||
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 {
|
||||
recentlyOpened.forEach { recentlyOpenedJSON in
|
||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = RecentItemBridge().deserialize(dict) {
|
||||
RecentsModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +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 { $0.string })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class InstancesManifest: Service, ObservableObject {
|
||||
static let shared = InstancesManifest()
|
||||
|
||||
@Published var instances = [ManifestedInstance]()
|
||||
|
||||
init() {
|
||||
super.init()
|
||||
|
||||
configure()
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
if let manifestURL {
|
||||
configureTransformer(
|
||||
manifestURL,
|
||||
requestMethods: [.get]
|
||||
) { (content: Entity<JSON>
|
||||
) -> [ManifestedInstance] in
|
||||
guard let instances = content.json.dictionaryValue["instances"] else { return [] }
|
||||
|
||||
return instances.arrayValue.compactMap(self.extractInstance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPublicAccount(_ country: String?, asCurrent: Bool = true) {
|
||||
guard let country else {
|
||||
AccountsModel.shared.publicAccount = nil
|
||||
if asCurrent {
|
||||
AccountsModel.shared.configureAccount()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
instancesList?.load().onSuccess { response in
|
||||
if let instances: [ManifestedInstance] = response.typedContent() {
|
||||
let countryInstances = instances.filter { $0.country == country }
|
||||
guard let instance = countryInstances.randomElement() else { return }
|
||||
let account = instance.anonymousAccount
|
||||
AccountsModel.shared.publicAccount = account
|
||||
if asCurrent {
|
||||
AccountsModel.shared.setCurrent(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changePublicAccount() {
|
||||
instancesList?.load().onSuccess { response in
|
||||
if let instances: [ManifestedInstance] = response.typedContent() {
|
||||
var countryInstances = instances.filter { $0.country == Defaults[.countryOfPublicInstances] }
|
||||
let region = countryInstances.first?.region ?? "Europe"
|
||||
var regionInstances = instances.filter { $0.region == region }
|
||||
|
||||
if let publicAccountUrl = AccountsModel.shared.publicAccount?.url {
|
||||
countryInstances = countryInstances.filter { $0.url != publicAccountUrl }
|
||||
regionInstances = regionInstances.filter { $0.url != publicAccountUrl }
|
||||
}
|
||||
|
||||
var instance: ManifestedInstance?
|
||||
|
||||
if AccountsModel.shared.current?.isPublic ?? false {
|
||||
instance = regionInstances.randomElement()
|
||||
} else {
|
||||
instance = countryInstances.randomElement() ?? regionInstances.randomElement()
|
||||
}
|
||||
|
||||
guard let instance else {
|
||||
SettingsModel.shared.presentAlert(title: "Could not change location", message: "No locations available at the moment")
|
||||
return
|
||||
}
|
||||
|
||||
let account = instance.anonymousAccount
|
||||
AccountsModel.shared.publicAccount = account
|
||||
AccountsModel.shared.setCurrent(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractInstance(from json: JSON) -> ManifestedInstance? {
|
||||
guard let app = json["app"].string,
|
||||
let videosApp = VideosApp(rawValue: app.lowercased()),
|
||||
let region = json["region"].string,
|
||||
let country = json["country"].string,
|
||||
let flag = json["flag"].string,
|
||||
let url = json["url"].url else { return nil }
|
||||
|
||||
return ManifestedInstance(
|
||||
app: videosApp,
|
||||
country: country,
|
||||
region: region,
|
||||
flag: flag,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
||||
var manifestURL: String? {
|
||||
Defaults[.instancesManifest].isEmpty ? nil : Defaults[.instancesManifest]
|
||||
}
|
||||
|
||||
var instancesList: Resource? {
|
||||
guard let manifestURL else { return nil }
|
||||
return resource(absoluteURL: manifestURL)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
import KeychainAccess
|
||||
|
||||
struct KeychainModel {
|
||||
static var shared = Self()
|
||||
|
||||
var keychain = Keychain(service: "stream.yattee.app")
|
||||
|
||||
func updateAccountKey(_ account: Account, _ key: String, _ value: String) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
keychain[accountKey(account, key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountKey(_ account: Account, _ key: String) -> String? {
|
||||
keychain[accountKey(account, key)]
|
||||
}
|
||||
|
||||
func accountKey(_ account: Account, _ key: String) -> String {
|
||||
"\(account.id)-\(key)"
|
||||
}
|
||||
|
||||
func removeAccountKeys(_ account: Account) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? keychain.remove(accountKey(account, "token"))
|
||||
try? keychain.remove(accountKey(account, "username"))
|
||||
try? keychain.remove(accountKey(account, "password"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user