Skip to content

Add search for integrations#1485

Merged
GianniCarlo merged 6 commits intodevelopfrom
feat/integrations-search
Mar 23, 2026
Merged

Add search for integrations#1485
GianniCarlo merged 6 commits intodevelopfrom
feat/integrations-search

Conversation

@GianniCarlo
Copy link
Collaborator

Purpose

  • Add a search bar for Jellyfin and Audiobookshelf
  • Add more debug information for storage space

@GianniCarlo GianniCarlo self-assigned this Mar 21, 2026
@GianniCarlo GianniCarlo requested a review from Copilot March 21, 2026 13:48
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds integration library search (Jellyfin + Audiobookshelf) and expands exported debug logs with detailed storage usage breakdown.

Changes:

  • Add searchable UI + query handling to Jellyfin and Audiobookshelf library screens.
  • Add Audiobookshelf search API call and pass search term to Jellyfin items endpoint.
  • Add storage breakdown details to the debug transferable export.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
BookPlayer/Settings/Sections/DebugFileTransferable.swift Adds a storage breakdown section to debug export output.
BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift Extends item fetch API to accept an optional search term.
BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift Adds debounced search query state and refetch-on-search behavior.
BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift Adds conditional .searchable and wraps grid layout in a ScrollView.
BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridView.swift Switches grid implementation to LazyVGrid.
BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift Adds a dedicated search endpoint call.
BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridView.swift Switches grid implementation to LazyVGrid.
BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift Adds debounced search query state and switches between list vs search requests.
BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift Adds conditional .searchable and wraps grid layout in a ScrollView.
BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift Adds Codable models for Audiobookshelf search response parsing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +191 to +199
private func onSearchQueryChanged() {
guard let folderID else { return }
fetchTask?.cancel()
fetchTask = nil
items = []
nextStartItemIndex = 0
totalItems = Int.max
fetchFolderItems(folderID: folderID)
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the search query changes, the list is reset but selectedItems is not cleared. This can leave stale selections for items no longer visible and may enable actions (download/delete) on outdated IDs. Consider clearing selectedItems (and potentially exiting edit mode) when resetting items for a new search.

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +206
private func onSearchQueryChanged() {
guard let libraryID else { return }
fetchTask?.cancel()
fetchTask = nil
items = []
nextPage = 0
totalItems = Int.max

if searchQuery.isEmpty {
fetchLibraryItems(libraryID: libraryID)
} else {
searchLibraryItems(libraryID: libraryID, query: searchQuery)
}
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the Jellyfin view model, changing the search query resets items but does not clear selectedItems. This can keep selections around for items that are no longer part of the current result set. Clear selectedItems (and potentially reset editMode) when starting a new search / returning to the full list.

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +128
private struct ConditionalSearchableModifier: ViewModifier {
let isSearchable: Bool
@Binding var text: String

func body(content: Content) -> some View {
if isSearchable {
content.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always))
} else {
content
}
}
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are now two near-identical conditional searchable modifiers (ConditionalSearchableModifier here and JellyfinSearchableModifier in JellyfinLibraryView). Consider extracting a single shared View extension / modifier to reduce duplication and keep behavior consistent across integrations.

Copilot uses AI. Check for mistakes.
libraryRepresentation += remoteOnlyInfo
}

libraryRepresentation += file.getStorageBreakdown()
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage breakdown does a full recursive enumeration across multiple directories and is executed synchronously while building the debug export string. On large libraries this can be slow and may block the caller (potentially the UI). Consider computing this off the main thread (e.g., make the breakdown async and await it from a Task) or gating it behind an explicit 'include storage breakdown' flag.

Copilot uses AI. Check for mistakes.
Comment on lines +239 to +262
/// Get the size of a folder's contents
private func getFolderSize(_ url: URL, skipHidden: Bool) -> Int64 {
let fm = FileManager.default
guard fm.fileExists(atPath: url.path) else { return 0 }

var options: FileManager.DirectoryEnumerationOptions = []
if skipHidden {
options.insert(.skipsHiddenFiles)
}

guard let enumerator = fm.enumerator(
at: url,
includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey],
options: options
) else { return 0 }

var size: Int64 = 0
for case let fileURL as URL in enumerator {
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]),
values.isRegularFile == true else { continue }
size += Int64(values.fileSize ?? 0)
}
return size
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage breakdown does a full recursive enumeration across multiple directories and is executed synchronously while building the debug export string. On large libraries this can be slow and may block the caller (potentially the UI). Consider computing this off the main thread (e.g., make the breakdown async and await it from a Task) or gating it behind an explicit 'include storage breakdown' flag.

Copilot uses AI. Check for mistakes.
Comment on lines 115 to 120
in folderID: String,
startIndex: Int?,
limit: Int?,
sortBy: JellyfinLayout.SortBy
sortBy: JellyfinLayout.SortBy,
searchTerm: String? = nil
) async throws -> (items: [JellyfinLibraryItem], nextStartIndex: Int, maxCountItems: Int) {
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider normalizing searchTerm inside the service (e.g., trimming whitespace and treating an empty string as nil). As written, passing \"\" would enable isRecursive and send an empty searchTerm, which can unintentionally broaden the query and increase server/load time.

Copilot uses AI. Check for mistakes.
Comment on lines 135 to 140
let parameters = Paths.GetItemsParameters(
startIndex: startIndex,
limit: limit,
isRecursive: false,
isRecursive: searchTerm != nil,
searchTerm: searchTerm,
sortOrder: sortOrder,
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider normalizing searchTerm inside the service (e.g., trimming whitespace and treating an empty string as nil). As written, passing \"\" would enable isRecursive and send an empty searchTerm, which can unintentionally broaden the query and increase server/load time.

Copilot uses AI. Check for mistakes.
@GianniCarlo GianniCarlo requested a review from Copilot March 23, 2026 00:53
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 135 to 142
let parameters = Paths.GetItemsParameters(
startIndex: startIndex,
limit: limit,
isRecursive: false,
isRecursive: searchTerm != nil,
searchTerm: searchTerm,
sortOrder: sortOrder,
parentID: folderID,
fields: [.sortName],
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRecursive is toggled based on searchTerm != nil, which treats searchTerm: "" (or whitespace-only) as a “search” and will change query behavior while also sending an effectively empty search term. Consider normalizing searchTerm (trim + convert empty to nil) and basing both isRecursive and searchTerm off the normalized value.

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +225
private func searchLibraryItems(libraryID: String, query: String) {
fetchTask = Task { @MainActor in
defer { self.fetchTask = nil }

do {
let items = try await connectionService.searchItems(
in: libraryID,
query: query,
limit: 100
)

self.totalItems = items.count
self.items = items
} catch is CancellationError {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Search results are capped at 100 via limit: 100, and totalItems is set to items.count, which will be incorrect/incomplete when the library has more than 100 matching items. If the API supports it, either remove the hard cap (pass nil) or implement paging for search and only set totalItems from a server-provided total (or clearly distinguish “loaded count” vs “total matches”).

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +162
func getStorageBreakdown() -> String {
var info = "\n\n--- Storage Breakdown ---\n"
let fm = FileManager.default

let processedURL = DataManager.getProcessedFolderURL()
let artworkCacheURL = ArtworkService.cacheDirectoryURL
let backupURL = DataManager.getBackupFolderURL()
let inboxURL = DataManager.getInboxFolderURL()
let dbBackupURL = DataManager.getDatabaseBackupFolderURL()
let syncTasksSwiftDataURL = DataManager.getSyncTasksSwiftDataURL()
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStorageBreakdown() recursively enumerates multiple directories and files; if this runs on the main actor/thread during debug export creation, it can noticeably block UI (especially for large libraries). Consider computing this off the main thread (e.g., in a detached task) and/or caching results for the lifetime of the export generation.

Copilot uses AI. Check for mistakes.
Comment on lines +240 to +263
/// Get the size of a folder's contents
private func getFolderSize(_ url: URL, skipHidden: Bool) -> Int64 {
let fm = FileManager.default
guard fm.fileExists(atPath: url.path) else { return 0 }

var options: FileManager.DirectoryEnumerationOptions = []
if skipHidden {
options.insert(.skipsHiddenFiles)
}

guard let enumerator = fm.enumerator(
at: url,
includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey],
options: options
) else { return 0 }

var size: Int64 = 0
for case let fileURL as URL in enumerator {
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]),
values.isRegularFile == true else { continue }
size += Int64(values.fileSize ?? 0)
}
return size
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStorageBreakdown() recursively enumerates multiple directories and files; if this runs on the main actor/thread during debug export creation, it can noticeably block UI (especially for large libraries). Consider computing this off the main thread (e.g., in a detached task) and/or caching results for the lifetime of the export generation.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +43
.modifier(JellyfinSearchableModifier(isSearchable: viewModel.isSearchable, text: $viewModel.searchQuery))
.searchPresentationToolbarBehavior(.avoidHidingContent)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional searchable modifier is duplicated with the Audiobookshelf screen (same logic, different type name). Consolidating this into a shared helper (e.g., a single ConditionalSearchableModifier in a common UI utilities file) reduces duplication and helps keep search behavior consistent between integrations.

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +145

private struct JellyfinSearchableModifier: ViewModifier {
let isSearchable: Bool
@Binding var text: String

func body(content: Content) -> some View {
if isSearchable {
content.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always))
} else {
content
}
}
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional searchable modifier is duplicated with the Audiobookshelf screen (same logic, different type name). Consolidating this into a shared helper (e.g., a single ConditionalSearchableModifier in a common UI utilities file) reduces duplication and helps keep search behavior consistent between integrations.

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +231
public func searchItems(
in libraryId: String,
query: String,
limit: Int? = nil
) async throws -> [AudiobookShelfLibraryItem] {
guard let connection else {
throw URLError(.userAuthenticationRequired)
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces new network behavior and response decoding for Audiobookshelf search, but there’s no accompanying test coverage shown here. Adding unit tests that validate URL construction (path + query items), authorization header presence, and decoding/mapping behavior would help prevent regressions (especially since this is user-facing search).

Copilot uses AI. Check for mistakes.
Comment on lines +273 to +277
let decoder = JSONDecoder()
let searchResponse = try decoder.decode(AudiobookShelfSearchResponse.self, from: data)

return searchResponse.book.compactMap { AudiobookShelfLibraryItem(apiItem: $0.libraryItem) }
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces new network behavior and response decoding for Audiobookshelf search, but there’s no accompanying test coverage shown here. Adding unit tests that validate URL construction (path + query items), authorization header presence, and decoding/mapping behavior would help prevent regressions (especially since this is user-facing search).

Copilot uses AI. Check for mistakes.
@GianniCarlo GianniCarlo merged commit 4f29f5a into develop Mar 23, 2026
4 of 6 checks passed
@GianniCarlo GianniCarlo deleted the feat/integrations-search branch March 23, 2026 01:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants