import 'package:sqflite/sqflite.dart'; enum LibrarySearchKind { songs, albums, artists, playlists } /// Each word must match, but words can occur in different metadata fields and /// in any order. Punctuation is a separator, never a SQL/FTS operator. class LibrarySearchQuery { LibrarySearchQuery(String text) : text = text.trim().toLowerCase(), terms = RegExp(r'[\p{L}\p{N}]+', unicode: true) .allMatches(text.toLowerCase()) .map((match) => match.group(0)!) .toSet() .toList(growable: false); final String text; final List terms; bool matches(String value) { final normalized = value.toLowerCase(); return terms.isNotEmpty && terms.every(normalized.contains); } int rank(String value) { final normalized = value.trim().toLowerCase(); if (normalized == text) return 0; if (normalized.startsWith(text)) return 1; if (normalized.contains(text)) return 2; return 3; } String predicate( String column, List args, { String? rowId, String? ftsTable, String? ftsContentTable, }) { if (terms.isEmpty) return '0'; final indexed = ftsTable == null ? const [] : terms.where((term) => term.runes.length >= 3).toList(); final predicates = []; if (indexed.isNotEmpty) { final tableName = ftsTable!.split('.').last; final matches = 'SELECT rowid FROM $ftsTable WHERE $tableName MATCH ?'; predicates.add( '$rowId IN (${ftsContentTable == null ? matches : 'SELECT id FROM $ftsContentTable WHERE rowid IN ($matches)'})', ); args.add(indexed.map((term) => '"$term"').join(' AND ')); } for (final term in terms) { if (indexed.contains(term)) continue; predicates.add("$column LIKE ? ESCAPE '\\'"); args.add('%$term%'); } return predicates.join(' AND '); } String orderBy(String column, List args) { final escaped = text .replaceAll('\\', '\\\\') .replaceAll('%', '\\%') .replaceAll('_', '\\_'); args.addAll([text, '$escaped%', '%$escaped%']); return '''CASE WHEN $column = ? THEN 0 WHEN $column LIKE ? ESCAPE '\\' THEN 1 WHEN $column LIKE ? ESCAPE '\\' THEN 2 ELSE 3 END'''; } } class LibrarySearchHit { const LibrarySearchHit({ required this.kind, required this.id, required this.title, this.artist = '', this.source = '', this.cover, this.samplePath = '', this.trackCount = 0, }); final LibrarySearchKind kind; final String id; final String title; final String artist; final String source; final String? cover; final String samplePath; final int trackCount; factory LibrarySearchHit.fromRow( LibrarySearchKind kind, Map row, ) => LibrarySearchHit( kind: kind, id: row['id'] as String, title: row['title'] as String? ?? '', artist: row['artist'] as String? ?? '', source: row['source'] as String? ?? '', cover: row['cover'] as String?, samplePath: row['sample_path'] as String? ?? '', trackCount: (row['track_count'] as num?)?.toInt() ?? 0, ); } /// Runs bounded, ranked searches in SQLite instead of copying the Library into /// Dart. The caller supplies the Library connection with history_db attached. class LibrarySearchStore { const LibrarySearchStore( this.db, { required this.historyFts, required this.localFts, }); final DatabaseExecutor db; final bool historyFts; final bool localFts; Future> search({ required String query, required LibrarySearchKind kind, required bool includeLocal, int limit = 40, int offset = 0, }) async { final search = LibrarySearchQuery(query); if (search.terms.isEmpty || kind == LibrarySearchKind.playlists) return []; final args = []; final parts = []; for (final local in [false, if (includeLocal) true]) { final alias = local ? 'l' : 'h'; final source = local ? 'local' : 'downloaded'; final table = local ? 'library_visible' : 'history_db.history'; final artist = local ? 'album_artist_norm' : 'sort_album_artist'; final album = local ? 'album_name_norm' : 'sort_album'; final title = local ? 'track_name_norm' : 'sort_track'; final where = [ if (local) '''NOT EXISTS ( SELECT 1 FROM library_path_keys lpk JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key WHERE lpk.item_id = l.id )''', ]; final column = switch (kind) { LibrarySearchKind.songs => '$alias.search_text', LibrarySearchKind.albums => "$alias.$album || ' ' || $alias.$artist", LibrarySearchKind.artists => '$alias.$artist', LibrarySearchKind.playlists => throw StateError('Not a track index'), }; where.add( search.predicate( column, args, // Views do not expose their underlying table's implicit rowid. rowId: local ? '$alias.id' : '$alias.rowid', ftsContentTable: local ? 'library' : null, ftsTable: kind != LibrarySearchKind.songs ? null : local ? (localFts ? 'library_search_fts' : null) : (historyFts ? 'history_db.history_search_fts' : null), ), ); parts.add(''' SELECT '$source' AS source, $alias.id, $alias.track_name AS title, $alias.artist_name AS artist, $alias.album_name AS album, COALESCE(NULLIF($alias.album_artist, ''), $alias.artist_name) AS album_artist, $alias.album_key, $alias.$title AS title_key, $alias.$album AS album_sort, $alias.$artist AS artist_key, ${local ? '$alias.cover_path' : '$alias.cover_url'} AS cover, $alias.file_path AS sample_path FROM $table $alias WHERE ${where.join(' AND ')} '''); } final rows = '(${parts.join(' UNION ALL ')})'; // Album predicates use only album-level fields, preserving full track // counts. Keep downloaded/local albums separate, as their detail screens // resolve tracks from different stores. Artists merge both sources. final select = switch (kind) { LibrarySearchKind.songs => 'SELECT *, 1 AS track_count FROM $rows', LibrarySearchKind.albums => ''' SELECT source, album_key AS id, MIN(album) AS title, MIN(album_artist) AS artist, MAX(NULLIF(cover, '')) AS cover, MAX(sample_path) AS sample_path, COUNT(*) AS track_count, MIN(album_sort) AS title_key FROM $rows WHERE album_sort != '' GROUP BY source, album_key''', LibrarySearchKind.artists => ''' SELECT '' AS source, artist_key AS id, MIN(album_artist) AS title, MIN(album_artist) AS artist, MAX(NULLIF(cover, '')) AS cover, MAX(sample_path) AS sample_path, COUNT(*) AS track_count, artist_key AS title_key FROM $rows WHERE artist_key != '' GROUP BY artist_key''', LibrarySearchKind.playlists => throw StateError('Not a track index'), }; final rank = search.orderBy('title_key', args); final result = await db.rawQuery( ''' SELECT * FROM ($select) ORDER BY $rank, title_key, artist, source, id LIMIT ? OFFSET ? ''', [...args, limit, offset], ); return result.map((row) => LibrarySearchHit.fromRow(kind, row)).toList(); } }