From 09b50c4bad8cff1befac9e24df750eb5f856c213 Mon Sep 17 00:00:00 2001 From: Kevin Heneveld <1192102+kevinheneveld@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:24:01 -0800 Subject: [PATCH] feat(search): unified library search across books, series, authors, narrators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the header search to match the owned library on all four dimensions at once, grouping suggestions by kind (each linking to the book detail or the relevant collection page). Enter — or a new "See all results" row — opens a dedicated results page at /search (LibrarySearchView) that lists every match in sectioned Books / Series / Authors / Narrators groups. This searches what you already have; it is distinct from the outward-facing "Add new" metadata search. This also wires up the `/search` route the header was already pushing to: the existing `selectSuggestion` did `router.push({ name: 'search' })`, but no route named `search` existed on the current tree (content/SearchView.vue is orphaned), so that navigation silently failed. The new route makes it resolve. Matching logic lives in utils/librarySearch (shared by the header overlay and the results page) and is keyed by a shared normalizer (utils/textUtils normalizeCollectionText) so a suggestion's count matches the page it links to. Entirely client-side over the loaded library, with the distinct-name index memoized so typing stays responsive. Non-person narrator credits ("Full Cast", "Various", uncredited) are excluded so the library doesn't sprout pseudo pages. Co-Authored-By: Claude Opus 4.8 --- fe/src/App.vue | 230 +++++-- fe/src/__tests__/librarySearch.spec.ts | 144 +++++ fe/src/router/index.ts | 6 + fe/src/utils/librarySearch.ts | 172 +++++ fe/src/utils/textUtils.ts | 43 ++ fe/src/views/library/LibrarySearchView.vue | 699 +++++++++++++++++++++ 6 files changed, 1230 insertions(+), 64 deletions(-) create mode 100644 fe/src/__tests__/librarySearch.spec.ts create mode 100644 fe/src/utils/librarySearch.ts create mode 100644 fe/src/views/library/LibrarySearchView.vue diff --git a/fe/src/App.vue b/fe/src/App.vue index 1571c998b..4ea548936 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -68,7 +68,7 @@ - +
{{ section.label }}
+ + + +
@@ -562,6 +574,12 @@ import { useAuthStore } from '@/stores/auth' import { apiService } from '@/services/api' import { getStartupConfigCached } from '@/services/startupConfigCache' import { handleImageError } from '@/utils/imageFallback' +import { + buildLibraryFacets, + matchLibraryBooks, + matchLibraryFacets, + type LibraryFacetMatch, +} from '@/utils/librarySearch' import { Pill } from '@/components/base' import { getPlaceholderUrl } from '@/utils/placeholder' import { useProtectedImages } from '@/composables/useProtectedImages' @@ -950,9 +968,21 @@ router.afterEach(() => { pendingNavPath.value = null }) const searchQuery = vueRef('') -const suggestions = vueRef< - Array<{ id: number; title: string; author?: string; imageUrl?: string }> ->([]) + +// A unified library-search suggestion. `kind` drives both the overlay grouping and +// where a click navigates: books open the detail page, the other kinds open their +// collection page (/collection//). Keys are composite (kind + id/name) +// because collection suggestions have no numeric id. +type SearchSuggestionKind = 'book' | 'series' | 'author' | 'narrator' +type SearchSuggestion = { + kind: SearchSuggestionKind + key: string + label: string + sublabel?: string + imageUrl?: string + to: string | { name: string; params: Record } +} +const suggestions = vueRef([]) const searching = vueRef(false) const searchInputRef = vueRef(null) @@ -984,6 +1014,15 @@ const handleSearchDocumentClick = (e: MouseEvent) => { } } +// Distinct series / authors / narrators across the library, each with a book count +// and a sample cover. Computed once and cached by Vue until the library changes, so +// each keystroke filters this prepared index instead of re-scanning every book. +const libraryFacets = computed(() => buildLibraryFacets(libraryStore.audiobooks)) + +const BOOK_SUGGESTION_LIMIT = 5 +const FACET_SUGGESTION_LIMIT = 4 +const facetSub = (count: number) => `${count} book${count !== 1 ? 's' : ''}` + let searchDebounceTimer: number | undefined const onSearchInput = async () => { if (searchDebounceTimer) clearTimeout(searchDebounceTimer) @@ -998,25 +1037,42 @@ const onSearchInput = async () => { if (libraryStore.audiobooks.length === 0) { await libraryStore.fetchLibrary() } - const lib = libraryStore.audiobooks - const lower = q.toLowerCase() - const localMatches = lib.filter( - (b) => - (b.title || '').toLowerCase().includes(lower) || - (Array.isArray(b.authors) ? b.authors.join(' ').toLowerCase() : '').includes(lower), - ) - if (localMatches.length > 0) { - // Only show local library matches in the header search - suggestions.value = localMatches.slice(0, 8).map((b) => ({ - id: b.id!, - title: b.title || 'Unknown', - author: Array.isArray(b.authors) ? b.authors[0] || '' : '', - imageUrl: b.imageUrl || '', - })) - } else { - // No fallback to indexers from header search; leave suggestions empty - suggestions.value = [] + const out: SearchSuggestion[] = [] + + // Books — title or any author contains the query. + for (const b of matchLibraryBooks(libraryStore.audiobooks, q, BOOK_SUGGESTION_LIMIT)) { + out.push({ + kind: 'book', + key: `book:${b.id}`, + label: b.title, + sublabel: b.author, + imageUrl: b.imageUrl, + to: { name: 'audiobook-detail', params: { id: String(b.id) } }, + }) + } + + // Series / authors / narrators — normalized substring match, most-books first. + const facets = libraryFacets.value + const pushFacets = ( + arr: LibraryFacetMatch[], + kind: Exclude, + ) => { + for (const f of matchLibraryFacets(arr, q, FACET_SUGGESTION_LIMIT)) { + out.push({ + kind, + key: `${kind}:${f.normKey}`, + label: f.name, + sublabel: facetSub(f.count), + imageUrl: f.imageUrl, + to: `/collection/${kind}/${encodeURIComponent(f.name)}`, + }) + } } + pushFacets(facets.series, 'series') + pushFacets(facets.authors, 'author') + pushFacets(facets.narrators, 'narrator') + + suggestions.value = out } catch (err) { logger.error('Header search failed', err) suggestions.value = [] @@ -1026,31 +1082,43 @@ const onSearchInput = async () => { }, 250) } -const selectSuggestion = (s: { id: number; title: string; author?: string }) => { - // Navigate to audiobook detail if local (id > 0), else open search view +// Section grouping for the overlay (Books / Series / Authors / Narrators). +const SUGGESTION_SECTION_LABELS: Record = { + book: 'Books', + series: 'Series', + author: 'Authors', + narrator: 'Narrators', +} +const suggestionSections = computed(() => { + const order: SearchSuggestionKind[] = ['book', 'series', 'author', 'narrator'] + return order + .map((kind) => ({ + kind, + label: SUGGESTION_SECTION_LABELS[kind], + items: suggestions.value.filter((s) => s.kind === kind), + })) + .filter((section) => section.items.length > 0) +}) + +const selectSuggestion = (s: SearchSuggestion) => { if (!s) return searchQuery.value = '' suggestions.value = [] - if (s.id && s.id > 0) { - // Navigate to audiobook detail page (router name: 'audiobook-detail') - void router.push({ name: 'audiobook-detail', params: { id: String(s.id) } }) - } else { - // Use the general search page for indexer results - void router.push({ name: 'search', query: { q: s.title } }) - } + searchOpen.value = false + void router.push(s.to) } -const applyFirstResult = () => { - if (suggestions.value.length > 0) selectSuggestion(suggestions.value[0]!) +// Enter (or the "See all results" row) opens the full results page — the single +// place that lists every matching book, series, author and narrator. +const goToResults = () => { + const q = searchQuery.value.trim() + if (!q) return + searchQuery.value = '' + suggestions.value = [] + searchOpen.value = false + void router.push({ name: 'search', query: { q } }) } -watch( - () => suggestions.value.length, - () => { - // Native lazy loading covers search suggestions automatically - }, -) - const parseAuthEnabledFromStartupConfig = (raw: unknown): boolean | null => { if (typeof raw === 'boolean') return raw if (typeof raw === 'string') { @@ -2357,6 +2425,40 @@ these are not present, the Google Fonts import in `fe/index.html` will be used a font-size: 0.9rem; } +.search-section + .search-section { + margin-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-top: 4px; +} + +.search-section-header { + padding: 6px 10px 2px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8a929a; +} + +.search-see-all { + display: block; + width: 100%; + text-align: left; + padding: 10px; + margin-top: 4px; + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: transparent; + color: #2196f3; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; +} + +.search-see-all:hover { + background: rgba(255, 255, 255, 0.03); +} + /* Mobile search overlay */ @media (max-width: 768px) { .nav-search-inline { diff --git a/fe/src/__tests__/librarySearch.spec.ts b/fe/src/__tests__/librarySearch.spec.ts new file mode 100644 index 000000000..340abd91b --- /dev/null +++ b/fe/src/__tests__/librarySearch.spec.ts @@ -0,0 +1,144 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { describe, expect, it } from 'vitest' +import { + buildLibraryFacets, + matchLibraryBooks, + matchLibraryFacets, + type LibrarySearchInput, +} from '@/utils/librarySearch' + +const library: LibrarySearchInput[] = [ + { + id: 1, + title: 'Project Hail Mary', + authors: ['Andy Weir'], + narrators: ['Ray Porter'], + series: undefined, + imageUrl: 'phm.jpg', + }, + { + id: 2, + title: 'The Martian', + authors: ['Andy Weir'], + narrators: ['R. C. Bray'], + series: undefined, + imageUrl: 'martian.jpg', + }, + { + id: 3, + title: 'Skyward', + authors: ['Brandon Sanderson'], + // Same narrator with different casing/punctuation must merge with book 4. + narrators: ['Suzy Jackson'], + series: 'Skyward', + imageUrl: 'skyward.jpg', + }, + { + id: 4, + title: 'Starsight', + authors: ['Brandon Sanderson'], + narrators: ['suzy jackson', 'Full Cast'], + series: 'Skyward', + imageUrl: 'starsight.jpg', + }, + { + id: 5, + title: 'The Sandman', + authors: ['Neil Gaiman'], + // Dramatized — every credit but the production token is a real person. + narrators: ['James McAvoy', 'A Full Cast'], + series: undefined, + imageUrl: 'sandman.jpg', + }, +] + +describe('buildLibraryFacets', () => { + it('fans a book out across each narrator and merges normalized duplicates', () => { + const { narrators } = buildLibraryFacets(library) + const suzy = narrators.find((n) => n.normKey === 'suzy jackson') + expect(suzy).toBeDefined() + // Books 3 and 4 both credit Suzy Jackson despite casing/spacing differences. + expect(suzy?.count).toBe(2) + expect(suzy?.name).toBe('Suzy Jackson') // first-seen display casing + }) + + it('excludes non-person narrator credits ("Full Cast")', () => { + const { narrators } = buildLibraryFacets(library) + const keys = narrators.map((n) => n.normKey) + expect(keys).not.toContain('full cast') + expect(keys).not.toContain('a full cast') + // The real co-narrator on a dramatized book is still surfaced. + expect(keys).toContain('james mcavoy') + }) + + it('counts authors and series book-centrically', () => { + const { authors, series } = buildLibraryFacets(library) + expect(authors.find((a) => a.normKey === 'andy weir')?.count).toBe(2) + expect(authors.find((a) => a.normKey === 'brandon sanderson')?.count).toBe(2) + expect(series.find((s) => s.normKey === 'skyward')?.count).toBe(2) + }) + + it('counts a name once per book even when listed twice (matches .some() filter)', () => { + const dupe: LibrarySearchInput[] = [ + { id: 9, title: 'Dup', authors: ['Jane Doe', 'jane doe'], narrators: ['Ray Porter', 'Ray Porter'] }, + ] + const { authors, narrators } = buildLibraryFacets(dupe) + expect(authors.find((a) => a.normKey === 'jane doe')?.count).toBe(1) + expect(narrators.find((n) => n.normKey === 'ray porter')?.count).toBe(1) + }) +}) + +describe('matchLibraryBooks', () => { + it('matches on title or author, case-insensitively', () => { + expect(matchLibraryBooks(library, 'martian').map((b) => b.id)).toEqual([2]) + expect(matchLibraryBooks(library, 'weir').map((b) => b.id).sort()).toEqual([1, 2]) + }) + + it('honors the result limit', () => { + expect(matchLibraryBooks(library, 'the', 1)).toHaveLength(1) + }) + + it('parses a release year from publishYear or publishedDate', () => { + const books: LibrarySearchInput[] = [ + { id: 1, title: 'Y From Field', authors: ['A'], publishYear: '2011' }, + { id: 2, title: 'Y From Date', authors: ['A'], publishedDate: '2008-05-01' }, + { id: 3, title: 'Y Missing', authors: ['A'] }, + ] + const byId = Object.fromEntries(matchLibraryBooks(books, 'y ').map((b) => [b.id, b.year])) + expect(byId[1]).toBe(2011) + expect(byId[2]).toBe(2008) + expect(byId[3]).toBeUndefined() + }) +}) + +describe('matchLibraryFacets', () => { + it('matches normalized substrings ranked by book count then name', () => { + const { authors } = buildLibraryFacets(library) + const hits = matchLibraryFacets(authors, 'an') + // All three contain "an"; Weir(2) and Sanderson(2) outrank Gaiman(1), and the + // two-book tie breaks alphabetically — so order is deterministic. + expect(hits.map((h) => h.name)).toEqual(['Andy Weir', 'Brandon Sanderson', 'Neil Gaiman']) + }) + + it('returns nothing for a blank/punctuation-only query', () => { + const { authors } = buildLibraryFacets(library) + expect(matchLibraryFacets(authors, ' ')).toEqual([]) + expect(matchLibraryFacets(authors, '!!!')).toEqual([]) + }) +}) diff --git a/fe/src/router/index.ts b/fe/src/router/index.ts index 914f8772f..d05c7e640 100644 --- a/fe/src/router/index.ts +++ b/fe/src/router/index.ts @@ -50,6 +50,12 @@ const routes = [ component: () => import('../views/library/CollectionView.vue'), meta: { requiresAuth: true }, }, + { + path: '/search', + name: 'search', + component: () => import('../views/library/LibrarySearchView.vue'), + meta: { requiresAuth: true }, + }, { path: '/add-new', name: 'add-new', diff --git a/fe/src/utils/librarySearch.ts b/fe/src/utils/librarySearch.ts new file mode 100644 index 000000000..09c109a11 --- /dev/null +++ b/fe/src/utils/librarySearch.ts @@ -0,0 +1,172 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { normalizeCollectionText, isNonPersonNarrator } from './textUtils' + +/** + * Unified library search — matches a single query against the books the user + * already owns across four dimensions (books, series, authors, narrators) and + * returns each grouped. Shared by the header search overlay and the full results + * page so both rank and bucket results identically. + * + * Collection facets (series/authors/narrators) are keyed with the same + * normalizer the collection pages use, so a result's book count matches the page + * it links to. Non-person narrator credits ("Full Cast") are excluded — they are + * not browsable people. + */ + +// The subset of an Audiobook this module reads. Kept structural so it does not +// couple to the full Audiobook type. +export interface LibrarySearchInput { + id?: number + title?: string + authors?: string[] + narrators?: string[] + series?: string + imageUrl?: string + publishYear?: string | number + publishedDate?: string +} + +export type LibrarySearchKind = 'book' | 'series' | 'author' | 'narrator' + +export interface LibraryBookMatch { + id: number + title: string + author: string + imageUrl: string + year?: number +} + +export interface LibraryFacetMatch { + name: string + normKey: string + count: number + imageUrl?: string +} + +export interface LibraryFacets { + series: LibraryFacetMatch[] + authors: LibraryFacetMatch[] + narrators: LibraryFacetMatch[] +} + +/** + * Build the distinct series / author / narrator index for a library, each with a + * book count and a sample cover. Pure and dependency-free so a caller can compute + * it once (e.g. a cached computed) and reuse it across many queries. + */ +export function buildLibraryFacets(books: readonly LibrarySearchInput[]): LibraryFacets { + const series = new Map() + const authors = new Map() + const narrators = new Map() + + // `seen` dedupes within a single book so a book that lists the same name twice + // still counts once — matching the collection page's `.some()` filter and the + // grouped-grid fan-out, so counts stay identical across all three surfaces. + const add = ( + map: Map, + raw: string | undefined, + cover: string, + seen: Set, + ) => { + const display = (raw ?? '').trim() + if (!display) return + const normKey = normalizeCollectionText(display) + if (!normKey || seen.has(normKey)) return + seen.add(normKey) + let facet = map.get(normKey) + if (!facet) { + facet = { name: display, normKey, count: 0, imageUrl: cover || undefined } + map.set(normKey, facet) + } + facet.count++ + if (!facet.imageUrl && cover) facet.imageUrl = cover + } + + for (const book of books) { + const cover = book.imageUrl || '' + if (book.series) add(series, book.series, cover, new Set()) + const authorsSeen = new Set() + for (const author of book.authors ?? []) add(authors, author, cover, authorsSeen) + const narratorsSeen = new Set() + for (const narrator of book.narrators ?? []) { + if (!isNonPersonNarrator(narrator)) add(narrators, narrator, cover, narratorsSeen) + } + } + + return { + series: [...series.values()], + authors: [...authors.values()], + narrators: [...narrators.values()], + } +} + +/** Best-effort 4-digit release year from publishYear or publishedDate. */ +function parsePublishYear(book: LibrarySearchInput): number | undefined { + if (book.publishYear != null && book.publishYear !== '') { + const y = Number.parseInt(String(book.publishYear), 10) + if (Number.isFinite(y)) return y + } + const match = (book.publishedDate || '').match(/\d{4}/) + return match ? Number.parseInt(match[0], 10) : undefined +} + +/** Books whose title or any author contains the query (case-insensitive). */ +export function matchLibraryBooks( + books: readonly LibrarySearchInput[], + rawQuery: string, + limit?: number, +): LibraryBookMatch[] { + const lower = rawQuery.trim().toLowerCase() + if (!lower) return [] + const matches: LibraryBookMatch[] = [] + for (const book of books) { + const titleHit = (book.title || '').toLowerCase().includes(lower) + const authorHit = (Array.isArray(book.authors) ? book.authors.join(' ').toLowerCase() : '').includes( + lower, + ) + if (!titleHit && !authorHit) continue + matches.push({ + id: book.id ?? 0, + title: book.title || 'Unknown', + author: Array.isArray(book.authors) ? book.authors[0] || '' : '', + imageUrl: book.imageUrl || '', + year: parsePublishYear(book), + }) + if (limit != null && matches.length >= limit) break + } + return matches +} + +/** + * Filter a prepared facet list by a normalized-substring match, ranked + * most-books-first then alphabetically. + */ +export function matchLibraryFacets( + facets: readonly LibraryFacetMatch[], + rawQuery: string, + limit?: number, +): LibraryFacetMatch[] { + const nq = normalizeCollectionText(rawQuery) + if (!nq) return [] + const matched = facets + .filter((facet) => facet.normKey.includes(nq)) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + return limit != null ? matched.slice(0, limit) : matched +} diff --git a/fe/src/utils/textUtils.ts b/fe/src/utils/textUtils.ts index a9a3eb7b9..b86462509 100644 --- a/fe/src/utils/textUtils.ts +++ b/fe/src/utils/textUtils.ts @@ -115,3 +115,46 @@ export function stripHtmlAndNormalize(text: string | undefined | null): string { return decodeHtmlEntities(raw) } + +/** + * Normalizes a collection/display name (series, author, narrator) to a stable + * comparison key: strips diacritics, lowercases, and collapses any run of + * non-alphanumerics to a single space. Used so the same name matches regardless + * of punctuation/accents, and so a header-search suggestion's count lines up with + * the collection page it links to (both key off this function). + */ +export function normalizeCollectionText(value: string | undefined | null): string { + if (!value) return '' + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +// Compared against normalizeCollectionText output. +const NON_PERSON_NARRATOR_TOKENS = new Set([ + 'full cast', + 'a full cast', + 'full cast production', + 'full cast dramatization', + 'various', + 'various narrators', + 'uncredited', +]) + +/** + * True when a narrator credit isn't a real, browsable person (e.g. "Full Cast"). + * Used to exclude such tokens from narrator grouping and unified search so the + * library doesn't sprout pseudo-narrator pages. Empty/blank credits count as + * non-person too. Not applied to the collection-page filter: you only reach a + * narrator page by clicking a real card, so there is nothing to exclude there. + */ +export function isNonPersonNarrator(name: string | undefined | null): boolean { + const normalized = normalizeCollectionText(name) + if (!normalized) return true + if (NON_PERSON_NARRATOR_TOKENS.has(normalized)) return true + // Catch "... full cast ..." phrasings not in the explicit set. + return normalized.includes('full cast') +} diff --git a/fe/src/views/library/LibrarySearchView.vue b/fe/src/views/library/LibrarySearchView.vue new file mode 100644 index 000000000..6e5c97523 --- /dev/null +++ b/fe/src/views/library/LibrarySearchView.vue @@ -0,0 +1,699 @@ + + + + + + +