diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 78687e51c..e863a65e7 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -3,7 +3,7 @@ * Operations for managing and searching content (streams, movies, series) */ -import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, or, sql } from 'drizzle-orm'; import { ipcMain } from 'electron'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; @@ -16,6 +16,25 @@ function escapeLikePattern(term: string): string { return term.replace(/[%_\\]/g, '\\$&'); } +/** + * Build LIKE patterns for multiple case variants to improve non-ASCII matching + * in SQLite (default LIKE case-insensitive behavior is ASCII-only). + */ +function buildLikePatterns(term: string): string[] { + const variants = new Set(); + const titleCase = + term.length > 0 + ? term.charAt(0).toLocaleUpperCase() + term.slice(1).toLocaleLowerCase() + : term; + + variants.add(term); + variants.add(term.toLocaleLowerCase()); + variants.add(term.toLocaleUpperCase()); + variants.add(titleCase); + + return [...variants].map(value => `%${escapeLikePattern(value)}%`); +} + /** * Check if content exists */ @@ -243,9 +262,11 @@ ipcMain.handle( async (event, playlistId: string, searchTerm: string, types: string[]) => { try { const db = await getDatabase(); - const searchTermLower = searchTerm.toLowerCase(); - const escapedTerm = escapeLikePattern(searchTerm); - const likePattern = `%${escapedTerm}%`; + const searchTermLower = searchTerm.toLocaleLowerCase(); + const likePatterns = buildLikePatterns(searchTerm); + const likeConditions = likePatterns.map( + pattern => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + ); // Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code). // This avoids loading the entire content table into JS memory. @@ -272,7 +293,7 @@ ipcMain.handle( schema.content.type, types as Array<'live' | 'movie' | 'series'> ), - sql`${schema.content.title} LIKE ${likePattern}` + or(...likeConditions) ) ) .limit(200); @@ -280,7 +301,9 @@ ipcMain.handle( // Post-filter for proper Unicode case-insensitive matching // (SQLite LIKE is only case-insensitive for ASCII) const result = candidates - .filter(item => item.title?.toLowerCase().includes(searchTermLower)) + .filter(item => + item.title?.toLocaleLowerCase().includes(searchTermLower) + ) .slice(0, 50); return result; @@ -299,9 +322,11 @@ ipcMain.handle( async (event, searchTerm: string, types: string[]) => { try { const db = await getDatabase(); - const searchTermLower = searchTerm.toLowerCase(); - const escapedTerm = escapeLikePattern(searchTerm); - const likePattern = `%${escapedTerm}%`; + const searchTermLower = searchTerm.toLocaleLowerCase(); + const likePatterns = buildLikePatterns(searchTerm); + const likeConditions = likePatterns.map( + pattern => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + ); // Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code). // This avoids loading the entire content table into JS memory. @@ -335,7 +360,7 @@ ipcMain.handle( schema.content.type, types as Array<'live' | 'movie' | 'series'> ), - sql`${schema.content.title} LIKE ${likePattern}` + or(...likeConditions) ) ) .orderBy(schema.content.title) @@ -344,7 +369,9 @@ ipcMain.handle( // Post-filter for proper Unicode case-insensitive matching // (SQLite LIKE is only case-insensitive for ASCII) const result = candidates - .filter(item => item.title?.toLowerCase().includes(searchTermLower)) + .filter(item => + item.title?.toLocaleLowerCase().includes(searchTermLower) + ) .slice(0, 50); return result;