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 @@ + + + + + + +