From 5132ab90830d8e5f47826ce5590fc1f58617d66f Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Thu, 29 Oct 2026 20:19:52 +0100 Subject: [PATCH] Search: a word with a leading ^ matches at a word's start only "^zed" finds "Zed Dead", "The_Zed_Files", not "Facialized": a regex on release_texts, (^|[^[:alnum:]]) before the word, which the trigram index serves (homelab: 551 texts for ^zed against 6,606 for zed, 35 ms warm). Words without ^ match anywhere as before; the search box says so. Co-Authored-By: Claude Opus 5.5 --- app/services/search_service.ts | 21 ++++++++++++++++++--- public/index.html | 2 +- tests/functional/releases_api.spec.ts | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/services/search_service.ts b/app/services/search_service.ts index c5e26c9..f0d9955 100644 --- a/app/services/search_service.ts +++ b/app/services/search_service.ts @@ -85,6 +85,19 @@ function contains(word: string): string { return `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%` } +/** + * A search word's condition on release_texts: anywhere in a text (ILIKE), or with a leading "^" at a word's start, the + * text's or after a character that's no letter or digit ("^zed" finds "Zed.Dead", "The_Zed", not "Facialized"). Both + * use the trigram index. + */ +function wordMatch(word: string): { sql: string; binding: string } { + if (word.startsWith('^') && word.length > 1) { + const rest = word.slice(1).replace(/[\\^$.|?*+()[\]{}]/g, (c) => `\\${c}`) + return { sql: 't.text ~* ?', binding: `(^|[^[:alnum:]])${rest}` } + } + return { sql: 't.text ILIKE ?', binding: contains(word) } +} + /** Searching and browsing the catalog, for the web interface and Newznab. */ class SearchService { async search(params: SearchParams) { @@ -93,10 +106,12 @@ class SearchService { if (!params.judged) where.push('NOT EXISTS (SELECT 1 FROM release_verdicts v WHERE v.release_id = r.id)') const bindings: unknown[] = [] // Every word in one of the release's texts (its name, the names the prober found inside; release_texts, by its - // trigram index, which yields ids: never an OR across the two tables); "The.Block.AU" and "the block au" alike. + // trigram index, which yields ids: never an OR across the two tables); "The.Block.AU" and "the block au" alike; + // "^zed" only at a word's start. for (const word of (params.q ?? '').split(/[\s._-]+/).filter(Boolean).slice(0, 8)) { - where.push('r.id IN (SELECT t.release_id FROM release_texts t WHERE t.text ILIKE ?)') - bindings.push(contains(word)) + const match = wordMatch(word) + where.push(`r.id IN (SELECT t.release_id FROM release_texts t WHERE ${match.sql})`) + bindings.push(match.binding) } if (params.readable) where.push('(r.readable OR r.inner_name IS NOT NULL)') if (params.complete) where.push('r.complete') diff --git a/public/index.html b/public/index.html index 6b73f85..80291ea 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@