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] Media Item Menu - Identify Media Item #1369

Merged
merged 20 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions Shared/Coordinators/ItemEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {

// MARK: - Route to Metadata

@Route(.push)
var editIdentity = makeEditIdentity
@Route(.modal)
var editMetadata = makeEditMetadata

Expand Down Expand Up @@ -60,6 +62,22 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {

// MARK: - Item Metadata

@ViewBuilder
func makeEditIdentity(item: BaseItemDto) -> some View {
switch item.type {
case .boxSet:
IdentifyItemView<BoxSetInfo>(viewModel: BoxSetInfoViewModel(item: item))
case .movie:
IdentifyItemView<MovieInfo>(viewModel: MovieInfoViewModel(item: item))
case .person:
IdentifyItemView<PersonLookupInfo>(viewModel: PersonInfoViewModel(item: item))
case .series:
IdentifyItemView<SeriesInfo>(viewModel: SeriesInfoViewModel(item: item))
default:
ErrorView(error: JellyfinAPIError("Invalid media type"))
}
}

func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
Expand Down
33 changes: 33 additions & 0 deletions Shared/Extensions/JellyfinAPI/RemoteSearchResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Foundation
import JellyfinAPI
import SwiftUI

extension RemoteSearchResult: @retroactive Equatable, @retroactive Identifiable {

public var id: String {
UUID().uuidString
}

public static func == (lhs: RemoteSearchResult, rhs: RemoteSearchResult) -> Bool {
lhs.albumArtist == rhs.albumArtist &&
lhs.artists == rhs.artists &&
lhs.imageURL == rhs.imageURL &&
lhs.indexNumber == rhs.indexNumber &&
lhs.indexNumberEnd == rhs.indexNumberEnd &&
lhs.name == rhs.name &&
lhs.overview == rhs.overview &&
lhs.parentIndexNumber == rhs.parentIndexNumber &&
lhs.premiereDate == rhs.premiereDate &&
lhs.productionYear == rhs.productionYear &&
lhs.providerIDs == rhs.providerIDs &&
lhs.searchProviderName == rhs.searchProviderName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Combine
import Foundation
import JellyfinAPI

class BoxSetInfoViewModel: ItemInfoViewModel<BoxSetInfo> {

// MARK: - Return Matching Box Set

override func searchItem(_ boxSetInfo: BoxSetInfo) async throws -> [RemoteSearchResult] {
guard let itemId = item.id, item.type == .boxSet else {
return []
}

let parameters = BoxSetInfoRemoteSearchQuery(
itemID: itemId,
searchInfo: boxSetInfo
)
let request = Paths.getBoxSetRemoteSearchResults(parameters)
let response = try await userSession.client.send(request)

return response.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Combine
import Foundation
import Get
import JellyfinAPI
import OrderedCollections

class ItemInfoViewModel<SearchInfo: Equatable>: ViewModel, Stateful, Eventful {

// MARK: - Events

enum Event: Equatable {
case updated
case cancelled
case error(JellyfinAPIError)
}

// MARK: - Actions

enum Action: Equatable {
case cancel
case search(SearchInfo)
case update(RemoteSearchResult)
}

// MARK: BackgroundState

enum BackgroundState: Hashable {
case searching
case refreshing
}

// MARK: - State

enum State: Hashable {
case initial
case updating
}

@Published
var backgroundStates: OrderedSet<BackgroundState> = []
@Published
var item: BaseItemDto
@Published
var searchResults: [RemoteSearchResult] = []
@Published
var state: State = .initial

private var updateTask: AnyCancellable?
private var searchTask: AnyCancellable?

private let eventSubject = PassthroughSubject<Event, Never>()

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

// MARK: - Initializer

init(item: BaseItemDto) {
self.item = item
super.init()
}

// MARK: - Respond to Actions

func respond(to action: Action) -> State {
switch action {

case .cancel:
updateTask?.cancel()
searchTask?.cancel()

self.backgroundStates = []
self.state = .initial

return state

case let .search(searchInfo):
searchTask?.cancel()

searchTask = Task { [weak self] in
guard let self else { return }

do {
await MainActor.run {
_ = self.backgroundStates.append(.searching)
}

let allElements = try await self.searchItem(searchInfo)

await MainActor.run {
self.searchResults = allElements
_ = self.backgroundStates.remove(.searching)
}
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.state = .initial
self.eventSubject.send(.error(apiError))
}
}
}.asAnyCancellable()
return state

case let .update(searchResult):
updateTask?.cancel()

updateTask = Task { [weak self] in
guard let self else { return }

do {
await MainActor.run {
self.state = .updating
}

try await updateItem(searchResult)

await MainActor.run {
self.state = .initial
self.eventSubject.send(.updated)
}
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.state = .initial
self.eventSubject.send(.error(apiError))
}
}
}.asAnyCancellable()

return state
}
}

// MARK: - Return Matching Elements (To Be Overridden)

func searchItem(_ searchInfo: SearchInfo) async throws -> [RemoteSearchResult] {
fatalError("This method should be overridden in subclasses")
}

// MARK: - Save Updated Item to Server

private func updateItem(_ match: RemoteSearchResult) async throws {
guard let itemId = item.id else { return }

let request = Paths.applySearchCriteria(itemID: itemId, match)
_ = try await userSession.client.send(request)

try await refreshItem()
}

// MARK: - Refresh Item

private func refreshItem() async throws {
guard let itemId = item.id else { return }

await MainActor.run {
_ = self.backgroundStates.append(.refreshing)
}

let request = Paths.getItem(userID: userSession.user.id, itemID: itemId)
let response = try await userSession.client.send(request)

await MainActor.run {
self.item = response.value
_ = self.backgroundStates.remove(.refreshing)

Notifications[.itemMetadataDidChange].post(item)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Combine
import Foundation
import JellyfinAPI

class MovieInfoViewModel: ItemInfoViewModel<MovieInfo> {

// MARK: - Return Matching Movies

override func searchItem(_ movieInfo: MovieInfo) async throws -> [RemoteSearchResult] {
guard let itemId = item.id, item.type == .movie else {
return []
}

let parameters = MovieInfoRemoteSearchQuery(
itemID: itemId,
searchInfo: movieInfo
)
let request = Paths.getMovieRemoteSearchResults(parameters)
let response = try await userSession.client.send(request)

return response.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Combine
import Foundation
import JellyfinAPI

class PersonInfoViewModel: ItemInfoViewModel<PersonLookupInfo> {

// MARK: - Return Matching People

override func searchItem(_ personLookupInfo: PersonLookupInfo) async throws -> [RemoteSearchResult] {
guard let itemId = item.id, item.type == .person else {
return []
}

let parameters = PersonLookupInfoRemoteSearchQuery(
itemID: itemId,
searchInfo: personLookupInfo
)
let request = Paths.getPersonRemoteSearchResults(parameters)
let response = try await userSession.client.send(request)

return response.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Combine
import Foundation
import JellyfinAPI

class SeriesInfoViewModel: ItemInfoViewModel<SeriesInfo> {

// MARK: - Return Matching Series

override func searchItem(_ seriesInfo: SeriesInfo) async throws -> [RemoteSearchResult] {
guard let itemId = item.id, item.type == .series else {
return []
}

let parameters = SeriesInfoRemoteSearchQuery(
itemID: itemId,
searchInfo: seriesInfo
)
let request = Paths.getSeriesRemoteSearchResults(parameters)
let response = try await userSession.client.send(request)

return response.value
}
}
Loading