Compare commits

...
10 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 6661375f99 chore: bump version to 2.8.4-beta+42
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:18:30 +00:00
Your NameandClaude Opus 4.5 d6e759f1cc fix: remove non-existent Podcast type check
Use mediaType enum check instead of type check since Podcast
is not a distinct class in the model hierarchy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:29:25 +00:00
Your NameandClaude Opus 4.5 8a38516715 feat: overhaul search scoring with fuzzy matching and stopword removal
Implements a modular search scoring system that fixes common search issues:

- Stopword removal: "the Ramones" now finds "Ramones"
- Reverse matching: Query containing result name gets scored
- Fuzzy matching: Jaro-Winkler algorithm handles typos ("beetles" -> "Beatles")
- N-gram matching: Bigrams enable partial string matches

New scoring tiers (highest to lowest):
- Exact match (100)
- Exact after stopword removal (95)
- Starts with (80-85)
- Word boundary (65-70)
- Reverse contains (55-60)
- Contains (45-55)
- Fuzzy high confidence (40-45)
- Fuzzy medium confidence (35)
- N-gram partial (25-35)
- Baseline (20)

New files:
- lib/utils/search/scoring_config.dart - Configurable weights
- lib/utils/search/text_normalizer.dart - Stopword removal
- lib/utils/search/fuzzy_matcher.dart - Jaro-Winkler implementation
- lib/utils/search/ngram_matcher.dart - Bigram matching
- lib/utils/search/search_scoring.dart - Main scorer class

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:18:54 +00:00
Your NameandClaude Opus 4.5 aaeb2109f6 chore: remove debug logging for radio metadata
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:55:58 +00:00
Your NameandClaude Opus 4.5 f65e76e858 fix: prevent radio metadata downgrade from bad events
- Remove faulty existingTrack.name.contains(' - ') check that
  incorrectly marked good metadata as not-proper
- Add protection for malformed titles starting with '- '
- Add debug logging for radio metadata quality comparison

The old check was meant to detect unparsed "Artist - Title" format
but it also blocked legitimate titles like "Jazzy French - La musique
revient vite..." from being recognized as proper metadata.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:31:49 +00:00
Your NameandClaude Opus 4.5 252395d164 Fix _currentTrack not updating for radio streams
The cache was being updated correctly with stream metadata, but
_currentTrack (which the UI displays) was only updated for podcasts.

Now also updates _currentTrack for radio when stream metadata arrives
(detected by artist changing from Unknown to actual value).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:10:24 +00:00
Your NameandClaude Opus 4.5 f36d108878 Fix radio stream metadata not displaying
For radio streams, MA sends now-playing info in `stream_title` field
(e.g., "Jazzy French - La musique revient vite...") while `title` field
contains the station name (e.g., "Jazz Radio Jazzy French").

Previously, stream_title was only used if title was "Unknown Track".
Now we always prefer stream_title for radio streams, parsing the
"Artist - Title" format properly.

Bump version to 2.8.4-beta+41

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:54:14 +00:00
Your NameandClaude Opus 4.5 8e548daf0c Fix black screen bug when Material You theme enabled (#37)
Override Material You ColorScheme backgrounds with app's preferred colors.
Material You generates unpredictable background values that could cause
black screen or inconsistent navigation bar colors.

- Force dark background to 0xFF1a1a1a
- Force dark surface to 0xFF2a2a2a
- Force light background to 0xFFFAFAFA

Bump version to 2.8.3-beta+40

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:27:46 +00:00
Your NameandClaude Opus 4.5 b15f10ade9 Bump version to 2.8.2-beta+39
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:18:41 +00:00
Your NameandClaude Opus 4.5 b9c7bc0e2a Fix Tailscale auth detection (issue #38)
Add .ts.net to _isLocalIpAddress() check so Tailscale URLs get the same
redirect-safe HTTP handling as local IPs. This fixes auth detection
failing to show username/password fields for Tailscale connections.

Fixes #38

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:03:17 +00:00
10 changed files with 837 additions and 171 deletions
+10 -3
View File
@@ -229,10 +229,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
ColorScheme? darkColorScheme;
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!;
lightColorScheme = light;
darkColorScheme = dark;
lightColorScheme = light.copyWith(
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 {
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
+35 -14
View File
@@ -2416,17 +2416,22 @@ class MusicAssistantProvider with ChangeNotifier {
_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?;
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artistName = parts[0].trim();
// If trackTitle is still the default or matches the stream_title, update it too
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
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
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 &&
existingTrack.artistsString != 'Unknown Artist' &&
!existingTrack.name.contains(' - ');
existingArtist != null &&
existingArtist != 'Unknown Artist' &&
existingArtist.trim().isNotEmpty;
final existingHasImage = existingTrack?.metadata?['images'] != null;
final newHasImage = metadata != null;
final newHasAlbum = albumName != 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)
// 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
// 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 &&
playerId == _selectedPlayer!.playerId &&
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
(_currentTrack == null ||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
if (shouldUpdateCurrentTrack) {
_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();
@@ -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?;
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
if (streamTitle != null && streamTitle.isNotEmpty) {
if (streamTitle.contains(' - ')) {
// Parse "Artist - Title" format
final parts = streamTitle.split(' - ');
if (parts.length >= 2) {
artist = parts[0].trim();
if (title == 'Unknown Track' || title == streamTitle) {
title = parts.sublist(1).join(' - ').trim();
}
} else {
// No separator, use stream_title as the title
title = streamTitle;
}
}
}
+17 -146
View File
@@ -15,6 +15,7 @@ import '../widgets/artist_avatar.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../utils/search/search_scoring.dart';
import 'album_details_screen.dart';
import 'artist_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
List<String>? _cachedAvailableFilters;
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
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) {
final queryLower = query.toLowerCase().trim();
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;
return _searchScorer.scoreItem(item, query);
}
/// 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 [];
final queryLower = query.toLowerCase();
final queryWords = queryLower.split(RegExp(r'\s+'));
// Use normalizer to get tokens with stopwords removed
// 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
final existingArtistKeys = <String>{};
@@ -942,6 +812,7 @@ class SearchScreenState extends State<SearchScreen> {
}
// Filter candidates: only include if artist name contains any query word
// (stopwords already removed from queryWords)
final crossRefArtists = <Artist>[];
for (final artist in candidateArtists.values) {
final artistLower = artist.name.toLowerCase();
+2 -1
View File
@@ -100,7 +100,8 @@ class AuthManager {
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
+137
View File
@@ -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;
}
}
+96
View File
@@ -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;
}
}
+86
View File
@@ -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();
}
+333
View File
@@ -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;
}
}
+114
View File
@@ -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
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.8.1-beta+38
version: 2.8.4-beta+42
environment:
sdk: '>=3.0.0 <4.0.0'