Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[iOS] Admin Dashboard - Media Access / Deletion Settings #1333

Merged
merged 4 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Shared/Coordinators/AdminDashboardCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
@Route(.push)
var userDetails = makeUserDetails
@Route(.modal)
var userMediaAccess = makeUserMediaAccess
@Route(.modal)
var userPermissions = makeUserPermissions
@Route(.modal)
var resetUserPassword = makeResetUserPassword
Expand Down Expand Up @@ -130,6 +132,12 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
}
}

func makeUserMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserAccessView(viewModel: viewModel)
}
}

func makeUserPermissions(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserPermissionsView(viewModel: viewModel)
Expand Down
13 changes: 13 additions & 0 deletions Shared/Extensions/Binding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ extension Binding {
)
}

func contains<E: Equatable>(_ value: E) -> Binding<Bool> where Value == [E] {
Binding<Bool>(
get: { wrappedValue.contains(value) },
set: { shouldBeContained in
if shouldBeContained {
wrappedValue.append(value)
} else {
wrappedValue.removeAll { $0 == value }
}
}
)
}

func map<V>(getter: @escaping (Value) -> V, setter: @escaping (V) -> Value) -> Binding<V> {
Binding<V>(
get: { getter(wrappedValue) },
Expand Down
8 changes: 8 additions & 0 deletions Shared/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ internal enum L10n {
internal static let accentColor = L10n.tr("Localizable", "accentColor", fallback: "Accent Color")
/// Some views may need an app restart to update.
internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.")
/// Access
internal static let access = L10n.tr("Localizable", "access", fallback: "Access")
/// Accessibility
internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility")
/// Active
Expand Down Expand Up @@ -366,6 +368,8 @@ internal enum L10n {
}
/// Are you sure you wish to delete this user?
internal static let deleteUserWarning = L10n.tr("Localizable", "deleteUserWarning", fallback: "Are you sure you wish to delete this user?")
/// Deletion
internal static let deletion = L10n.tr("Localizable", "deletion", fallback: "Deletion")
/// Delivery
internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery")
/// Details
Expand Down Expand Up @@ -414,6 +418,8 @@ internal enum L10n {
internal static let editUsers = L10n.tr("Localizable", "editUsers", fallback: "Edit Users")
/// Empty Next Up
internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up")
/// Enable all libraries
internal static let enableAllLibraries = L10n.tr("Localizable", "enableAllLibraries", fallback: "Enable all libraries")
/// Enabled
internal static let enabled = L10n.tr("Localizable", "enabled", fallback: "Enabled")
/// Enter custom bitrate in Mbps
Expand Down Expand Up @@ -618,6 +624,8 @@ internal enum L10n {
internal static let mayResultInPlaybackFailure = L10n.tr("Localizable", "mayResultInPlaybackFailure", fallback: "This setting may result in media failing to start playback.")
/// Media
internal static let media = L10n.tr("Localizable", "media", fallback: "Media")
/// Media Access
internal static let mediaAccess = L10n.tr("Localizable", "mediaAccess", fallback: "Media Access")
/// Media downloads
internal static let mediaDownloads = L10n.tr("Localizable", "mediaDownloads", fallback: "Media downloads")
/// Media playback
Expand Down
216 changes: 157 additions & 59 deletions Shared/ViewModels/AdminDashboard/ServerUserAdminViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,32 @@ import OrderedCollections

final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiable {

// MARK: Event
// MARK: - Event

enum Event {
case error(JellyfinAPIError)
case updated
}

// MARK: Action
// MARK: - Action

enum Action: Equatable {
case cancel
case loadDetails
case loadLibraries(isHidden: Bool? = false)
case updatePolicy(UserPolicy)
case updateConfiguration(UserConfiguration)
case updateUsername(String)
}

// MARK: Background State
// MARK: - Background State

enum BackgroundState: Hashable {
case updating
case refreshing
}

// MARK: State
// MARK: - State

enum State: Hashable {
case initial
Expand All @@ -45,112 +47,208 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
case error(JellyfinAPIError)
}

// MARK: Published Values
// MARK: - Published Values

@Published
final var state: State = .initial
@Published
final var backgroundStates: OrderedSet<BackgroundState> = []

@Published
private(set) var user: UserDto

@Published
var libraries: [BaseItemDto] = []

private var userTaskCancellable: AnyCancellable?
private var eventSubject = PassthroughSubject<Event, Never>()

var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}

private var userTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init()

// MARK: Initialize from UserDto
// MARK: - Initialize

init(user: UserDto) {
self.user = user
}

// MARK: Respond
// MARK: - Respond

func respond(to action: Action) -> State {
switch action {
case .cancel:
userTask?.cancel()
return .initial

case .loadDetails:
return performAction {
try await self.loadDetails()
userTaskCancellable?.cancel()

userTaskCancellable = Task {
do {
await MainActor.run {
_ = backgroundStates.append(.refreshing)
}

try await loadDetails()

await MainActor.run {
state = .content
_ = backgroundStates.remove(.refreshing)
}
} catch {
await MainActor.run {
state = .error(.init(error.localizedDescription))
eventSubject.send(.error(.init(error.localizedDescription)))
_ = backgroundStates.remove(.refreshing)
}
}
}

case let .updatePolicy(policy):
return performAction {
try await self.updatePolicy(policy: policy)
.asAnyCancellable()

return state

case let .loadLibraries(isHidden):
userTaskCancellable?.cancel()

userTaskCancellable = Task {
do {
await MainActor.run {
_ = backgroundStates.append(.refreshing)
}

try await loadLibraries(isHidden: isHidden)

await MainActor.run {
state = .content
_ = backgroundStates.remove(.refreshing)
}
} catch {
await MainActor.run {
state = .error(.init(error.localizedDescription))
eventSubject.send(.error(.init(error.localizedDescription)))
_ = backgroundStates.remove(.refreshing)
}
}
}
.asAnyCancellable()

case let .updateConfiguration(configuration):
return performAction {
try await self.updateConfiguration(configuration: configuration)
}
return state

case let .updateUsername(username):
return performAction {
try await self.updateUsername(username: username)
case let .updatePolicy(policy):
userTaskCancellable?.cancel()

userTaskCancellable = Task {
do {
await MainActor.run {
_ = backgroundStates.append(.updating)
}

try await updatePolicy(policy: policy)

await MainActor.run {
state = .content
eventSubject.send(.updated)
_ = backgroundStates.remove(.updating)
}
} catch {
await MainActor.run {
state = .error(.init(error.localizedDescription))
eventSubject.send(.error(.init(error.localizedDescription)))
_ = backgroundStates.remove(.updating)
}
}
}
}
}
.asAnyCancellable()

// MARK: - Perform Action
return state

private func performAction(action: @escaping () async throws -> Void) -> State {
userTask?.cancel()

userTask = Task {
do {
await MainActor.run {
_ = self.backgroundStates.append(.updating)
case let .updateConfiguration(configuration):
userTaskCancellable?.cancel()

userTaskCancellable = Task {
do {
await MainActor.run {
_ = backgroundStates.append(.updating)
}

try await updateConfiguration(configuration: configuration)

await MainActor.run {
state = .content
eventSubject.send(.updated)
_ = backgroundStates.remove(.updating)
}
} catch {
await MainActor.run {
state = .error(.init(error.localizedDescription))
eventSubject.send(.error(.init(error.localizedDescription)))
_ = backgroundStates.remove(.updating)
}
}
}
.asAnyCancellable()

try await action()

await MainActor.run {
self.state = .content
self.eventSubject.send(.updated)
}
return state

await MainActor.run {
_ = self.backgroundStates.remove(.updating)
}
} catch {
let jellyfinError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.state = .error(jellyfinError)
self.backgroundStates.remove(.updating)
self.eventSubject.send(.error(jellyfinError))
case let .updateUsername(username):
userTaskCancellable?.cancel()

userTaskCancellable = Task {
do {
await MainActor.run {
_ = backgroundStates.append(.updating)
}

try await updateUsername(username: username)

await MainActor.run {
state = .content
eventSubject.send(.updated)
_ = backgroundStates.remove(.updating)
}
} catch {
await MainActor.run {
state = .error(.init(error.localizedDescription))
eventSubject.send(.error(.init(error.localizedDescription)))
_ = backgroundStates.remove(.updating)
}
}
}
}
.asAnyCancellable()
.asAnyCancellable()

return .updating
return state
}
}

// MARK: - Load User
// MARK: - Load User Details

private func loadDetails() async throws {
guard let userID = user.id else { return }
guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") }
let request = Paths.getUserByID(userID: userID)
let response = try await userSession.client.send(request)

await MainActor.run {
self.user = response.value
self.state = .content
}
}

// MARK: - Load Libraries

private func loadLibraries(isHidden: Bool?) async throws {
let request = Paths.getMediaFolders(isHidden: isHidden)
let response = try await userSession.client.send(request)

await MainActor.run {
self.libraries = response.value.items ?? []
}
}

// MARK: - Update User Policy

private func updatePolicy(policy: UserPolicy) async throws {
guard let userID = user.id else { return }
guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") }
let request = Paths.updateUserPolicy(userID: userID, policy)
try await userSession.client.send(request)

Expand All @@ -162,7 +260,7 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
// MARK: - Update User Configuration

private func updateConfiguration(configuration: UserConfiguration) async throws {
guard let userID = user.id else { return }
guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") }
let request = Paths.updateUserConfiguration(userID: userID, configuration)
try await userSession.client.send(request)

Expand All @@ -171,10 +269,10 @@ final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiabl
}
}

// MARK: - Update User Name
// MARK: - Update Username

private func updateUsername(username: String) async throws {
guard let userID = user.id else { return }
guard let userID = user.id else { throw JellyfinAPIError("User ID is missing") }
var updatedUser = user
updatedUser.name = username

Expand Down
Loading