9 Commits

Author SHA1 Message Date
Deluan Quintão
ae9b8a5fe6
feat(search): rank exact matches above prefix matches (#5704)
* feat(search): boost exact token matches over prefix matches

buildFTS5Query now emits (word OR word*) instead of word* for plain tokens. The match set is unchanged (exact is a subset of prefix), but bm25 gives the rare exact token a high-IDF contribution, so rows containing the literal query word rank above prefix-only matches. The degraded-query check keeps evaluating the plain prefix form, preserving the LIKE fallback for queries like "1+" and "C++".

* feat(search): weight artist search_normalized equal to name in bm25

For the artist table, search_normalized holds only the artist's name in alternate spelling (transliterated/punctuation-stripped), so a hit there is as meaningful as a name hit. Combined with exact-token boosting, artists like MØ now rank in the top results for the query "MO" instead of dead last. media_file and album keep weight 1.0 because their search_normalized mixes title, album, and artist variants.

* test(persistence): add exact-match ranking regression test

Seeds MØ, Morrissey, and Modest Mouse and asserts MØ ranks first for the queries "MO" and "MØ": the exact transliterated hit in search_normalized must outrank name-prefix matches. The corpus deliberately has no competing exact-word names, since exact-vs-exact ordering depends on corpus statistics rather than the guaranteed exact-over-prefix property. Rows are inserted per-test (with their library_artist associations) and cleaned up to avoid disturbing the shared seed fixtures and their count assertions.

* docs(search): document exact-token OR emission in buildFTS5Query

* test(persistence): harden exact-match ranking test fixtures

Register the corpus cleanup before the insert loop so a mid-loop assertion failure cannot leak fts-rank-% rows into the shared integration DB, and reuse the existing createArtistWithLibrary helper instead of hand-rolling Put+AddArtist (which also replaces the ad-hoc context.TODO with the helper's GinkgoT().Context).

* fix(search): flag multi-word degraded queries for the LIKE fallback

The degradation probe was joined with explicit " AND " like the real query, so ftsQueryDegraded counted the literal AND as a long token and never flagged queries where every term degrades to a short token (e.g. "1+ 2+"). This predates this branch (the old code passed the same AND-joined string), but the probe now exists separately, so join it with spaces — it only feeds ftsQueryDegraded, which needs no explicit operators.
2026-07-02 15:51:03 -04:00
Deluan Quintão
427d4b9bce
fix(search): artists with atomic non-ASCII names unfindable after FTS5 migration (#5703)
* fix(scanner): update artist search_normalized when rescanning

The FTS5 migration back-fills artist.search_normalized with a SQL
punctuation-strip approximation, relying on the next scan to compute the
precise value in Go (normalizeForFTS transliterates atomic letters like
Ø/æ/ß that FTS5's remove_diacritics cannot fold). But the scanner
persisted artists with an explicit column list that omitted
search_normalized, so not even a full scan ever repaired it: an artist
migrated from a pre-FTS database (e.g. "GØGGS") stayed unfindable by
any ASCII search, while their albums and songs, which are saved with all
columns, were fixed by a full scan. Add search_normalized to the column
list so a full scan re-indexes the artist via the artist_fts trigger.

* refactor(persistence): move normalizeForFTS to utils/str

Export it as str.NormalizeForFTS so the upcoming migration can reuse the
exact index-time normalization. Migrations cannot import the persistence
package (persistence -> db -> db/migrations would be an import cycle).

* fix(persistence): backfill artist search_normalized via migration

Recompute artist.search_normalized with the precise Go normalization for
databases migrated from pre-FTS5 versions, where the SQL back-fill could
not transliterate atomic letters (Ø/æ/ß) and the scanner never rewrote
the column. Only changed rows are updated, so the artist_fts update
trigger re-indexes exactly the affected artists, making artists like
GØGGS or MØ findable again without requiring a full scan.

* refactor(persistence): share FTS punctuation-strip regex via utils/str

Index-time normalization (NormalizeForFTS) and query-time processing
(buildFTS5Query/ftsQueryDegraded) must produce matching tokens, so keep
the punctuation-strip pattern in a single exported symbol instead of two
identical private copies that could drift. Also document that derived
columns computed in dbArtist.PostMapArgs must be listed in the scanner's
artist Put, which is how search_normalized went stale in the first place.

* chore(migrations): announce artist search backfill in the log

Match the FTS5 migration's notice() pattern so startup isn't silent
while the backfill runs on large libraries.

* docs: tighten comments added in this branch

* docs: describe FTSPunctStrip by what it matches, not one replacement
2026-07-02 12:53:10 -04:00
Deluan Quintão
5ec6e6a8d4
fix(opensubsonic): make search3 empty-query pagination fast at large offsets (#5601)
* fix(subsonic): make search3 empty-query pagination fast at large offsets

Empty-query search3 (used by clients like Symfonium to sync the whole
library) degraded linearly with songOffset: the offset optimization in
optimizePagination keeps the original query's LEFT JOINs (annotation,
bookmark, library) inside its rowid NOT IN subquery, making it as slow as
plain OFFSET (~5s per page at offset 900K on a 920K-track library).

Rewrite the empty-query branch of doSearch to use the same two-phase
approach as the FTS search: Phase 1 paginates rowids on the bare main
table, which SQLite satisfies with a covering index at any offset; Phase 2
hydrates only the page's rows with all JOINs. The Phase 2 hydration logic
is extracted into hydrateRowidPage, now shared with ftsSearch.execute.

Also replace the media_file_missing index with a composite covering index
on (missing, library_id), so Phase 1 stays covering for non-admin users,
whose queries include a library_id filter. The composite serves all
missing-only lookups via its prefix.

With a 920K-track / 85K-album test library, search3 empty-query responses
are now flat (~0.1s) at every offset, for both admin and non-admin users
(previously 3-5s at offsets above 600K).

* refactor(persistence): share search Phase 1 contract and dedup junction fan-out

Extract the Phase 1 query assembly that was duplicated between the FTS
search and the empty-query search into executeTwoPhase: both paths now
supply only their strategy-specific FROM/JOINs and ORDER BY, while the
shared contract (missing filter, library access, options.Filters, and
Max/Offset semantics) lives in one place.

Also fix a pagination integrity bug: the artist library filter joins the
library_artist junction table, so an artist present in multiple libraries
produced duplicate rowids in Phase 1, corrupting offset-based pagination
(short pages and repeated artists during full-library syncs). Phase 1 now
applies DISTINCT whenever a junction-based LibraryFilter is set. DISTINCT
is used instead of GROUP BY because bm25() cannot be evaluated in a
grouped query; plain-filter tables (media_file, album) skip the dedup so
their Phase 1 keeps the streaming covering-index plan. This also fixes the
same duplication in the pre-existing FTS search path.

* fix(persistence): pin artist search Phase 1 join order with CROSS JOIN

search3 always filters artists by library (library_artist.library_id IN
...), and with the junction JOIN in the search Phase 1 rowid query SQLite
chose to drive from library_artist, sorting every junction row with a temp
b-tree on each page — a flat ~200ms penalty per request at 405K artists,
even at offset 0 (the previous code avoided this by accident: its GROUP BY
artist.id pinned an artist-driven plan).

Use CROSS JOIN (SQLite's explicit join-order override) in a search-only
variant of the artist library filter, keeping artist as the outer table so
Phase 1 streams rowids in artist.id order from the primary key index and
LIMIT/OFFSET short-circuits. The DISTINCT dedup stays and costs nothing
under the streaming plan. Other artist queries keep the planner's freedom.

With 405K artists, empty-query artist search is now 0.07s at offset 0 and
0.25s at offset 399K end-to-end (was 0.31s/0.34s before this fix, and up
to 1.2s on master at deep offsets). Artist FTS text search is unaffected.
2026-06-12 15:53:37 -04:00
Deluan
2a43c4683e chore: go fix
Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-28 22:13:05 -03:00
Deluan Quintão
81a17f6bbb
fix(search): normalization for non-NFKD Unicode letters (ø, æ, œ, ß) (#5413)
* fix(search): transliterate non-ASCII letters symmetrically in FTS5 path

Songs and artists with letters like ø, æ, œ, ß were unsearchable. The
query path in server/subsonic/searching.go transliterates with
sanitize.Accents (Øystein → Oystein), but the FTS5 tokenizer's
remove_diacritics 2 only strips NFKD-decomposable marks — atomic
letters with built-in strokes/ligatures survive tokenization, so the
query side and index side disagreed.

Apply sanitize.Accents on both sides:

- normalizeForFTS now also emits an ASCII-transliterated form for each
  word, so search_normalized contains the variant the query produces.
- buildFTS5Query transliterates the unquoted portion of the input so
  every caller (Subsonic, REST fullTextFilter) gets the same handling.
  Quoted phrases stay as typed, preserving phrase matches against the
  original title/artist columns.

Existing libraries pick up the fix as records are re-scanned; users
can trigger a manual full rescan to refresh older entries.

* fix(search): cache transliteration and add ß/quoted-phrase test coverage

Address review feedback: call sanitize.Accents once per word and reuse
the result for both the punct-stripped and accent-only paths. Add missing
test entries for ß→ss transliteration and quoted Unicode phrase preservation.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 20:27:38 -04:00
Deluan
ccee33f474 fix(search): use explicit AND in FTS5 queries to fix apostrophe search
FTS5's implicit AND (space-separated tokens) silently fails when combined
with parenthesized OR groups produced by processPunctuatedWords. For example,
searching "you've got" generated the query `("you ve" OR youve*) got*` which
returned no results. Using explicit AND (`("you ve" OR youve*) AND got*`)
resolves this FTS5 quirk. Since implicit and explicit AND are semantically
identical in FTS5, this change is safe for all queries unconditionally.
2026-03-26 20:15:28 -04:00
Deluan Quintão
b59eb32961
feat(subsonic): sort search3 results by relevance (#5086)
* fix(subsonic): optimize search3 for high-cardinality FTS queries

Use a two-phase query strategy for FTS5 searches to avoid the
performance penalty of expensive LEFT JOINs (annotation, bookmark,
library) on high-cardinality results like "the".

Phase 1 runs a lightweight query (main table + FTS index only) to get
sorted, paginated rowids. Phase 2 hydrates only those few rowids with
the full JOINs, making them nearly free.

For queries with complex ORDER BY expressions that reference joined
tables (e.g. artist search sorted by play count), the optimization is
skipped and the original single-query approach is used.

* fix(search): update order by clauses to include 'rank' for FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): reintroduce 'rank' in Phase 2 ORDER BY for FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): remove 'rank' from ORDER BY in non-FTS queries and adjust two-phase query handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): update FTS ranking to use bm25 weights and simplify ORDER BY qualification

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refine FTS query handling and improve comments for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refactor full-text search handling to streamline query strategy selection and improve LIKE fallback logic.

Increase e2e coverage for search3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: enhance FTS column definitions and relevance weights

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): refactor Search method signatures to remove offset and size parameters, streamline query handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): allow single-character queries in search strategies and update related tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): make FTS Phase 1 treat Max=0 as no limit, reorganize tests

FTS Phase 1 unconditionally called Limit(uint64(options.Max)), which
produced LIMIT 0 when Max was zero. This diverged from applyOptions
where Max=0 means no limit. Now Phase 1 mirrors applyOptions: only add
LIMIT/OFFSET when the value is positive. Also moved legacy backend
integration tests from sql_search_fts_test.go to sql_search_like_test.go
and added regression tests for the Max=0 behavior on both backends.

* refactor: simplify callSearch function by removing variadic options and directly using QueryOptions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(search): implement ftsQueryDegraded function to detect significant content loss in FTS queries

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-23 08:51:54 -05:00
Deluan
ec75808153 fix(subsonic): handle empty quoted phrases in FTS5 query and search expression
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 22:00:00 -05:00
Deluan Quintão
54de0dbc52
feat(server): implement FTS5-based full-text search (#5079)
* build: add sqlite_fts5 build tag to enable FTS5 support

* feat: add SearchBackend config option (default: fts)

* feat: add buildFTS5Query for safe FTS5 query preprocessing

* feat: add FTS5 search backend with config toggle, refactor legacy search

- Add searchExprFunc type and getSearchExpr() for backend selection
- Rename fullTextExpr to legacySearchExpr
- Add ftsSearchExpr using FTS5 MATCH subquery
- Update fullTextFilter in sql_restful.go to use configured backend

* feat: add FTS5 migration with virtual tables, triggers, and search_participants

Creates FTS5 virtual tables for media_file, album, and artist with
unicode61 tokenizer and diacritic folding. Adds search_participants
column, populates from JSON, and sets up INSERT/UPDATE/DELETE triggers.

* feat: populate search_participants in PostMapArgs for FTS5 indexing

* test: add FTS5 search integration tests

* fix: exclude FTS5 virtual tables from e2e DB restore

The restoreDB function iterates all tables in sqlite_master and
runs DELETE + INSERT to reset state. FTS5 contentless virtual tables
cannot be directly deleted from. Since triggers handle FTS5 sync
automatically, simply skip tables matching *_fts and *_fts_* patterns.

* build: add compile-time guard for sqlite_fts5 build tag

Same pattern as netgo: compilation fails with a clear error if
the sqlite_fts5 build tag is missing.

* build: add sqlite_fts5 tag to reflex dev server config

* build: extract GO_BUILD_TAGS variable in Makefile to avoid duplication

* fix: strip leading * from FTS5 queries to prevent "unknown special query" error

* feat: auto-append prefix wildcard to FTS5 search tokens for broader matching

Every plain search token now gets a trailing * appended (e.g., "love" becomes
"love*"), so searching for "love" also matches "lovelace", "lovely", etc.
Quoted phrases are preserved as exact matches without wildcards. Results are
ordered alphabetically by name/title, so shorter exact matches naturally
appear first.

* fix: clarify comments about FTS5 operator neutralization

The comments said "strip" but the code lowercases operators to
neutralize them (FTS5 operators are case-sensitive). Updated comments
to accurately describe the behavior.

* fix: use fmt.Sprintf for FTS5 phrase placeholders

The previous encoding used rune('0'+index) which silently breaks with
10+ quoted phrases. Use fmt.Sprintf for arbitrary index support.

* fix: validate and normalize SearchBackend config option

Normalize the value to lowercase and fall back to "fts" with a log
warning for unrecognized values. This prevents silent misconfiguration
from typos like "FTS", "Legacy", or "fts5".

* refactor: improve documentation for build tags and FTS5 requirements

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: convert FTS5 query and search backend normalization tests to DescribeTable format

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add sqlite_fts5 build tag to golangci configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add UISearchDebounceMs configuration option and update related components

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: fall back to legacy search when SearchFullString is enabled

FTS5 is token-based and cannot match substrings within words, so
getSearchExpr now returns legacySearchExpr when SearchFullString
is true, regardless of SearchBackend setting.

* fix: add sqlite_fts5 build tag to CI pipeline and Dockerfile

* fix: add WHEN clauses to FTS5 AFTER UPDATE triggers

Added WHEN clauses to the media_file_fts_au, album_fts_au, and
artist_fts_au triggers so they only fire when FTS-indexed columns
actually change. Previously, every row update (e.g., play count, rating,
starred status) triggered an unnecessary delete+insert cycle in the FTS
shadow tables. The WHEN clauses use IS NOT for NULL-safe comparison of
each indexed column, avoiding FTS index churn for non-indexed updates.

* feat: add SearchBackend configuration option to data and insights components

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: enhance input sanitization for FTS5 by stripping additional punctuation and special characters

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add search_normalized column for punctuated name search (R.E.M., AC/DC)

Add index-time normalization and query-time single-letter collapsing to
fix FTS5 search for punctuated names. A new search_normalized column
stores concatenated forms of punctuated words (e.g., "R.E.M." → "REM",
"AC/DC" → "ACDC") and is indexed in FTS5 tables. At query time, runs of
consecutive single letters (from dot-stripping) are collapsed into OR
expressions like ("R E M" OR REM*) to match both the original tokens and
the normalized form. This enables searching by "R.E.M.", "REM", "AC/DC",
"ACDC", "A-ha", or "Aha" and finding the correct results.

* refactor: simplify isSingleUnicodeLetter to avoid []rune allocation

Use utf8.DecodeRuneInString to check for a single Unicode letter
instead of converting the entire string to a []rune slice.

* feat: define ftsSearchColumns for flexible FTS5 search column inclusion

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: update collapseSingleLetterRuns to return quoted phrases for abbreviations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement extractPunctuatedWords to handle artist/album names with embedded punctuation

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: punctuated word handling to improve processing of artist/album names

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add CJK support for search queries with LIKE filters

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance FTS5 search by adding album version support and CJK handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: search configuration to use structured options

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance search functionality to support punctuation-only queries and update related tests

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-21 17:52:42 -05:00