Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6661375f99 | ||
|
|
d6e759f1cc | ||
|
|
8a38516715 | ||
|
|
aaeb2109f6 | ||
|
|
f65e76e858 | ||
|
|
252395d164 | ||
|
|
f36d108878 | ||
|
|
8e548daf0c | ||
|
|
b15f10ade9 | ||
|
|
b9c7bc0e2a |
+10
-3
@@ -229,10 +229,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
ColorScheme? darkColorScheme;
|
ColorScheme? darkColorScheme;
|
||||||
|
|
||||||
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
||||||
// Use system color schemes
|
// Use system color schemes, but override background to match app design
|
||||||
|
// Material You doesn't set background consistently, causing black screen issues
|
||||||
final (light, dark) = snapshot.data!;
|
final (light, dark) = snapshot.data!;
|
||||||
lightColorScheme = light;
|
lightColorScheme = light.copyWith(
|
||||||
darkColorScheme = dark;
|
surface: light.surface,
|
||||||
|
background: const Color(0xFFFAFAFA), // App's preferred light background
|
||||||
|
);
|
||||||
|
darkColorScheme = dark.copyWith(
|
||||||
|
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
|
||||||
|
background: const Color(0xFF1a1a1a), // App's preferred dark background
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Use custom color from theme provider
|
// Use custom color from theme provider
|
||||||
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||||
|
|||||||
@@ -2416,17 +2416,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('📻 Found artist from artists array: $artistName');
|
_logger.log('📻 Found artist from artists array: $artistName');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for stream_title which might contain "Artist - Title" format
|
// Check for stream_title which contains the actual now-playing metadata
|
||||||
|
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
|
||||||
final streamTitle = currentMedia['stream_title'] as String?;
|
final streamTitle = currentMedia['stream_title'] as String?;
|
||||||
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
|
if (streamTitle != null && streamTitle.isNotEmpty) {
|
||||||
final parts = streamTitle.split(' - ');
|
if (streamTitle.contains(' - ')) {
|
||||||
if (parts.length >= 2) {
|
// Parse "Artist - Title" format
|
||||||
artistName = parts[0].trim();
|
final parts = streamTitle.split(' - ');
|
||||||
// If trackTitle is still the default or matches the stream_title, update it too
|
if (parts.length >= 2) {
|
||||||
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
|
artistName = parts[0].trim();
|
||||||
trackTitle = parts.sublist(1).join(' - ').trim();
|
trackTitle = parts.sublist(1).join(' - ').trim();
|
||||||
|
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
|
||||||
}
|
}
|
||||||
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
|
} else {
|
||||||
|
// No separator, use stream_title as the title
|
||||||
|
trackTitle = streamTitle;
|
||||||
|
_logger.log('📻 Using stream_title as title: $trackTitle');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2453,14 +2458,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Only cache if we don't already have better data from queue
|
// Only cache if we don't already have better data from queue
|
||||||
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
||||||
|
// Check if existing track has proper artist (not Unknown Artist and not empty)
|
||||||
|
final existingArtist = existingTrack?.artistsString;
|
||||||
final existingHasProperArtist = existingTrack != null &&
|
final existingHasProperArtist = existingTrack != null &&
|
||||||
existingTrack.artistsString != 'Unknown Artist' &&
|
existingArtist != null &&
|
||||||
!existingTrack.name.contains(' - ');
|
existingArtist != 'Unknown Artist' &&
|
||||||
|
existingArtist.trim().isNotEmpty;
|
||||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||||
final newHasImage = metadata != null;
|
final newHasImage = metadata != null;
|
||||||
final newHasAlbum = albumName != null;
|
final newHasAlbum = albumName != null;
|
||||||
final existingHasAlbum = existingTrack?.album != null;
|
final existingHasAlbum = existingTrack?.album != null;
|
||||||
final newHasProperArtist = artistName != 'Unknown Artist';
|
// Check if new data has proper artist AND title (not malformed like "- Something")
|
||||||
|
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
|
||||||
|
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
|
||||||
|
|
||||||
// For podcasts, album info is crucial (it's the podcast name)
|
// For podcasts, album info is crucial (it's the podcast name)
|
||||||
// If new track has album but existing doesn't, prefer new or merge
|
// If new track has album but existing doesn't, prefer new or merge
|
||||||
@@ -2511,13 +2521,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||||
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
|
// Update _currentTrack if:
|
||||||
|
// - We don't have it yet
|
||||||
|
// - Podcast with new album data
|
||||||
|
// - Radio with new stream metadata (artist changed from Unknown)
|
||||||
|
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
|
||||||
|
_currentTrack?.artistsString == null;
|
||||||
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
|
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
|
||||||
playerId == _selectedPlayer!.playerId &&
|
playerId == _selectedPlayer!.playerId &&
|
||||||
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
|
(_currentTrack == null ||
|
||||||
|
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
|
||||||
|
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
|
||||||
if (shouldUpdateCurrentTrack) {
|
if (shouldUpdateCurrentTrack) {
|
||||||
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
|
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
|
||||||
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
|
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -2554,15 +2571,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for stream_title with "Artist - Title" format
|
// Check for stream_title which contains the actual now-playing metadata
|
||||||
final streamTitle = currentMedia['stream_title'] as String?;
|
final streamTitle = currentMedia['stream_title'] as String?;
|
||||||
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
|
if (streamTitle != null && streamTitle.isNotEmpty) {
|
||||||
final parts = streamTitle.split(' - ');
|
if (streamTitle.contains(' - ')) {
|
||||||
if (parts.length >= 2) {
|
// Parse "Artist - Title" format
|
||||||
artist = parts[0].trim();
|
final parts = streamTitle.split(' - ');
|
||||||
if (title == 'Unknown Track' || title == streamTitle) {
|
if (parts.length >= 2) {
|
||||||
|
artist = parts[0].trim();
|
||||||
title = parts.sublist(1).join(' - ').trim();
|
title = parts.sublist(1).join(' - ').trim();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No separator, use stream_title as the title
|
||||||
|
title = streamTitle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-146
@@ -15,6 +15,7 @@ import '../widgets/artist_avatar.dart';
|
|||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../utils/search/search_scoring.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
@@ -128,6 +129,9 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// PERF: Cache available filters to avoid recalculating during build
|
// PERF: Cache available filters to avoid recalculating during build
|
||||||
List<String>? _cachedAvailableFilters;
|
List<String>? _cachedAvailableFilters;
|
||||||
|
|
||||||
|
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
||||||
|
final SearchScorer _searchScorer = SearchScorer();
|
||||||
|
|
||||||
// Scroll-to-hide search bar (vertical scroll only)
|
// Scroll-to-hide search bar (vertical scroll only)
|
||||||
bool _isSearchBarVisible = true;
|
bool _isSearchBarVisible = true;
|
||||||
double _lastVerticalScrollOffset = 0;
|
double _lastVerticalScrollOffset = 0;
|
||||||
@@ -750,151 +754,15 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate relevance score for a media item based on query match
|
/// Calculate relevance score for a media item based on query match.
|
||||||
|
///
|
||||||
|
/// Uses advanced scoring with:
|
||||||
|
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||||
|
/// - Fuzzy matching (typo tolerance via Jaro-Winkler)
|
||||||
|
/// - N-gram matching (partial matches)
|
||||||
|
/// - Reverse matching (query contains result name)
|
||||||
double _calculateRelevanceScore(MediaItem item, String query) {
|
double _calculateRelevanceScore(MediaItem item, String query) {
|
||||||
final queryLower = query.toLowerCase().trim();
|
return _searchScorer.scoreItem(item, query);
|
||||||
if (queryLower.isEmpty) return 0;
|
|
||||||
|
|
||||||
double score = 0;
|
|
||||||
final nameLower = item.name.toLowerCase();
|
|
||||||
|
|
||||||
// Primary name matching
|
|
||||||
if (nameLower == queryLower) {
|
|
||||||
score = 100; // Exact match
|
|
||||||
} else if (nameLower.startsWith(queryLower)) {
|
|
||||||
score = 80; // Starts with query
|
|
||||||
} else if (_matchesWordBoundary(nameLower, queryLower)) {
|
|
||||||
score = 60; // Contains query at word boundary
|
|
||||||
} else if (nameLower.contains(queryLower)) {
|
|
||||||
score = 40; // Contains query anywhere
|
|
||||||
} else {
|
|
||||||
score = 20; // Fuzzy/partial match (MA returned it, so some relevance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bonus for library items (Album has inLibrary property)
|
|
||||||
if (item is Album && item.inLibrary) {
|
|
||||||
score += 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bonus for favorites
|
|
||||||
if (item.favorite == true) {
|
|
||||||
score += 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secondary field matching (artist/author/creator name)
|
|
||||||
if (item is Album) {
|
|
||||||
final artistLower = item.artistsString.toLowerCase();
|
|
||||||
if (artistLower == queryLower) {
|
|
||||||
score += 15; // Artist exact match
|
|
||||||
} else if (artistLower.contains(queryLower)) {
|
|
||||||
score += 8; // Artist contains query
|
|
||||||
}
|
|
||||||
} else if (item is Track) {
|
|
||||||
final artistLower = item.artistsString.toLowerCase();
|
|
||||||
if (artistLower == queryLower) {
|
|
||||||
score += 15;
|
|
||||||
} else if (artistLower.contains(queryLower)) {
|
|
||||||
score += 8;
|
|
||||||
}
|
|
||||||
// Also check album name for tracks
|
|
||||||
if (item.album?.name != null) {
|
|
||||||
final albumLower = item.album!.name.toLowerCase();
|
|
||||||
if (albumLower.contains(queryLower)) {
|
|
||||||
score += 5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (item is Audiobook) {
|
|
||||||
// Check audiobook author names
|
|
||||||
final authorLower = item.authorsString.toLowerCase();
|
|
||||||
if (authorLower == queryLower) {
|
|
||||||
score += 15; // Author exact match
|
|
||||||
} else if (authorLower.contains(queryLower)) {
|
|
||||||
score += 8; // Author contains query
|
|
||||||
}
|
|
||||||
// Also check narrator names
|
|
||||||
final narratorLower = item.narratorsString.toLowerCase();
|
|
||||||
if (narratorLower.contains(queryLower)) {
|
|
||||||
score += 5;
|
|
||||||
}
|
|
||||||
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
|
||||||
// Check podcast metadata for author/creator
|
|
||||||
final metadata = item.metadata;
|
|
||||||
bool foundCreatorMatch = false;
|
|
||||||
if (metadata != null) {
|
|
||||||
final author = (metadata['author'] as String? ?? '').toLowerCase();
|
|
||||||
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
|
|
||||||
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
|
|
||||||
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
|
|
||||||
|
|
||||||
// Check all possible creator fields
|
|
||||||
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
|
|
||||||
bool foundExact = false;
|
|
||||||
bool foundContains = false;
|
|
||||||
for (final field in creatorFields) {
|
|
||||||
if (field == queryLower) {
|
|
||||||
foundExact = true;
|
|
||||||
break;
|
|
||||||
} else if (field.contains(queryLower)) {
|
|
||||||
foundContains = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (foundExact) {
|
|
||||||
score += 15; // Creator exact match
|
|
||||||
foundCreatorMatch = true;
|
|
||||||
} else if (foundContains) {
|
|
||||||
score += 8; // Creator contains query
|
|
||||||
foundCreatorMatch = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check description for keyword matches
|
|
||||||
final description = (metadata['description'] as String? ?? '').toLowerCase();
|
|
||||||
if (description.contains(queryLower)) {
|
|
||||||
score += 5; // Increased from 3 to match other media types
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: If no creator metadata matched, but query matches strongly in name,
|
|
||||||
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
|
|
||||||
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
|
|
||||||
// Multi-word query that matches in name suggests creator/host name
|
|
||||||
if (queryLower.contains(' ')) {
|
|
||||||
// Calculate how prominent the query is in the name
|
|
||||||
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
|
|
||||||
final prominence = queryLower.length / nameLower.length;
|
|
||||||
if (prominence >= 0.5) {
|
|
||||||
score += 15; // Query is >50% of name - very strong signal
|
|
||||||
} else if (prominence >= 0.3) {
|
|
||||||
score += 12; // Query is 30-50% of name - strong signal
|
|
||||||
} else {
|
|
||||||
score += 8; // Query in name but less prominent
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
score += 5; // Single word match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if query matches at a word boundary in text
|
|
||||||
/// Handles both single-word and multi-word queries
|
|
||||||
bool _matchesWordBoundary(String text, String query) {
|
|
||||||
// For multi-word queries, check if query appears at start of a word in text
|
|
||||||
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
|
|
||||||
if (query.contains(' ')) {
|
|
||||||
// Check if text starts with query or contains query after whitespace
|
|
||||||
if (text.startsWith(query)) return true;
|
|
||||||
if (text.contains(' $query')) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For single-word queries, check individual words
|
|
||||||
final words = text.split(RegExp(r'\s+'));
|
|
||||||
for (final word in words) {
|
|
||||||
if (word.startsWith(query)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract artists from tracks/albums that match the query but aren't in direct results
|
/// Extract artists from tracks/albums that match the query but aren't in direct results
|
||||||
@@ -907,8 +775,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
) {
|
) {
|
||||||
if (query.isEmpty) return [];
|
if (query.isEmpty) return [];
|
||||||
|
|
||||||
final queryLower = query.toLowerCase();
|
// Use normalizer to get tokens with stopwords removed
|
||||||
final queryWords = queryLower.split(RegExp(r'\s+'));
|
// This way "the Beatles" becomes ["beatles"] for better matching
|
||||||
|
final normalizedQuery = _searchScorer.normalizer.normalizeQuery(query);
|
||||||
|
final queryWords = normalizedQuery.tokensNoStop;
|
||||||
|
|
||||||
// Create a set of existing artist identifiers to avoid duplicates
|
// Create a set of existing artist identifiers to avoid duplicates
|
||||||
final existingArtistKeys = <String>{};
|
final existingArtistKeys = <String>{};
|
||||||
@@ -942,6 +812,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter candidates: only include if artist name contains any query word
|
// Filter candidates: only include if artist name contains any query word
|
||||||
|
// (stopwords already removed from queryWords)
|
||||||
final crossRefArtists = <Artist>[];
|
final crossRefArtists = <Artist>[];
|
||||||
for (final artist in candidateArtists.values) {
|
for (final artist in candidateArtists.values) {
|
||||||
final artistLower = artist.name.toLowerCase();
|
final artistLower = artist.name.toLowerCase();
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ class AuthManager {
|
|||||||
host.startsWith('172.31.') ||
|
host.startsWith('172.31.') ||
|
||||||
host == 'localhost' ||
|
host == 'localhost' ||
|
||||||
host.startsWith('127.') ||
|
host.startsWith('127.') ||
|
||||||
host.endsWith('.local');
|
host.endsWith('.local') ||
|
||||||
|
host.endsWith('.ts.net');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to detect auth strategy for a given URL
|
/// Try to detect auth strategy for a given URL
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// Fuzzy string matching using Jaro-Winkler similarity.
|
||||||
|
///
|
||||||
|
/// Jaro-Winkler is optimized for short strings like artist/song names
|
||||||
|
/// and gives extra weight to matching prefixes (common typos occur mid-word).
|
||||||
|
class FuzzyMatcher {
|
||||||
|
/// Calculate Jaro-Winkler similarity between two strings.
|
||||||
|
///
|
||||||
|
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
|
||||||
|
///
|
||||||
|
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
|
||||||
|
/// which is useful for handling typos like "Beetles" vs "Beatles".
|
||||||
|
double jaroWinklerSimilarity(String s1, String s2) {
|
||||||
|
if (s1 == s2) return 1.0;
|
||||||
|
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final jaro = _jaroSimilarity(s1, s2);
|
||||||
|
|
||||||
|
// Winkler modification: boost score for common prefix
|
||||||
|
int prefixLength = 0;
|
||||||
|
const maxPrefix = 4; // Standard Winkler prefix length
|
||||||
|
final minLen = min(s1.length, s2.length);
|
||||||
|
final prefixLimit = min(maxPrefix, minLen);
|
||||||
|
|
||||||
|
for (int i = 0; i < prefixLimit; i++) {
|
||||||
|
if (s1[i] == s2[i]) {
|
||||||
|
prefixLength++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalingFactor = 0.1; // Standard Winkler scaling factor
|
||||||
|
return jaro + (prefixLength * scalingFactor * (1 - jaro));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate base Jaro similarity.
|
||||||
|
double _jaroSimilarity(String s1, String s2) {
|
||||||
|
final s1Len = s1.length;
|
||||||
|
final s2Len = s2.length;
|
||||||
|
|
||||||
|
// Match window: characters within this distance can match
|
||||||
|
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
|
||||||
|
if (matchWindow < 0) return 0.0;
|
||||||
|
|
||||||
|
final s1Matches = List.filled(s1Len, false);
|
||||||
|
final s2Matches = List.filled(s2Len, false);
|
||||||
|
|
||||||
|
int matches = 0;
|
||||||
|
int transpositions = 0;
|
||||||
|
|
||||||
|
// Find matching characters
|
||||||
|
for (int i = 0; i < s1Len; i++) {
|
||||||
|
final start = max(0, i - matchWindow);
|
||||||
|
final end = min(s2Len, i + matchWindow + 1);
|
||||||
|
|
||||||
|
for (int j = start; j < end; j++) {
|
||||||
|
if (s2Matches[j] || s1[i] != s2[j]) continue;
|
||||||
|
s1Matches[i] = true;
|
||||||
|
s2Matches[j] = true;
|
||||||
|
matches++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches == 0) return 0.0;
|
||||||
|
|
||||||
|
// Count transpositions
|
||||||
|
int k = 0;
|
||||||
|
for (int i = 0; i < s1Len; i++) {
|
||||||
|
if (!s1Matches[i]) continue;
|
||||||
|
while (!s2Matches[k]) {
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
if (s1[i] != s2[k]) transpositions++;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jaro similarity formula
|
||||||
|
return (matches / s1Len +
|
||||||
|
matches / s2Len +
|
||||||
|
(matches - transpositions / 2) / matches) /
|
||||||
|
3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the best match score between query tokens and text tokens.
|
||||||
|
///
|
||||||
|
/// Useful for matching individual words when full string matching fails.
|
||||||
|
/// Returns the highest similarity score found between any token pair.
|
||||||
|
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||||
|
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
double bestScore = 0.0;
|
||||||
|
|
||||||
|
for (final queryToken in queryTokens) {
|
||||||
|
for (final textToken in textTokens) {
|
||||||
|
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
// Early exit if we find a perfect match
|
||||||
|
if (score == 1.0) return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if two strings are a fuzzy match within a threshold.
|
||||||
|
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
|
||||||
|
return jaroWinklerSimilarity(s1, s2) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate average token match score.
|
||||||
|
///
|
||||||
|
/// For each query token, finds the best matching text token,
|
||||||
|
/// then averages all best matches. Useful for multi-word queries.
|
||||||
|
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||||
|
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
double totalScore = 0.0;
|
||||||
|
|
||||||
|
for (final queryToken in queryTokens) {
|
||||||
|
double bestForToken = 0.0;
|
||||||
|
for (final textToken in textTokens) {
|
||||||
|
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||||
|
if (score > bestForToken) {
|
||||||
|
bestForToken = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalScore += bestForToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalScore / queryTokens.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/// N-gram based string matching for partial matches.
|
||||||
|
///
|
||||||
|
/// Uses character bigrams (2-grams) to find partial string matches
|
||||||
|
/// when exact and fuzzy matching fail.
|
||||||
|
class NgramMatcher {
|
||||||
|
/// Generate character bigrams from text.
|
||||||
|
///
|
||||||
|
/// Example: "hello" -> ["he", "el", "ll", "lo"]
|
||||||
|
///
|
||||||
|
/// For strings shorter than 2 characters, returns the string itself.
|
||||||
|
List<String> generateBigrams(String text) {
|
||||||
|
if (text.length < 2) return text.isNotEmpty ? [text] : [];
|
||||||
|
|
||||||
|
final bigrams = <String>[];
|
||||||
|
for (int i = 0; i < text.length - 1; i++) {
|
||||||
|
bigrams.add(text.substring(i, i + 2));
|
||||||
|
}
|
||||||
|
return bigrams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate character trigrams from text.
|
||||||
|
///
|
||||||
|
/// Example: "hello" -> ["hel", "ell", "llo"]
|
||||||
|
List<String> generateTrigrams(String text) {
|
||||||
|
if (text.length < 3) return text.isNotEmpty ? [text] : [];
|
||||||
|
|
||||||
|
final trigrams = <String>[];
|
||||||
|
for (int i = 0; i < text.length - 2; i++) {
|
||||||
|
trigrams.add(text.substring(i, i + 3));
|
||||||
|
}
|
||||||
|
return trigrams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate bigram similarity using Dice coefficient.
|
||||||
|
///
|
||||||
|
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
|
||||||
|
/// Returns a value between 0.0 and 1.0.
|
||||||
|
double bigramSimilarity(String s1, String s2) {
|
||||||
|
final lower1 = s1.toLowerCase();
|
||||||
|
final lower2 = s2.toLowerCase();
|
||||||
|
|
||||||
|
final bigrams1 = generateBigrams(lower1);
|
||||||
|
final bigrams2 = generateBigrams(lower2);
|
||||||
|
|
||||||
|
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final set1 = bigrams1.toSet();
|
||||||
|
final set2 = bigrams2.toSet();
|
||||||
|
|
||||||
|
final intersection = set1.intersection(set2).length;
|
||||||
|
|
||||||
|
// Dice coefficient
|
||||||
|
return (2 * intersection) / (set1.length + set2.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Jaccard similarity between bigram sets.
|
||||||
|
///
|
||||||
|
/// Jaccard: |intersection| / |union|
|
||||||
|
/// More conservative than Dice coefficient.
|
||||||
|
double jaccardSimilarity(String s1, String s2) {
|
||||||
|
final lower1 = s1.toLowerCase();
|
||||||
|
final lower2 = s2.toLowerCase();
|
||||||
|
|
||||||
|
final bigrams1 = generateBigrams(lower1).toSet();
|
||||||
|
final bigrams2 = generateBigrams(lower2).toSet();
|
||||||
|
|
||||||
|
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final intersection = bigrams1.intersection(bigrams2).length;
|
||||||
|
final union = bigrams1.union(bigrams2).length;
|
||||||
|
|
||||||
|
return intersection / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if query partially matches text via bigrams.
|
||||||
|
///
|
||||||
|
/// Uses Dice coefficient with configurable threshold.
|
||||||
|
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
|
||||||
|
return bigramSimilarity(query, text) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate containment coefficient.
|
||||||
|
///
|
||||||
|
/// Measures how many of the query's bigrams are in the text.
|
||||||
|
/// Useful when query is expected to be shorter than text.
|
||||||
|
/// Returns |intersection| / |query bigrams|
|
||||||
|
double containmentScore(String query, String text) {
|
||||||
|
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
|
||||||
|
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
|
||||||
|
|
||||||
|
if (queryBigrams.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
final intersection = queryBigrams.intersection(textBigrams).length;
|
||||||
|
return intersection / queryBigrams.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/// Configuration for search scoring weights and thresholds.
|
||||||
|
///
|
||||||
|
/// All scores are designed to work with the existing sorting system
|
||||||
|
/// where higher scores indicate better matches.
|
||||||
|
class ScoringConfig {
|
||||||
|
// Base scores for match types (primary name matching)
|
||||||
|
final double exactMatch;
|
||||||
|
final double exactMatchNoStopwords;
|
||||||
|
final double startsWithMatch;
|
||||||
|
final double startsWithNoStopwords;
|
||||||
|
final double wordBoundaryMatch;
|
||||||
|
final double wordBoundaryNoStopwords;
|
||||||
|
final double reverseContainsMatch;
|
||||||
|
final double reverseContainsNoStopwords;
|
||||||
|
final double containsMatch;
|
||||||
|
final double containsNoStopwords;
|
||||||
|
final double fuzzyMatchHigh;
|
||||||
|
final double fuzzyMatchMedium;
|
||||||
|
final double ngramMatch;
|
||||||
|
final double baseline;
|
||||||
|
|
||||||
|
// Bonuses (additive)
|
||||||
|
final double libraryBonus;
|
||||||
|
final double favoriteBonus;
|
||||||
|
final double artistFieldExactBonus;
|
||||||
|
final double artistFieldPartialBonus;
|
||||||
|
final double albumFieldBonus;
|
||||||
|
final double authorFieldExactBonus;
|
||||||
|
final double authorFieldPartialBonus;
|
||||||
|
final double narratorFieldBonus;
|
||||||
|
final double creatorFieldExactBonus;
|
||||||
|
final double creatorFieldPartialBonus;
|
||||||
|
final double descriptionBonus;
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
final double fuzzyHighThreshold;
|
||||||
|
final double fuzzyMediumThreshold;
|
||||||
|
final double ngramThreshold;
|
||||||
|
|
||||||
|
// Minimum lengths for matching
|
||||||
|
final int minReverseMatchLength;
|
||||||
|
final int minTokenLength;
|
||||||
|
|
||||||
|
const ScoringConfig({
|
||||||
|
// Primary match scores
|
||||||
|
this.exactMatch = 100,
|
||||||
|
this.exactMatchNoStopwords = 95,
|
||||||
|
this.startsWithMatch = 85,
|
||||||
|
this.startsWithNoStopwords = 80,
|
||||||
|
this.wordBoundaryMatch = 70,
|
||||||
|
this.wordBoundaryNoStopwords = 65,
|
||||||
|
this.reverseContainsMatch = 60,
|
||||||
|
this.reverseContainsNoStopwords = 55,
|
||||||
|
this.containsMatch = 50,
|
||||||
|
this.containsNoStopwords = 45,
|
||||||
|
this.fuzzyMatchHigh = 40,
|
||||||
|
this.fuzzyMatchMedium = 35,
|
||||||
|
this.ngramMatch = 25,
|
||||||
|
this.baseline = 20,
|
||||||
|
|
||||||
|
// Bonuses
|
||||||
|
this.libraryBonus = 10,
|
||||||
|
this.favoriteBonus = 5,
|
||||||
|
this.artistFieldExactBonus = 15,
|
||||||
|
this.artistFieldPartialBonus = 8,
|
||||||
|
this.albumFieldBonus = 5,
|
||||||
|
this.authorFieldExactBonus = 15,
|
||||||
|
this.authorFieldPartialBonus = 8,
|
||||||
|
this.narratorFieldBonus = 5,
|
||||||
|
this.creatorFieldExactBonus = 15,
|
||||||
|
this.creatorFieldPartialBonus = 8,
|
||||||
|
this.descriptionBonus = 5,
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
this.fuzzyHighThreshold = 0.90,
|
||||||
|
this.fuzzyMediumThreshold = 0.85,
|
||||||
|
this.ngramThreshold = 0.5,
|
||||||
|
|
||||||
|
// Minimum lengths
|
||||||
|
this.minReverseMatchLength = 3,
|
||||||
|
this.minTokenLength = 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Default configuration
|
||||||
|
static const ScoringConfig defaults = ScoringConfig();
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/// Search scoring system for ranking search results.
|
||||||
|
///
|
||||||
|
/// Provides advanced text matching including:
|
||||||
|
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||||
|
/// - Fuzzy matching (typo tolerance)
|
||||||
|
/// - N-gram matching (partial matches)
|
||||||
|
/// - Reverse matching (query contains result)
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'scoring_config.dart';
|
||||||
|
export 'text_normalizer.dart';
|
||||||
|
export 'fuzzy_matcher.dart';
|
||||||
|
export 'ngram_matcher.dart';
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import '../../models/media_item.dart';
|
||||||
|
import 'scoring_config.dart';
|
||||||
|
import 'text_normalizer.dart';
|
||||||
|
import 'fuzzy_matcher.dart';
|
||||||
|
import 'ngram_matcher.dart';
|
||||||
|
|
||||||
|
/// Main search scorer that combines all matching strategies.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final scorer = SearchScorer();
|
||||||
|
/// final score = scorer.scoreItem(mediaItem, query);
|
||||||
|
/// ```
|
||||||
|
class SearchScorer {
|
||||||
|
final TextNormalizer _normalizer;
|
||||||
|
final FuzzyMatcher _fuzzyMatcher;
|
||||||
|
final NgramMatcher _ngramMatcher;
|
||||||
|
final ScoringConfig _config;
|
||||||
|
|
||||||
|
// Cache for normalized query (reused across items in same search)
|
||||||
|
String? _cachedQueryString;
|
||||||
|
NormalizedQuery? _cachedQuery;
|
||||||
|
|
||||||
|
SearchScorer({ScoringConfig? config})
|
||||||
|
: _normalizer = TextNormalizer(),
|
||||||
|
_fuzzyMatcher = FuzzyMatcher(),
|
||||||
|
_ngramMatcher = NgramMatcher(),
|
||||||
|
_config = config ?? ScoringConfig.defaults;
|
||||||
|
|
||||||
|
/// Access to the normalizer for external use
|
||||||
|
TextNormalizer get normalizer => _normalizer;
|
||||||
|
|
||||||
|
/// Clear the query cache (call when starting a new search)
|
||||||
|
void clearCache() {
|
||||||
|
_cachedQueryString = null;
|
||||||
|
_cachedQuery = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a media item against a search query.
|
||||||
|
///
|
||||||
|
/// Returns a score where higher values indicate better matches.
|
||||||
|
/// Score components:
|
||||||
|
/// - Primary name matching (0-100)
|
||||||
|
/// - Secondary field bonuses (0-20)
|
||||||
|
/// - Library/favorite bonuses (0-15)
|
||||||
|
double scoreItem(MediaItem item, String query) {
|
||||||
|
// Use cached normalized query if available
|
||||||
|
if (_cachedQueryString != query) {
|
||||||
|
_cachedQueryString = query;
|
||||||
|
_cachedQuery = _normalizer.normalizeQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
final nq = _cachedQuery!;
|
||||||
|
if (nq.isEmpty) return 0;
|
||||||
|
|
||||||
|
final nameLower = item.name.toLowerCase();
|
||||||
|
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
|
||||||
|
|
||||||
|
// Calculate primary score based on name matching
|
||||||
|
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
|
||||||
|
|
||||||
|
// Add secondary field bonuses based on media type
|
||||||
|
score += _calculateSecondaryScore(item, nq);
|
||||||
|
|
||||||
|
// Add library/favorite bonuses
|
||||||
|
score += _calculateBonuses(item);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate primary score based on name matching.
|
||||||
|
double _calculatePrimaryScore(
|
||||||
|
String nameLower,
|
||||||
|
String nameNoStopwords,
|
||||||
|
NormalizedQuery nq,
|
||||||
|
) {
|
||||||
|
// Tier 1: Exact match (highest priority)
|
||||||
|
if (nameLower == nq.normalized) {
|
||||||
|
return _config.exactMatch;
|
||||||
|
}
|
||||||
|
if (nameNoStopwords == nq.withoutStopwords) {
|
||||||
|
return _config.exactMatchNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: Starts with
|
||||||
|
if (nameLower.startsWith(nq.normalized)) {
|
||||||
|
return _config.startsWithMatch;
|
||||||
|
}
|
||||||
|
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
|
||||||
|
return _config.startsWithNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: Word boundary match
|
||||||
|
if (_matchesWordBoundary(nameLower, nq.normalized)) {
|
||||||
|
return _config.wordBoundaryMatch;
|
||||||
|
}
|
||||||
|
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
|
||||||
|
return _config.wordBoundaryNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 4: Reverse contains (result name IN query)
|
||||||
|
// This solves "the ramones" finding "Ramones"
|
||||||
|
if (_reverseContains(nq.normalized, nameLower)) {
|
||||||
|
return _config.reverseContainsMatch;
|
||||||
|
}
|
||||||
|
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
|
||||||
|
return _config.reverseContainsNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 5: Contains anywhere
|
||||||
|
if (nameLower.contains(nq.normalized)) {
|
||||||
|
return _config.containsMatch;
|
||||||
|
}
|
||||||
|
if (nameNoStopwords.contains(nq.withoutStopwords)) {
|
||||||
|
return _config.containsNoStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 6: Fuzzy matching (typo tolerance)
|
||||||
|
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
|
||||||
|
nq.withoutStopwords,
|
||||||
|
nameNoStopwords,
|
||||||
|
);
|
||||||
|
if (fuzzyScore >= _config.fuzzyHighThreshold) {
|
||||||
|
// Scale score based on similarity (0.90-1.0 -> 40-45)
|
||||||
|
return _config.fuzzyMatchHigh +
|
||||||
|
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
|
||||||
|
}
|
||||||
|
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
|
||||||
|
return _config.fuzzyMatchMedium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 7: Token-level fuzzy (individual words)
|
||||||
|
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
|
||||||
|
nq.tokensNoStop,
|
||||||
|
_normalizer.tokenize(nameNoStopwords),
|
||||||
|
);
|
||||||
|
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
|
||||||
|
return _config.fuzzyMatchMedium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 8: N-gram partial matching
|
||||||
|
final ngramScore = _ngramMatcher.bigramSimilarity(
|
||||||
|
nq.withoutStopwords,
|
||||||
|
nameNoStopwords,
|
||||||
|
);
|
||||||
|
if (ngramScore >= _config.ngramThreshold) {
|
||||||
|
// Scale: 0.5-1.0 -> 25-35
|
||||||
|
return _config.ngramMatch + (ngramScore * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline: Music Assistant API returned it, so some relevance
|
||||||
|
return _config.baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if query matches at word boundary in text.
|
||||||
|
bool _matchesWordBoundary(String text, String query) {
|
||||||
|
if (query.contains(' ')) {
|
||||||
|
// Multi-word query: check if at start or after space
|
||||||
|
if (text.startsWith(query)) return true;
|
||||||
|
if (text.contains(' $query')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-word query: check if any word starts with query
|
||||||
|
final words = text.split(RegExp(r'\s+'));
|
||||||
|
for (final word in words) {
|
||||||
|
if (word.startsWith(query)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if text is contained within query (reverse matching).
|
||||||
|
///
|
||||||
|
/// Solves: "the ramones" query finding "Ramones" artist
|
||||||
|
bool _reverseContains(String query, String text) {
|
||||||
|
if (text.length < _config.minReverseMatchLength) return false;
|
||||||
|
|
||||||
|
// Direct containment
|
||||||
|
if (query.contains(text)) return true;
|
||||||
|
|
||||||
|
// Token-level: any query token equals text
|
||||||
|
final queryTokens = query.split(RegExp(r'\s+'));
|
||||||
|
for (final token in queryTokens) {
|
||||||
|
if (token.length >= _config.minReverseMatchLength && text == token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate secondary field bonuses based on media type.
|
||||||
|
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
|
||||||
|
double bonus = 0;
|
||||||
|
|
||||||
|
if (item is Album) {
|
||||||
|
bonus += _scoreArtistField(item.artistsString, nq);
|
||||||
|
} else if (item is Track) {
|
||||||
|
bonus += _scoreArtistField(item.artistsString, nq);
|
||||||
|
// Also check album name
|
||||||
|
if (item.album?.name != null) {
|
||||||
|
final albumLower = item.album!.name.toLowerCase();
|
||||||
|
if (albumLower.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.albumFieldBonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item is Audiobook) {
|
||||||
|
// Check authors
|
||||||
|
final authorLower = item.authorsString.toLowerCase();
|
||||||
|
if (authorLower == nq.withoutStopwords) {
|
||||||
|
bonus += _config.authorFieldExactBonus;
|
||||||
|
} else if (authorLower.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.authorFieldPartialBonus;
|
||||||
|
}
|
||||||
|
// Check narrators
|
||||||
|
final narratorLower = item.narratorsString.toLowerCase();
|
||||||
|
if (narratorLower.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.narratorFieldBonus;
|
||||||
|
}
|
||||||
|
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||||
|
bonus += _scorePodcastFields(item, nq);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score artist field for albums and tracks.
|
||||||
|
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
|
||||||
|
final artistLower = artistsString.toLowerCase();
|
||||||
|
if (artistLower == nq.withoutStopwords) {
|
||||||
|
return _config.artistFieldExactBonus;
|
||||||
|
}
|
||||||
|
if (artistLower.contains(nq.withoutStopwords)) {
|
||||||
|
return _config.artistFieldPartialBonus;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score podcast-specific fields (creator, description).
|
||||||
|
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
|
||||||
|
double bonus = 0;
|
||||||
|
final metadata = item.metadata;
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
// Check creator fields
|
||||||
|
final creatorFields = [
|
||||||
|
metadata['author'] as String?,
|
||||||
|
metadata['publisher'] as String?,
|
||||||
|
metadata['owner'] as String?,
|
||||||
|
metadata['creator'] as String?,
|
||||||
|
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
|
||||||
|
|
||||||
|
bool foundExact = false;
|
||||||
|
bool foundContains = false;
|
||||||
|
for (final field in creatorFields) {
|
||||||
|
if (field == nq.withoutStopwords) {
|
||||||
|
foundExact = true;
|
||||||
|
break;
|
||||||
|
} else if (field.contains(nq.withoutStopwords)) {
|
||||||
|
foundContains = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundExact) {
|
||||||
|
bonus += _config.creatorFieldExactBonus;
|
||||||
|
} else if (foundContains) {
|
||||||
|
bonus += _config.creatorFieldPartialBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check description
|
||||||
|
final description =
|
||||||
|
(metadata['description'] as String? ?? '').toLowerCase();
|
||||||
|
if (description.contains(nq.withoutStopwords)) {
|
||||||
|
bonus += _config.descriptionBonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: podcast name prominence check
|
||||||
|
if (bonus == 0) {
|
||||||
|
final nameLower = item.name.toLowerCase();
|
||||||
|
if (nameLower.contains(nq.withoutStopwords)) {
|
||||||
|
if (nq.withoutStopwords.contains(' ')) {
|
||||||
|
// Multi-word query prominence
|
||||||
|
final prominence = nq.withoutStopwords.length / nameLower.length;
|
||||||
|
if (prominence >= 0.5) {
|
||||||
|
bonus += _config.creatorFieldExactBonus;
|
||||||
|
} else if (prominence >= 0.3) {
|
||||||
|
bonus += _config.creatorFieldPartialBonus + 4;
|
||||||
|
} else {
|
||||||
|
bonus += _config.creatorFieldPartialBonus;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bonus += _config.descriptionBonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate library and favorite bonuses.
|
||||||
|
double _calculateBonuses(MediaItem item) {
|
||||||
|
double bonus = 0;
|
||||||
|
|
||||||
|
// Library bonus (Album has inLibrary property)
|
||||||
|
if (item is Album && item.inLibrary) {
|
||||||
|
bonus += _config.libraryBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite bonus
|
||||||
|
if (item.favorite == true) {
|
||||||
|
bonus += _config.favoriteBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bonus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/// Text normalization utilities for search scoring.
|
||||||
|
///
|
||||||
|
/// Handles query normalization, stopword removal, and tokenization.
|
||||||
|
|
||||||
|
/// Represents a normalized search query with various forms for matching.
|
||||||
|
class NormalizedQuery {
|
||||||
|
/// Original query as entered by user
|
||||||
|
final String original;
|
||||||
|
|
||||||
|
/// Lowercased and trimmed query
|
||||||
|
final String normalized;
|
||||||
|
|
||||||
|
/// Query with stopwords removed
|
||||||
|
final String withoutStopwords;
|
||||||
|
|
||||||
|
/// Individual tokens from normalized query
|
||||||
|
final List<String> tokens;
|
||||||
|
|
||||||
|
/// Tokens with stopwords removed
|
||||||
|
final List<String> tokensNoStop;
|
||||||
|
|
||||||
|
const NormalizedQuery({
|
||||||
|
required this.original,
|
||||||
|
required this.normalized,
|
||||||
|
required this.withoutStopwords,
|
||||||
|
required this.tokens,
|
||||||
|
required this.tokensNoStop,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the query is empty after normalization
|
||||||
|
bool get isEmpty => normalized.isEmpty;
|
||||||
|
|
||||||
|
/// Whether stopword removal changed the query
|
||||||
|
bool get hasStopwordsRemoved => normalized != withoutStopwords;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes text and queries for search matching.
|
||||||
|
class TextNormalizer {
|
||||||
|
/// Common English stopwords relevant to music/media searches.
|
||||||
|
///
|
||||||
|
/// Intentionally minimal to avoid over-filtering:
|
||||||
|
/// - Articles that commonly prefix artist/album names
|
||||||
|
/// - Common prepositions that add noise
|
||||||
|
static const Set<String> _stopwords = {
|
||||||
|
// Articles
|
||||||
|
'the',
|
||||||
|
'a',
|
||||||
|
'an',
|
||||||
|
// Common prepositions in music/media context
|
||||||
|
'of',
|
||||||
|
'and',
|
||||||
|
'in',
|
||||||
|
'on',
|
||||||
|
'at',
|
||||||
|
'to',
|
||||||
|
'for',
|
||||||
|
'with',
|
||||||
|
'by',
|
||||||
|
// Common noise words
|
||||||
|
'is',
|
||||||
|
'are',
|
||||||
|
'was',
|
||||||
|
'be',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Normalize a search query into multiple forms for matching.
|
||||||
|
///
|
||||||
|
/// Returns a [NormalizedQuery] containing:
|
||||||
|
/// - Original query
|
||||||
|
/// - Lowercased/trimmed version
|
||||||
|
/// - Version with stopwords removed
|
||||||
|
/// - Tokenized versions
|
||||||
|
NormalizedQuery normalizeQuery(String query) {
|
||||||
|
final normalized = query.toLowerCase().trim();
|
||||||
|
final tokens = tokenize(normalized);
|
||||||
|
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||||
|
|
||||||
|
// If all tokens were stopwords, preserve original tokens
|
||||||
|
// e.g., "The The" (band name) shouldn't become empty
|
||||||
|
final effectiveTokensNoStop =
|
||||||
|
tokensNoStop.isEmpty ? tokens : tokensNoStop;
|
||||||
|
final withoutStopwords = effectiveTokensNoStop.join(' ');
|
||||||
|
|
||||||
|
return NormalizedQuery(
|
||||||
|
original: query,
|
||||||
|
normalized: normalized,
|
||||||
|
withoutStopwords: withoutStopwords,
|
||||||
|
tokens: tokens,
|
||||||
|
tokensNoStop: effectiveTokensNoStop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize text for comparison (lowercase, trim).
|
||||||
|
String normalizeText(String text) {
|
||||||
|
return text.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize text with stopwords removed.
|
||||||
|
String normalizeTextNoStopwords(String text) {
|
||||||
|
final tokens = tokenize(text.toLowerCase().trim());
|
||||||
|
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||||
|
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split text into tokens on whitespace.
|
||||||
|
List<String> tokenize(String text) {
|
||||||
|
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a word is a stopword.
|
||||||
|
bool isStopword(String word) {
|
||||||
|
return _stopwords.contains(word.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.8.1-beta+38
|
version: 2.8.4-beta+42
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user