diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index d04b148f3..d29d4234d 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -39,24 +39,21 @@ struct AudiobookShelfRootView: View { .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: AudiobookShelfLibraryLevelData.self) { destination in switch destination { - case .topLevel(let libraryName): - AudiobookShelfLibraryView( - viewModel: AudiobookShelfLibraryViewModel( - libraryID: nil, - connectionService: connectionViewModel.connectionService, - singleFileDownloadService: singleFileDownloadService, - navigation: navigation, - navigationTitle: libraryName - ) + case .library(source: .browseCategories(let library), title: _): + AudiobookShelfBrowseTabsView( + library: library, + connectionService: connectionViewModel.connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation ) - case .library(let item): + case .library(source: let source, title: let title): AudiobookShelfLibraryView( viewModel: AudiobookShelfLibraryViewModel( - libraryID: item.id, + source: source, connectionService: connectionViewModel.connectionService, singleFileDownloadService: singleFileDownloadService, navigation: navigation, - navigationTitle: item.title + navigationTitle: title ) ) case .details(let item): diff --git a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift index 4a1054d4d..f50ad9104 100644 --- a/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift +++ b/BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift @@ -49,7 +49,10 @@ final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { Task { @MainActor in navigation.path.append( - AudiobookShelfLibraryLevelData.topLevel(libraryName: form.serverName) + AudiobookShelfLibraryLevelData.library( + source: AudiobookShelfLibraryViewSource.libraries, + title: form.serverName + ) ) } } else { @@ -78,7 +81,10 @@ final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { connectionState = .connected navigation.path.append( - AudiobookShelfLibraryLevelData.topLevel(libraryName: form.serverName) + AudiobookShelfLibraryLevelData.library( + source: AudiobookShelfLibraryViewSource.libraries, + title: form.serverName + ) ) } catch let error as AudiobookShelfError { throw error.localizedDescription @@ -90,6 +96,7 @@ final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { @MainActor func handleSignOutAction() { connectionService.deleteConnection() + navigation.path = NavigationPath() form = AudiobookShelfConnectionFormViewModel() connectionState = .disconnected } @@ -97,7 +104,10 @@ final class AudiobookShelfConnectionViewModel: ObservableObject, BPLogger { @MainActor func handleGoToLibraryAction() { navigation.path.append( - AudiobookShelfLibraryLevelData.topLevel(libraryName: form.serverName) + AudiobookShelfLibraryLevelData.library( + source: AudiobookShelfLibraryViewSource.libraries, + title: form.serverName + ) ) } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift index a2a718d8d..c8286b78c 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift @@ -8,32 +8,52 @@ import Foundation +struct AudiobookShelfSeriesReference: Codable, Hashable { + let id: String + let name: String + let sequence: String? +} + struct AudiobookShelfLibraryItem: Identifiable, Hashable, Codable { enum Kind: String, Codable { case audiobook = "book" case podcast = "podcast" case library = "library" + case browseCategory = "browseCategory" + case series = "series" + case collection = "collection" + case author = "author" + case narrator = "narrator" } let id: String let title: String let kind: Kind let libraryId: String - + // Metadata let authorName: String? let narratorName: String? let duration: TimeInterval? let size: Int64? - + let subtitle: String? + let series: [AudiobookShelfSeriesReference]? + let addedAt: Int64? + let updatedAt: Int64? + // Cover image let coverPath: String? - + let coverItemId: String? + // Progress (if included) let progress: Double? let currentTime: TimeInterval? let isFinished: Bool? - + + // Browse metadata + let browseCategory: AudiobookShelfBrowseCategory? + let filter: AudiobookShelfItemFilter? + init( id: String, title: String, @@ -43,10 +63,17 @@ struct AudiobookShelfLibraryItem: Identifiable, Hashable, Codable { narratorName: String? = nil, duration: TimeInterval? = nil, size: Int64? = nil, + subtitle: String? = nil, + series: [AudiobookShelfSeriesReference]? = nil, + addedAt: Int64? = nil, + updatedAt: Int64? = nil, coverPath: String? = nil, + coverItemId: String? = nil, progress: Double? = nil, currentTime: TimeInterval? = nil, - isFinished: Bool? = nil + isFinished: Bool? = nil, + browseCategory: AudiobookShelfBrowseCategory? = nil, + filter: AudiobookShelfItemFilter? = nil ) { self.id = id self.title = title @@ -56,14 +83,118 @@ struct AudiobookShelfLibraryItem: Identifiable, Hashable, Codable { self.narratorName = narratorName self.duration = duration self.size = size + self.subtitle = subtitle + self.series = series + self.addedAt = addedAt + self.updatedAt = updatedAt self.coverPath = coverPath + self.coverItemId = coverItemId self.progress = progress self.currentTime = currentTime self.isFinished = isFinished + self.browseCategory = browseCategory + self.filter = filter } } extension AudiobookShelfLibraryItem { + var isDownloadable: Bool { + kind == .audiobook || kind == .podcast + } + + var isNavigable: Bool { + !isDownloadable + } + + var placeholderImageName: String { + switch kind { + case .podcast, .audiobook: "waveform" + case .library: "folder" + case .browseCategory: + switch browseCategory { + case .books: "books.vertical" + case .series: "rectangle.stack" + case .collections: "square.stack.3d.up" + case .authors: "person.2" + case .narrators: "mic" + case .none: "square.grid.2x2" + } + case .series: "rectangle.stack" + case .collection: "square.stack.3d.up" + case .author: "person" + case .narrator: "mic" + } + } + + func seriesSequence(for seriesID: String) -> String? { + series?.first(where: { $0.id == seriesID })?.sequence + } + + init(library: AudiobookShelfLibrary) { + self.init( + id: library.id, + title: library.name, + kind: .library, + libraryId: library.id, + subtitle: library.mediaType == "podcast" ? "Podcast library" : "Audiobook library" + ) + } + + init(category: AudiobookShelfBrowseCategory, libraryId: String) { + self.init( + id: category.rawValue, + title: category.title, + kind: .browseCategory, + libraryId: libraryId, + subtitle: "Browse by \(category.title.lowercased())", + browseCategory: category + ) + } + + init(author: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { + self.init( + id: author.id, + title: author.name, + kind: .author, + libraryId: libraryId, + subtitle: "Author", + filter: AudiobookShelfItemFilter(group: .authors, value: author.id, title: author.name) + ) + } + + init(series: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { + self.init( + id: series.id, + title: series.name, + kind: .series, + libraryId: libraryId, + subtitle: "Series", + filter: AudiobookShelfItemFilter(group: .series, value: series.id, title: series.name) + ) + } + + init(narrator: String, libraryId: String) { + self.init( + id: narrator, + title: narrator, + kind: .narrator, + libraryId: libraryId, + subtitle: "Narrator", + filter: AudiobookShelfItemFilter(group: .narrators, value: narrator, title: narrator) + ) + } + + init(collection: AudiobookShelfCollection) { + self.init( + id: collection.id, + title: collection.name, + kind: .collection, + libraryId: collection.libraryId, + subtitle: collection.description ?? "\(collection.books.count) books", + coverItemId: collection.books.first?.id + ) + } + init?(apiItem: AudiobookShelfAPIItem) { guard let mediaType = apiItem.mediaType, let kind = Kind(rawValue: mediaType) else { @@ -75,10 +206,13 @@ extension AudiobookShelfLibraryItem { title: apiItem.media.metadata.title, kind: kind, libraryId: apiItem.libraryId, - authorName: apiItem.media.metadata.authorName, - narratorName: apiItem.media.metadata.narratorName, + authorName: apiItem.media.metadata.primaryAuthorName, + narratorName: apiItem.media.metadata.primaryNarratorName, duration: apiItem.media.duration, size: apiItem.size, + series: apiItem.media.metadata.series, + addedAt: apiItem.addedAt, + updatedAt: apiItem.updatedAt, coverPath: apiItem.media.coverPath, progress: apiItem.userMediaProgress?.progress, currentTime: apiItem.userMediaProgress?.currentTime, @@ -92,23 +226,68 @@ extension AudiobookShelfLibraryItem { struct AudiobookShelfAPIItem: Codable { let id: String let libraryId: String + let addedAt: Int64? + let updatedAt: Int64? let mediaType: String? let media: Media let size: Int64? let userMediaProgress: UserMediaProgress? - + struct Media: Codable { let metadata: Metadata let coverPath: String? let duration: TimeInterval? - + struct Metadata: Codable { let title: String let authorName: String? let narratorName: String? + let authors: [NamedEntity]? + let narrators: [String]? + let series: [AudiobookShelfSeriesReference]? + + enum CodingKeys: String, CodingKey { + case title + case authorName + case narratorName + case authors + case narrators + case series + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + title = try container.decode(String.self, forKey: .title) + authorName = try container.decodeIfPresent(String.self, forKey: .authorName) + narratorName = try container.decodeIfPresent(String.self, forKey: .narratorName) + authors = try container.decodeIfPresent([NamedEntity].self, forKey: .authors) + narrators = try container.decodeIfPresent([String].self, forKey: .narrators) + + if let seriesArray = try? container.decode([AudiobookShelfSeriesReference].self, forKey: .series) { + series = seriesArray + } else if let seriesSingle = try? container.decode(AudiobookShelfSeriesReference.self, forKey: .series) { + series = [seriesSingle] + } else { + series = nil + } + } + + var primaryAuthorName: String? { + authorName ?? authors?.first?.name + } + + var primaryNarratorName: String? { + narratorName ?? narrators?.first + } + } + + struct NamedEntity: Codable { + let id: String + let name: String } } - + struct UserMediaProgress: Codable { let progress: Double let currentTime: TimeInterval @@ -130,3 +309,29 @@ struct AudiobookShelfSearchResponse: Codable { let libraryItem: AudiobookShelfAPIItem } } + +struct AudiobookShelfLibraryFilterData: Codable { + let authors: [NamedEntity] + let genres: [String] + let tags: [String] + let series: [NamedEntity] + let narrators: [String] + let languages: [String] + + struct NamedEntity: Codable, Hashable { + let id: String + let name: String + } +} + +struct AudiobookShelfCollection: Codable { + let id: String + let libraryId: String + let name: String + let description: String? + let books: [AudiobookShelfAPIItem] +} + +struct AudiobookShelfCollectionsResponse: Codable { + let results: [AudiobookShelfCollection] +} diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift index 095fdc2be..eaa1e7298 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItemImageView.swift @@ -65,9 +65,6 @@ fileprivate struct AudiobookShelfLibraryItemImageViewWrapper: View, Equatable { } private var placeholderImageName: String { - switch item.kind { - case .podcast, .audiobook: "waveform" - case .library: "folder" - } + item.placeholderImageName } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift index 50b3d6601..074d99b75 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift @@ -8,8 +8,50 @@ import Foundation +enum AudiobookShelfBrowseCategory: String, CaseIterable, Codable, Hashable { + case books + case series + case collections + case authors + case narrators + + var title: String { + switch self { + case .books: "Books" + case .series: "Series" + case .collections: "Collections" + case .authors: "Authors" + case .narrators: "Narrators" + } + } +} + +enum AudiobookShelfItemFilterGroup: String, Codable, Hashable { + case authors + case series + case narrators +} + +struct AudiobookShelfItemFilter: Codable, Hashable { + let group: AudiobookShelfItemFilterGroup + let value: String + let title: String + + var queryValue: String { + let base64Value = Data(value.utf8).base64EncodedString() + return "\(group.rawValue).\(base64Value)" + } +} + +enum AudiobookShelfLibraryViewSource: Equatable, Hashable { + case libraries + case browseCategories(library: AudiobookShelfLibraryItem) + case books(libraryID: String, filter: AudiobookShelfItemFilter?) + case entities(libraryID: String, category: AudiobookShelfBrowseCategory) + case collection(id: String) +} + enum AudiobookShelfLibraryLevelData: Equatable, Hashable { - case topLevel(libraryName: String) - case library(data: AudiobookShelfLibraryItem) + case library(source: AudiobookShelfLibraryViewSource, title: String) case details(data: AudiobookShelfLibraryItem) } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift index 44e3acc76..cd562b5a5 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift @@ -26,7 +26,7 @@ struct AudiobookShelfLibraryView: var body: some View { Group { - if viewModel.layout == .grid { + if viewModel.isGridEnabled, viewModel.layout == .grid { ScrollView { AudiobookShelfLibraryGridView(viewModel: viewModel) .padding() @@ -56,7 +56,7 @@ struct AudiobookShelfLibraryView: } } .toolbar { - if viewModel.editMode.isEditing { + if viewModel.allowsEditing, viewModel.editMode.isEditing { ToolbarItemGroup(placement: .bottomBar) { bottomBar } @@ -66,11 +66,14 @@ struct AudiobookShelfLibraryView: @ViewBuilder var toolbarTrailing: some View { - if !viewModel.editMode.isEditing { + if !viewModel.editMode.isEditing, + viewModel.allowsEditing || viewModel.showsLayoutPreferences || viewModel.showsSortPreferences { Menu { - ThemedSection { - Button(action: viewModel.onEditToggleSelectTapped) { - Label("select_title".localized, systemImage: "checkmark.circle") + if viewModel.allowsEditing { + ThemedSection { + Button(action: viewModel.onEditToggleSelectTapped) { + Label("select_title".localized, systemImage: "checkmark.circle") + } } } @@ -78,7 +81,7 @@ struct AudiobookShelfLibraryView: } label: { Label("more_title".localized, systemImage: "ellipsis.circle") } - } else { + } else if viewModel.allowsEditing { Button(action: viewModel.onEditToggleSelectTapped) { Text("done_title".localized).bold() } @@ -87,16 +90,20 @@ struct AudiobookShelfLibraryView: @ViewBuilder var layoutPreferences: some View { - ThemedSection { - Picker(selection: $viewModel.layout, label: Text("Layout options".localized)) { - Label("Grid".localized, systemImage: "square.grid.2x2").tag(AudiobookShelfLayout.Options.grid) - Label("List".localized, systemImage: "list.bullet").tag(AudiobookShelfLayout.Options.list) + if viewModel.showsLayoutPreferences { + ThemedSection { + Picker(selection: $viewModel.layout, label: Text("Layout options".localized)) { + Label("Grid".localized, systemImage: "square.grid.2x2").tag(AudiobookShelfLayout.Options.grid) + Label("List".localized, systemImage: "list.bullet").tag(AudiobookShelfLayout.Options.list) + } } } - ThemedSection { - Picker(selection: $viewModel.sortBy, label: Text("Sort by".localized)) { - Label("sort_most_recent_button", systemImage: "clock").tag(AudiobookShelfLayout.SortBy.recent) - Label("Title".localized, systemImage: "textformat.abc").tag(AudiobookShelfLayout.SortBy.title) + if viewModel.showsSortPreferences { + ThemedSection { + Picker(selection: $viewModel.sortBy, label: Text("Sort by".localized)) { + Label("sort_most_recent_button", systemImage: "clock").tag(AudiobookShelfLayout.SortBy.recent) + Label("Title".localized, systemImage: "textformat.abc").tag(AudiobookShelfLayout.SortBy.title) + } } } } @@ -128,3 +135,167 @@ private struct ConditionalSearchableModifier: ViewModifier { } } } + +struct AudiobookShelfBrowseTabsView: View { + let library: AudiobookShelfLibraryItem + + @State private var selectedCategory: AudiobookShelfBrowseCategory = .books + + @StateObject private var booksViewModel: AudiobookShelfLibraryViewModel + @StateObject private var seriesViewModel: AudiobookShelfLibraryViewModel + @StateObject private var collectionsViewModel: AudiobookShelfLibraryViewModel + @StateObject private var authorsViewModel: AudiobookShelfLibraryViewModel + @StateObject private var narratorsViewModel: AudiobookShelfLibraryViewModel + + @EnvironmentObject private var theme: ThemeViewModel + + init( + library: AudiobookShelfLibraryItem, + connectionService: AudiobookShelfConnectionService, + singleFileDownloadService: SingleFileDownloadService, + navigation: BPNavigation + ) { + self.library = library + + self._booksViewModel = .init( + wrappedValue: AudiobookShelfLibraryViewModel( + source: .books(libraryID: library.id, filter: nil), + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: library.title + ) + ) + self._seriesViewModel = .init( + wrappedValue: AudiobookShelfLibraryViewModel( + source: .entities(libraryID: library.id, category: .series), + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: library.title + ) + ) + self._collectionsViewModel = .init( + wrappedValue: AudiobookShelfLibraryViewModel( + source: .entities(libraryID: library.id, category: .collections), + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: library.title + ) + ) + self._authorsViewModel = .init( + wrappedValue: AudiobookShelfLibraryViewModel( + source: .entities(libraryID: library.id, category: .authors), + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: library.title + ) + ) + self._narratorsViewModel = .init( + wrappedValue: AudiobookShelfLibraryViewModel( + source: .entities(libraryID: library.id, category: .narrators), + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + navigation: navigation, + navigationTitle: library.title + ) + ) + } + + var body: some View { + selectedView + .background(theme.systemBackgroundColor) + .safeAreaInset(edge: .bottom) { + if !selectedViewModel.editMode.isEditing { + bottomSwitcher + } + } + } + + private var selectedViewModel: AudiobookShelfLibraryViewModel { + switch selectedCategory { + case .books: + booksViewModel + case .series: + seriesViewModel + case .collections: + collectionsViewModel + case .authors: + authorsViewModel + case .narrators: + narratorsViewModel + } + } + + @ViewBuilder + private var selectedView: some View { + switch selectedCategory { + case .books: + AudiobookShelfLibraryView(viewModel: booksViewModel) + case .series: + AudiobookShelfLibraryView(viewModel: seriesViewModel) + case .collections: + AudiobookShelfLibraryView(viewModel: collectionsViewModel) + case .authors: + AudiobookShelfLibraryView(viewModel: authorsViewModel) + case .narrators: + AudiobookShelfLibraryView(viewModel: narratorsViewModel) + } + } + + private var bottomSwitcher: some View { + HStack(spacing: 6) { + ForEach(AudiobookShelfBrowseCategory.allCases, id: \.self) { category in + Button { + selectedCategory = category + } label: { + VStack(spacing: 4) { + Image(systemName: iconName(for: category)) + .bpFont(.body) + Text(category.title) + .bpFont(.caption) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .foregroundStyle(selectedCategory == category ? Color.white : theme.primaryColor) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(selectedCategory == category ? theme.linkColor : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(theme.secondarySystemBackgroundColor.opacity(0.96)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(theme.separatorColor.opacity(0.25), lineWidth: 1) + ) + ) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + } + + private func iconName(for category: AudiobookShelfBrowseCategory) -> String { + switch category { + case .books: + "books.vertical.fill" + case .series: + "rectangle.stack.fill" + case .collections: + "square.stack.3d.up.fill" + case .authors: + "person.2.fill" + case .narrators: + "mic.fill" + } + } +} diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift index 1932eea39..0d470d3e4 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift @@ -26,12 +26,17 @@ protocol AudiobookShelfLibraryViewModelProtocol: ObservableObject { var searchQuery: String { get set } var isSearchable: Bool { get } + var isGridEnabled: Bool { get } + var showsLayoutPreferences: Bool { get } + var showsSortPreferences: Bool { get } + var allowsEditing: Bool { get } var connectionService: AudiobookShelfConnectionService { get } func fetchInitialItems() func fetchMoreItemsIfNeeded(currentItem: AudiobookShelfLibraryItem) func cancelFetchItems() + func destination(for item: AudiobookShelfLibraryItem) -> AudiobookShelfLibraryLevelData? @MainActor func handleDoneAction() @@ -63,6 +68,7 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc var navigation: BPNavigation let navigationTitle: String + let source: AudiobookShelfLibraryViewSource @AppStorage(Constants.UserDefaults.audiobookshelfLibraryLayout) var layout: AudiobookShelfLayout.Options = .grid @@ -70,10 +76,7 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc @AppStorage(Constants.UserDefaults.audiobookshelfLibraryLayoutSortBy) var sortBy: AudiobookShelfLayout.SortBy = .recent { didSet { - guard let libraryID = libraryID else { return } - items = [] - nextPage = 0 - fetchLibraryItems(libraryID: libraryID) + handleSortChanged() } } @@ -85,16 +88,14 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc @Published var editMode: EditMode = .inactive @Published var selectedItems: Set = [] - var isSearchable: Bool { libraryID != nil } - var onTransition: BPTransition? - var libraryID: String? let connectionService: AudiobookShelfConnectionService private let singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? private var nextPage = 0 + private var allItems: [AudiobookShelfLibraryItem] = [] private static let itemBatchSize = 20 private static let itemFetchMargin = 3 @@ -102,25 +103,81 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc private var disposeBag = Set() - var canFetchMoreItems: Bool { - items.count < totalItems + var isSearchable: Bool { + switch source { + case .books(_, .none): + true + case .books(_, _), .entities(_, _), .collection(_): + true + case .libraries, .browseCategories(_): + false + } + } + + var isGridEnabled: Bool { + switch source { + case .libraries, .books(_, _), .collection(_): + true + case .browseCategories(_), .entities(_, _): + false + } + } + + var showsLayoutPreferences: Bool { + isGridEnabled + } + + var showsSortPreferences: Bool { + switch source { + case .books(_, _), .collection(_): + true + case .libraries, .browseCategories(_), .entities(_, _): + false + } + } + + var allowsEditing: Bool { + switch source { + case .books(_, _), .collection(_): + true + case .libraries, .browseCategories(_), .entities(_, _): + false + } + } + + private var usesRemoteBookSearch: Bool { + if case .books(_, .none) = source { + return true + } + return false + } + + private var usesPagedFetching: Bool { + if case .books(_, .none) = source, searchQuery.isEmpty { + return true + } + return false + } + + private var canFetchMoreItems: Bool { + usesPagedFetching && items.count < totalItems } init( - libraryID: String?, + source: AudiobookShelfLibraryViewSource, connectionService: AudiobookShelfConnectionService, singleFileDownloadService: SingleFileDownloadService, navigation: BPNavigation, navigationTitle: String ) { - self.libraryID = libraryID + self.source = source self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService self.navigation = navigation self.navigationTitle = navigationTitle $searchQuery - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .debounce(for: .milliseconds(350), scheduler: RunLoop.main) .removeDuplicates() .dropFirst() .sink { [weak self] _ in @@ -130,14 +187,15 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } func fetchInitialItems() { - fetchMoreItems() + guard items.isEmpty, fetchTask == nil else { return } + fetchSourceItems() } func fetchMoreItemsIfNeeded(currentItem: AudiobookShelfLibraryItem) { - guard items.count >= Self.itemFetchMargin else { return } + guard canFetchMoreItems, items.count >= Self.itemFetchMargin else { return } let thresholdIndex = items.index(items.endIndex, offsetBy: -Self.itemFetchMargin) if items.firstIndex(where: { $0.id == currentItem.id }) == thresholdIndex { - fetchMoreItems() + fetchBooksPage() } } @@ -146,45 +204,141 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc fetchTask = nil } - private func fetchMoreItems() { - guard fetchTask == nil && canFetchMoreItems else { - return + func destination(for item: AudiobookShelfLibraryItem) -> AudiobookShelfLibraryLevelData? { + switch item.kind { + case .audiobook, .podcast: + return .details(data: item) + case .library: + return .library(source: .browseCategories(library: item), title: item.title) + case .browseCategory: + guard let category = item.browseCategory else { return nil } + switch category { + case .books: + return .library(source: .books(libraryID: item.libraryId, filter: nil), title: category.title) + case .series, .collections, .authors, .narrators: + return .library(source: .entities(libraryID: item.libraryId, category: category), title: category.title) + } + case .collection: + return .library(source: .collection(id: item.id), title: item.title) + case .author, .series, .narrator: + guard let filter = item.filter else { return nil } + return .library(source: .books(libraryID: item.libraryId, filter: filter), title: item.title) + } + } + + @MainActor + func handleDoneAction() { + onTransition?(.done) + } + + @MainActor + func onEditToggleSelectTapped() { + guard allowsEditing else { return } + + withAnimation { + editMode = editMode.isEditing ? .inactive : .active + } + + if !editMode.isEditing { + selectedItems.removeAll() + } + } + + @MainActor + func onSelectTapped(for item: AudiobookShelfLibraryItem) { + guard item.isDownloadable else { return } + + if let index = selectedItems.firstIndex(of: item.id) { + selectedItems.remove(at: index) + } else { + selectedItems.insert(item.id) } + } + + @MainActor + func onSelectAllTapped() { + guard allowsEditing else { return } - if let libraryID { - fetchLibraryItems(libraryID: libraryID) + if selectedItems.isEmpty { + let ids = items.compactMap { item in + item.isDownloadable ? item.id : nil + } + selectedItems = Set(ids) } else { - fetchTopLevelItems() + selectedItems.removeAll() } } - private func fetchTopLevelItems() { + @MainActor + func onDownloadTapped() { + let items = selectedItems.compactMap { id in + self.items.first(where: { $0.id == id && $0.isDownloadable }) + } + + var urls = [URL]() + for item in items { + do { + let url = try connectionService.createItemDownloadUrl(item) + urls.append(url) + } catch { + self.error = error + } + } + + guard !urls.isEmpty else { return } + singleFileDownloadService.handleDownload(urls) + navigation.dismiss?() + } + + private func handleSortChanged() { + guard !items.isEmpty else { return } + + switch source { + case .books(let libraryID, .none): + resetForFreshFetch() + fetchBookItems(libraryID: libraryID, filter: nil) + default: + applyLocalSearchAndSort() + } + } + + private func fetchSourceItems() { + switch source { + case .libraries: + fetchLibraries() + case .browseCategories(let library): + loadLocalItems(AudiobookShelfBrowseCategory.allCases.map { + AudiobookShelfLibraryItem(category: $0, libraryId: library.id) + }) + case .books(let libraryID, let filter): + fetchBookItems(libraryID: libraryID, filter: filter) + case .entities(let libraryID, let category): + fetchEntityItems(libraryID: libraryID, category: category) + case .collection(let id): + fetchCollectionItems(collectionID: id) + } + } + + private func fetchLibraries() { fetchTask?.cancel() fetchTask = Task { @MainActor in defer { self.fetchTask = nil } - items = [] do { let libraries = try await connectionService.fetchLibraries() - - // Convert libraries to library items so users can select which library to browse - let libraryItems = libraries.map { library in - AudiobookShelfLibraryItem( - id: library.id, - title: library.name, - kind: .library, - libraryId: library.id, - authorName: nil, - narratorName: nil, - duration: nil, - size: nil, - coverPath: nil, - progress: nil + let libraryItems = libraries + .filter { $0.mediaType == "book" } + .map(AudiobookShelfLibraryItem.init(library:)) + loadLocalItems(libraryItems) + + if libraryItems.count == 1, let library = libraryItems.first { + navigation.path.append( + AudiobookShelfLibraryLevelData.library( + source: AudiobookShelfLibraryViewSource.browseCategories(library: library), + title: library.title + ) ) } - - self.totalItems = libraryItems.count - self.items = libraryItems } catch is CancellationError { // ignore } catch { @@ -193,36 +347,45 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } } - private func onSearchQueryChanged() { - guard let libraryID else { return } + private func fetchEntityItems(libraryID: String, category: AudiobookShelfBrowseCategory) { fetchTask?.cancel() - fetchTask = nil - editMode = .inactive - items = [] - selectedItems.removeAll() - nextPage = 0 - totalItems = Int.max + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } - if searchQuery.isEmpty { - fetchLibraryItems(libraryID: libraryID) - } else { - searchLibraryItems(libraryID: libraryID, query: searchQuery) + do { + switch category { + case .books: + loadLocalItems([]) + case .authors: + let filterData = try await connectionService.fetchFilterData(in: libraryID) + loadLocalItems(filterData.authors.map { AudiobookShelfLibraryItem(author: $0, libraryId: libraryID) }) + case .series: + let filterData = try await connectionService.fetchFilterData(in: libraryID) + loadLocalItems(filterData.series.map { AudiobookShelfLibraryItem(series: $0, libraryId: libraryID) }) + case .narrators: + let filterData = try await connectionService.fetchFilterData(in: libraryID) + loadLocalItems(filterData.narrators.map { AudiobookShelfLibraryItem(narrator: $0, libraryId: libraryID) }) + case .collections: + let collections = try await connectionService.fetchCollections(in: libraryID) + loadLocalItems(collections.map(AudiobookShelfLibraryItem.init(collection:))) + } + } catch is CancellationError { + // ignore + } catch { + self.error = error + } } } - private func searchLibraryItems(libraryID: String, query: String) { + private func fetchCollectionItems(collectionID: String) { + fetchTask?.cancel() fetchTask = Task { @MainActor in defer { self.fetchTask = nil } do { - let items = try await connectionService.searchItems( - in: libraryID, - query: query, - limit: Self.searchResultLimit - ) - - self.totalItems = items.count - self.items = items + let collection = try await connectionService.fetchCollection(id: collectionID) + let books = collection.books.compactMap(AudiobookShelfLibraryItem.init(apiItem:)) + loadLocalItems(books) } catch is CancellationError { // ignore } catch { @@ -231,27 +394,33 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } } - private func fetchLibraryItems(libraryID: String) { + private func fetchBookItems(libraryID: String, filter: AudiobookShelfItemFilter?) { + resetSelectionState() + + if filter == nil, usesRemoteBookSearch, searchQuery.isEmpty { + fetchBooksPage() + } else if filter == nil, usesRemoteBookSearch { + searchLibraryItems(libraryID: libraryID, query: searchQuery) + } else { + fetchAllFilteredBooks(libraryID: libraryID, filter: filter) + } + } + + private func fetchBooksPage() { + guard case .books(let libraryID, let filter) = source else { return } + guard filter == nil, fetchTask == nil, canFetchMoreItems else { return } + fetchTask = Task { @MainActor in defer { self.fetchTask = nil } do { - var desc: Bool? - let sortByParam: String - switch sortBy { - case .recent: - sortByParam = "addedAt" - desc = true - case .title: - sortByParam = "media.metadata.title" - } - let (items, totalItems) = try await connectionService.fetchItems( in: libraryID, limit: Self.itemBatchSize, page: nextPage, - sortBy: sortByParam, - desc: desc + sortBy: sortParameter, + desc: sortDescending, + filter: nil ) self.nextPage += 1 @@ -265,61 +434,288 @@ final class AudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelProtoc } } - @MainActor - func handleDoneAction() { - onTransition?(.done) + private func fetchAllFilteredBooks(libraryID: String, filter: AudiobookShelfItemFilter?) { + fetchTask?.cancel() + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + + do { + let (items, _) = try await connectionService.fetchItems( + in: libraryID, + limit: 0, + page: 0, + sortBy: sortParameter, + desc: sortDescending, + filter: filter + ) + + loadLocalItems(items) + } catch is CancellationError { + // ignore + } catch { + self.error = error + } + } } - @MainActor - func onEditToggleSelectTapped() { - withAnimation { - editMode = editMode.isEditing ? .inactive : .active + private func searchLibraryItems(libraryID: String, query: String) { + fetchTask?.cancel() + fetchTask = Task { @MainActor in + defer { self.fetchTask = nil } + + do { + let items = try await connectionService.searchItems( + in: libraryID, + query: query, + limit: Self.searchResultLimit + ) + + self.totalItems = items.count + self.items = sortItems(items) + } catch is CancellationError { + // ignore + } catch { + self.error = error + } } + } - if !editMode.isEditing { - selectedItems.removeAll() + private func onSearchQueryChanged() { + switch source { + case .books(let libraryID, .none): + resetForFreshFetch() + + if searchQuery.isEmpty { + fetchBookItems(libraryID: libraryID, filter: nil) + } else { + searchLibraryItems(libraryID: libraryID, query: searchQuery) + } + default: + applyLocalSearchAndSort() } } - @MainActor - func onSelectTapped(for item: AudiobookShelfLibraryItem) { - if let index = selectedItems.firstIndex(of: item.id) { - selectedItems.remove(at: index) - } else { - selectedItems.insert(item.id) + private func loadLocalItems(_ items: [AudiobookShelfLibraryItem]) { + allItems = items + applyLocalSearchAndSort() + } + + private func applyLocalSearchAndSort() { + let normalizedQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let filteredItems = normalizedQuery.isEmpty + ? allItems + : allItems.filter { item in + item.title.localizedCaseInsensitiveContains(normalizedQuery) + || item.subtitle?.localizedCaseInsensitiveContains(normalizedQuery) == true + || item.authorName?.localizedCaseInsensitiveContains(normalizedQuery) == true + || item.narratorName?.localizedCaseInsensitiveContains(normalizedQuery) == true + } + + let sortedItems = sortItems(filteredItems) + totalItems = sortedItems.count + items = sortedItems + } + + private func sortItems(_ items: [AudiobookShelfLibraryItem]) -> [AudiobookShelfLibraryItem] { + if let seriesID = activeSeriesID { + return items.sorted { lhs, rhs in + compareSeriesItems(lhs, rhs, seriesID: seriesID) + } + } + + switch sortBy { + case .recent: + return items.sorted { lhs, rhs in + let lhsDate = lhs.updatedAt ?? lhs.addedAt ?? 0 + let rhsDate = rhs.updatedAt ?? rhs.addedAt ?? 0 + if lhsDate == rhsDate { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhsDate > rhsDate + } + case .title: + return items.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } } } - @MainActor - func onSelectAllTapped() { - if selectedItems.isEmpty { - let ids: [AudiobookShelfLibraryItem.ID] = items.compactMap { item in - guard item.kind == .audiobook else { return nil } - return item.id + private var activeSeriesID: String? { + guard case .books(_, let filter?) = source, + filter.group == .series + else { + return nil + } + + return filter.value + } + + private func compareSeriesItems( + _ lhs: AudiobookShelfLibraryItem, + _ rhs: AudiobookShelfLibraryItem, + seriesID: String + ) -> Bool { + let lhsSortValue = seriesSortValue(lhs.seriesSequence(for: seriesID)) + let rhsSortValue = seriesSortValue(rhs.seriesSequence(for: seriesID)) + + switch (lhsSortValue, rhsSortValue) { + case let (.some(lhsValue), .some(rhsValue)): + if lhsValue != rhsValue { + return lhsValue < rhsValue } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } - selectedItems = Set(ids) - } else { - selectedItems.removeAll() + let lhsSequence = lhs.seriesSequence(for: seriesID) ?? lhs.title + let rhsSequence = rhs.seriesSequence(for: seriesID) ?? rhs.title + let fallbackComparison = lhsSequence.localizedStandardCompare(rhsSequence) + if fallbackComparison != .orderedSame { + return fallbackComparison == .orderedAscending } + + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending } - @MainActor - func onDownloadTapped() { - let items = selectedItems.compactMap({ id in - self.items.first(where: { $0.id == id }) - }) + private func seriesSortValue(_ sequence: String?) -> Decimal? { + guard let trimmedSequence = sequence?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmedSequence.isEmpty + else { + return nil + } - var urls = [URL]() - for item in items { - do { - let url = try connectionService.createItemDownloadUrl(item) - urls.append(url) - } catch { - self.error = error + if let decimal = Decimal(string: trimmedSequence) { + return decimal + } + + if let wordValue = seriesWordSortValue(trimmedSequence) { + return wordValue + } + + let pattern = #"[-+]?\d+(?:\.\d+)?"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: trimmedSequence, + range: NSRange(trimmedSequence.startIndex..., in: trimmedSequence) + ), + let range = Range(match.range, in: trimmedSequence) + else { + return nil + } + + return Decimal(string: String(trimmedSequence[range])) + } + + private func seriesWordSortValue(_ sequence: String) -> Decimal? { + let normalized = sequence + .lowercased() + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + + let tokens = normalized + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + func decimal(_ value: String) -> Decimal { + Decimal(string: value) ?? 0 + } + + let values: [String: Decimal] = [ + "minus": decimal("-1"), + "negative": decimal("-1"), + "zero": decimal("0"), + "one": decimal("1"), + "first": decimal("1"), + "two": decimal("2"), + "second": decimal("2"), + "three": decimal("3"), + "third": decimal("3"), + "four": decimal("4"), + "fourth": decimal("4"), + "five": decimal("5"), + "fifth": decimal("5"), + "six": decimal("6"), + "sixth": decimal("6"), + "seven": decimal("7"), + "seventh": decimal("7"), + "eight": decimal("8"), + "eighth": decimal("8"), + "nine": decimal("9"), + "ninth": decimal("9"), + "ten": decimal("10"), + "tenth": decimal("10"), + "eleven": decimal("11"), + "eleventh": decimal("11"), + "twelve": decimal("12"), + "twelfth": decimal("12"), + "thirteen": decimal("13"), + "thirteenth": decimal("13"), + "fourteen": decimal("14"), + "fourteenth": decimal("14"), + "fifteen": decimal("15"), + "fifteenth": decimal("15"), + "sixteen": decimal("16"), + "sixteenth": decimal("16"), + "seventeen": decimal("17"), + "seventeenth": decimal("17"), + "eighteen": decimal("18"), + "eighteenth": decimal("18"), + "nineteen": decimal("19"), + "nineteenth": decimal("19"), + "twenty": decimal("20"), + "twentieth": decimal("20"), + "half": decimal("0.5") + ] + + for (index, token) in tokens.enumerated() { + guard var value = values[token] else { continue } + + if index > 0 { + let previous = tokens[index - 1] + if previous == "minus" || previous == "negative" { + value *= -1 + } } + + return value + } + + return nil + } + + private func resetSelectionState() { + editMode = .inactive + selectedItems.removeAll() + } + + private func resetForFreshFetch() { + fetchTask?.cancel() + fetchTask = nil + resetSelectionState() + items = [] + totalItems = Int.max + nextPage = 0 + } + + private var sortParameter: String { + switch sortBy { + case .recent: + "addedAt" + case .title: + "media.metadata.title" + } + } + + private var sortDescending: Bool? { + switch sortBy { + case .recent: + true + case .title: + nil } - singleFileDownloadService.handleDownload(urls) - navigation.dismiss?() } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift index 24a716c65..dd8818e93 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift @@ -200,7 +200,7 @@ final class MockAudiobookShelfAudiobookDetailsViewModel: AudiobookShelfAudiobook publisher: nil, series: [.init(id: "1", name: "The Great American Novels", sequence: nil)] ) - let parentData = AudiobookShelfLibraryLevelData.topLevel(libraryName: "Mock Library") + let parentData = AudiobookShelfLibraryLevelData.library(source: .libraries, title: "Mock Library") let vm = MockAudiobookShelfAudiobookDetailsViewModel(item: item, details: details) AudiobookShelfAudiobookDetailsView(viewModel: vm, onDownloadTap: {}) .environmentObject(MockAudiobookShelfLibraryViewModel(data: parentData)) diff --git a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift index d3f29fa20..2ebf6b4e5 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift @@ -24,7 +24,7 @@ struct AudiobookShelfLibraryGridItemView: View { ZStack(alignment: .topTrailing) { AudiobookShelfLibraryItemImageView(item: item) .overlay { - if editMode?.wrappedValue.isEditing == true, item.kind == .audiobook { + if editMode?.wrappedValue.isEditing == true, item.isDownloadable { Image(systemName: isSelected ? "checkmark.circle" : "circle") .foregroundStyle(.white) .background(isSelected ? .blue : .clear) @@ -35,7 +35,7 @@ struct AudiobookShelfLibraryGridItemView: View { } .accessibilityHidden(true) - if item.kind == .library { + if item.isNavigable { libraryBadge } } @@ -52,7 +52,7 @@ struct AudiobookShelfLibraryGridItemView: View { ZStack { Circle().strokeBorder(.foreground, lineWidth: 1 * accessabilityScale) .background(Circle().fill(.background)) - Image(systemName: "folder.fill") + Image(systemName: item.placeholderImageName) .resizable() .aspectRatio(contentMode: .fit) .padding(4) diff --git a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift index 4605b9d6e..e2b88caf3 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift @@ -42,15 +42,10 @@ struct AudiobookShelfLibraryGridView AudiobookShelfLibraryLevelData? { nil } func handleDoneAction() {} @@ -99,7 +99,9 @@ final class MockAudiobookShelfLibraryViewModel: AudiobookShelfLibraryViewModelPr #Preview("top level") { let model = { - let model = MockAudiobookShelfLibraryViewModel(data: .topLevel(libraryName: "Mock Library")) + let model = MockAudiobookShelfLibraryViewModel( + data: .library(source: .libraries, title: "Mock Library") + ) model.items = [ AudiobookShelfLibraryItem( id: "0.1", diff --git a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift index fda0b90e9..b5ef860d3 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListItemView.swift @@ -18,11 +18,20 @@ struct AudiobookShelfLibraryListItemView: View { AudiobookShelfLibraryItemImageView(item: item) .frame(width: 50, height: 50) .accessibilityHidden(true) - Text(item.title) - .bpFont(.titleRegular) - .foregroundStyle(theme.primaryColor) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .bpFont(.titleRegular) + .foregroundStyle(theme.primaryColor) + + if let subtitle = item.subtitle ?? item.authorName ?? item.narratorName { + Text(subtitle) + .bpFont(.caption) + .foregroundStyle(theme.secondaryColor) + .lineLimit(1) + } + } Spacer() - if item.kind == .library { + if item.isNavigable { Image(systemName: "chevron.forward") .foregroundStyle(theme.secondaryColor) } diff --git a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift index b98b05b46..30e8127d8 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/ListLayout/AudiobookShelfLibraryListView.swift @@ -16,7 +16,7 @@ struct AudiobookShelfLibraryListView (items: [AudiobookShelfLibraryItem], total: Int) { guard let connection else { throw URLError(.userAuthenticationRequired) @@ -191,6 +192,9 @@ class AudiobookShelfConnectionService: BPLogger { queryItems.append(URLQueryItem(name: "desc", value: desc ? "1" : "0")) } } + if let filter { + queryItems.append(URLQueryItem(name: "filter", value: filter.queryValue)) + } if !queryItems.isEmpty { urlComponents.queryItems = queryItems @@ -221,6 +225,108 @@ class AudiobookShelfConnectionService: BPLogger { return (items, itemsResponse.total) } + public func fetchFilterData(in libraryId: String) async throws -> AudiobookShelfLibraryFilterData { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("libraries") + .appendingPathComponent(libraryId) + .appendingPathComponent("filterdata") + + var request = URLRequest(url: url) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AudiobookShelfError.unexpectedResponse(code: nil) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(AudiobookShelfLibraryFilterData.self, from: data) + } + + public func fetchCollections(in libraryId: String) async throws -> [AudiobookShelfCollection] { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + guard + var urlComponents = URLComponents( + url: connection.url + .appendingPathComponent("api") + .appendingPathComponent("libraries") + .appendingPathComponent(libraryId) + .appendingPathComponent("collections"), + resolvingAgainstBaseURL: false + ) + else { + throw URLError(.badURL) + } + + urlComponents.queryItems = [ + URLQueryItem(name: "minified", value: "1") + ] + + guard let url = urlComponents.url else { + throw AudiobookShelfError.urlFromComponents(urlComponents) + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AudiobookShelfError.unexpectedResponse(code: nil) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + let collectionsResponse = try decoder.decode(AudiobookShelfCollectionsResponse.self, from: data) + return collectionsResponse.results + } + + public func fetchCollection(id: String) async throws -> AudiobookShelfCollection { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("collections") + .appendingPathComponent(id) + + var request = URLRequest(url: url) + request.setValue("Bearer \(connection.apiToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AudiobookShelfError.unexpectedResponse(code: nil) + } + + guard (200...299).contains(httpResponse.statusCode) else { + if httpResponse.statusCode == 404 { + throw URLError(.fileDoesNotExist) + } + throw AudiobookShelfError.unexpectedResponse(code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(AudiobookShelfCollection.self, from: data) + } + public func searchItems( in libraryId: String, query: String, @@ -343,7 +449,9 @@ class AudiobookShelfConnectionService: BPLogger { guard let connection = connection else { return nil } let baseURL = connection.url - let itemID = item.id + guard let itemID = item.coverItemId ?? (item.isDownloadable ? item.id : nil) else { + return nil + } // AudiobookShelf image endpoint: /api/items/:id/cover // Optional query params: width, height, format