Signed-off-by: mbrucedogs <mbrucedogs@gmail.com>

This commit is contained in:
mbrucedogs 2025-07-24 21:31:33 -05:00
parent 683050f271
commit 8d19ff39a7
6 changed files with 313 additions and 28 deletions

15
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@tailwindcss/postcss": "^4.1.11",
"@types/react-router-dom": "^5.3.3",
"firebase": "^11.10.0",
"fuse.js": "^7.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
@ -3336,6 +3337,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -6841,6 +6851,11 @@
"dev": true,
"optional": true
},
"fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",

View File

@ -19,6 +19,7 @@
"@tailwindcss/postcss": "^4.1.11",
"@types/react-router-dom": "^5.3.3",
"firebase": "^11.10.0",
"fuse.js": "^7.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",

View File

@ -1,7 +1,8 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useAppSelector, selectArtistsArray, selectSongsArray } from '../redux';
import { useActions } from './useActions';
import { usePaginatedData } from './index';
import { filterArtists } from '../utils/dataProcessing';
import type { Song } from '../types';
export const useArtists = () => {
@ -9,6 +10,9 @@ export const useArtists = () => {
const allSongs = useAppSelector(selectSongsArray);
const { handleAddToQueue, handleToggleFavorite } = useActions();
// Manage search term locally
const [searchTerm, setSearchTerm] = useState('');
// Pre-compute songs by artist and song counts for performance
const songsByArtist = useMemo(() => {
const songsMap = new Map<string, Song[]>();
@ -27,8 +31,13 @@ export const useArtists = () => {
return { songsMap, countsMap };
}, [allSongs]);
// Use the composable pagination hook
const pagination = usePaginatedData(allArtists, {
// Apply fuzzy search to artists
const filteredArtists = useMemo(() => {
return filterArtists(allArtists, searchTerm);
}, [allArtists, searchTerm]);
// Use the composable pagination hook with fuzzy-filtered results
const pagination = usePaginatedData(filteredArtists, {
itemsPerPage: 20 // Default pagination size
});
@ -42,15 +51,21 @@ export const useArtists = () => {
return songsByArtist.countsMap.get((artistName || '').toLowerCase()) || 0;
}, [songsByArtist.countsMap]);
// Handle search term changes
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
pagination.resetPage && pagination.resetPage(); // Reset to first page on new search
}, [pagination]);
return {
artists: pagination.items,
allArtists: pagination.searchTerm ? pagination.items : allArtists,
searchTerm: pagination.searchTerm,
allArtists: searchTerm ? pagination.items : allArtists,
searchTerm,
hasMore: pagination.hasMore,
loadMore: pagination.loadMore,
currentPage: pagination.currentPage,
totalPages: pagination.totalPages,
handleSearchChange: pagination.setSearchTerm,
handleSearchChange,
getSongsByArtist,
getSongCountByArtist,
handleAddToQueue,

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useActions } from './useActions';
import { useFilteredSongs, usePaginatedData } from './index';
import { UI_CONSTANTS } from '../constants';
@ -6,20 +6,25 @@ import { UI_CONSTANTS } from '../constants';
export const useSearch = () => {
const { handleAddToQueue, handleToggleFavorite, handleToggleDisabled, isSongDisabled } = useActions();
// Use the composable filtered songs hook
// Manage search term locally
const [searchTerm, setSearchTerm] = useState('');
// Use the composable filtered songs hook, passing the search term
const { songs: filteredSongs, disabledSongsLoading } = useFilteredSongs({
searchTerm,
context: 'useSearch'
});
// Use the composable pagination hook
// Use the composable pagination hook (no search term here, just paginates filtered results)
const pagination = usePaginatedData(filteredSongs, {
itemsPerPage: UI_CONSTANTS.PAGINATION.ITEMS_PER_PAGE
});
// Update search term and reset pagination when search changes
const handleSearchChange = useCallback((value: string) => {
// Only search if the term meets minimum length requirement
if (value.length >= UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH || value.length === 0) {
pagination.setSearchTerm(value);
setSearchTerm(value);
pagination.resetPage && pagination.resetPage(); // Optional: reset to first page on new search
}
}, [pagination]);
@ -33,7 +38,7 @@ export const useSearch = () => {
};
return {
searchTerm: pagination.searchTerm,
searchTerm,
searchResults,
handleSearchChange,
handleAddToQueue,

View File

@ -175,7 +175,6 @@ const queueSlice = createSlice({
.addCase(removeFromQueue.fulfilled, (state, action) => {
state.loading = false;
const { key } = action.payload;
console.log('removeFromQueue.fulfilled - removing key:', key);
// Clear the queue state - the real-time sync will update it with the new data
state.data = {};
@ -212,7 +211,6 @@ const queueSlice = createSlice({
.addCase(reorderQueueAsync.fulfilled, (state, action) => {
state.loading = false;
const { updates } = action.payload;
console.log('reorderQueueAsync.fulfilled - updates:', updates);
// Clear the queue state - the real-time sync will update it with the new data
state.data = {};

View File

@ -1,5 +1,6 @@
import { debugLog } from './logger';
import type { Song, QueueItem, TopPlayed } from '../types';
import Fuse from 'fuse.js';
// Convert Firebase object to array with keys
export const objectToArray = <T extends { key?: string }>(
@ -18,8 +19,11 @@ export const filterDisabledSongs = (songs: Song[], disabledSongPaths: Set<string
return songs.filter(song => !disabledSongPaths.has(song.path));
};
// Filter songs by search term with intelligent multi-word handling
// Filter songs by search term with FUZZY MATCHING using Fuse.js (90% threshold)
// TODO: REVERT - This is the new fuzzy matching implementation. To revert, replace with the old filterSongs function below
export const filterSongs = (songs: Song[], searchTerm: string, disabledSongPaths?: Set<string>): Song[] => {
debugLog('🚀 FILTER SONGS CALLED with:', { searchTerm, songsCount: songs.length });
let filteredSongs = songs;
// First filter out disabled songs if disabledSongPaths is provided
@ -27,26 +31,273 @@ export const filterSongs = (songs: Song[], searchTerm: string, disabledSongPaths
filteredSongs = filterDisabledSongs(songs, disabledSongPaths);
}
if (!searchTerm.trim()) return filteredSongs;
const terms = (searchTerm || '').toLowerCase().split(/\s+/).filter(term => term.length > 0);
if (terms.length === 0) return filteredSongs;
return filteredSongs.filter(song => {
const songTitle = (song.title || '').toLowerCase();
const songArtist = (song.artist || '').toLowerCase();
// If only one term, use OR logic (title OR artist)
if (terms.length === 1) {
return songTitle.includes(terms[0]) || songArtist.includes(terms[0]);
if (!searchTerm.trim()) {
debugLog('📝 No search term, returning all songs');
return filteredSongs;
}
// If multiple terms, use AND logic (all terms must match somewhere)
return terms.every(term =>
songTitle.includes(term) || songArtist.includes(term)
);
// Configure Fuse.js for fuzzy matching with 90% threshold
// Note: Fuse.js threshold is 0.0 (exact) to 1.0 (very loose), so 0.1 = 90% similarity
const fuseOptions = {
keys: ['title', 'artist'],
threshold: 0.2, // 80% similarity threshold (more reasonable)
includeScore: true,
includeMatches: false,
minMatchCharLength: 2, // Allow shorter matches
shouldSort: true,
findAllMatches: true,
location: 0,
distance: 100, // More reasonable distance
useExtendedSearch: false,
ignoreLocation: true, // Allow words anywhere in the text
ignoreFieldNorm: false,
};
// Split search term into individual words for better matching
const searchWords = searchTerm.toLowerCase().split(/\s+/).filter(word => word.length >= 2);
debugLog('🔍 FUZZY SEARCH DEBUG:', {
originalSearchTerm: searchTerm,
searchWords: searchWords,
totalSongsToSearch: filteredSongs.length,
firstFewSongs: filteredSongs.slice(0, 3).map(s => `${s.artist} - ${s.title}`)
});
if (searchWords.length === 0) {
debugLog('❌ No search words found, returning all songs');
return filteredSongs;
}
// Search for each word individually and find songs that contain ALL words
const songsWithAllWords = new Map<Song, number[]>(); // Song -> array of scores for each word
searchWords.forEach((word, wordIndex) => {
debugLog(`\n🔤 Searching for word ${wordIndex + 1}: "${word}"`);
const fuse = new Fuse(filteredSongs, fuseOptions);
const wordResults = fuse.search(word);
debugLog(` Found ${wordResults.length} matches for "${word}":`);
if (wordResults.length === 0) {
debugLog(` ❌ No matches found for "${word}"`);
} else {
wordResults.slice(0, 5).forEach((result, resultIndex) => {
const song = result.item;
const score = result.score || 1;
const similarity = Math.round((1 - score) * 100);
debugLog(` ${resultIndex + 1}. "${song.artist} - ${song.title}" (Score: ${score.toFixed(3)}, ${similarity}% match)`);
});
if (wordResults.length > 5) {
debugLog(` ... and ${wordResults.length - 5} more results`);
}
}
// Add songs that match this word to our tracking map
wordResults.forEach(result => {
const song = result.item;
const score = result.score || 1;
if (!songsWithAllWords.has(song)) {
songsWithAllWords.set(song, new Array(searchWords.length).fill(1)); // Initialize with worst scores
}
// Store the score for this word
songsWithAllWords.get(song)![wordIndex] = score;
});
});
// Only keep songs that have ALL words (no missing words)
const allResults = new Map<Song, number>(); // Song -> best score
songsWithAllWords.forEach((scores, song) => {
// Check if this song has all words (no missing words with score 1)
const hasAllWords = scores.every(score => score < 1);
if (hasAllWords) {
// Use the best (lowest) score for ranking
const bestScore = Math.min(...scores);
allResults.set(song, bestScore);
}
});
// Convert back to array and sort by score
const fuzzyFilteredSongs = Array.from(allResults.entries())
.sort(([, scoreA], [, scoreB]) => scoreA - scoreB)
.map(([song]) => song);
debugLog('\n🎯 FINAL COMBINED RESULTS:');
debugLog(` Total unique songs found: ${fuzzyFilteredSongs.length}`);
debugLog(' Top 10 results:');
fuzzyFilteredSongs.slice(0, 10).forEach((song, index) => {
const score = allResults.get(song) || 1;
const similarity = Math.round((1 - score) * 100);
debugLog(` ${index + 1}. "${song.artist} - ${song.title}" (Best score: ${score.toFixed(3)}, ${similarity}% match)`);
});
if (fuzzyFilteredSongs.length > 10) {
debugLog(` ... and ${fuzzyFilteredSongs.length - 10} more results`);
}
debugLog('Fuzzy search results:', {
searchTerm,
searchWords,
totalSongs: filteredSongs.length,
fuzzyResults: fuzzyFilteredSongs.length,
firstFewResults: fuzzyFilteredSongs.slice(0, 3).map(s => `${s.artist} - ${s.title}`)
});
return fuzzyFilteredSongs;
};
// OLD IMPLEMENTATION (for easy revert):
// export const filterSongs = (songs: Song[], searchTerm: string, disabledSongPaths?: Set<string>): Song[] => {
// let filteredSongs = songs;
//
// // First filter out disabled songs if disabledSongPaths is provided
// if (disabledSongPaths) {
// filteredSongs = filterDisabledSongs(songs, disabledSongPaths);
// }
//
// if (!searchTerm.trim()) return filteredSongs;
//
// const terms = (searchTerm || '').toLowerCase().split(/\s+/).filter(term => term.length > 0);
//
// if (terms.length === 0) return filteredSongs;
//
// return filteredSongs.filter(song => {
// const songTitle = (song.title || '').toLowerCase();
// const songArtist = (song.artist || '').toLowerCase();
//
// // If only one term, use OR logic (title OR artist)
// if (terms.length === 1) {
// return songTitle.includes(terms[0]) || songArtist.includes(terms[0]);
// }
//
// // If multiple terms, use AND logic (all terms must match somewhere)
// return terms.every(term =>
// songTitle.includes(term) || songArtist.includes(term)
// );
// });
// };
// Filter artists by search term with FUZZY MATCHING using Fuse.js
export const filterArtists = (artists: string[], searchTerm: string): string[] => {
debugLog('🎤 ARTIST SEARCH CALLED with:', { searchTerm, artistsCount: artists.length });
if (!searchTerm.trim()) {
debugLog('📝 No search term, returning all artists');
return artists;
}
// Configure Fuse.js for fuzzy matching artists
const fuseOptions = {
threshold: 0.2, // 80% similarity threshold
includeScore: true,
includeMatches: false,
minMatchCharLength: 2,
shouldSort: true,
findAllMatches: true,
location: 0,
distance: 100,
useExtendedSearch: false,
ignoreLocation: true,
ignoreFieldNorm: false,
};
// Split search term into individual words
const searchWords = searchTerm.toLowerCase().split(/\s+/).filter(word => word.length >= 2);
debugLog('🔍 ARTIST FUZZY SEARCH DEBUG:', {
originalSearchTerm: searchTerm,
searchWords: searchWords,
totalArtistsToSearch: artists.length,
firstFewArtists: artists.slice(0, 3)
});
if (searchWords.length === 0) {
debugLog('❌ No search words found, returning all artists');
return artists;
}
// Search for each word individually and find artists that contain ALL words
const artistsWithAllWords = new Map<string, number[]>(); // Artist -> array of scores for each word
searchWords.forEach((word, wordIndex) => {
debugLog(`\n🔤 Searching for artist word ${wordIndex + 1}: "${word}"`);
const fuse = new Fuse(artists, fuseOptions);
const wordResults = fuse.search(word);
debugLog(` Found ${wordResults.length} artist matches for "${word}":`);
if (wordResults.length === 0) {
debugLog(` ❌ No artist matches found for "${word}"`);
} else {
wordResults.slice(0, 5).forEach((result, resultIndex) => {
const artist = result.item;
const score = result.score || 1;
const similarity = Math.round((1 - score) * 100);
debugLog(` ${resultIndex + 1}. "${artist}" (Score: ${score.toFixed(3)}, ${similarity}% match)`);
});
if (wordResults.length > 5) {
debugLog(` ... and ${wordResults.length - 5} more results`);
}
}
// Add artists that match this word to our tracking map
wordResults.forEach(result => {
const artist = result.item;
const score = result.score || 1;
if (!artistsWithAllWords.has(artist)) {
artistsWithAllWords.set(artist, new Array(searchWords.length).fill(1)); // Initialize with worst scores
}
// Store the score for this word
artistsWithAllWords.get(artist)![wordIndex] = score;
});
});
// Only keep artists that have ALL words (no missing words)
const allResults = new Map<string, number>(); // Artist -> best score
artistsWithAllWords.forEach((scores, artist) => {
// Check if this artist has all words (no missing words with score 1)
const hasAllWords = scores.every(score => score < 1);
if (hasAllWords) {
// Use the best (lowest) score for ranking
const bestScore = Math.min(...scores);
allResults.set(artist, bestScore);
}
});
// Convert back to array and sort by score
const fuzzyFilteredArtists = Array.from(allResults.entries())
.sort(([, scoreA], [, scoreB]) => scoreA - scoreB)
.map(([artist]) => artist);
debugLog('\n🎯 ARTIST FINAL COMBINED RESULTS:');
debugLog(` Total unique artists found: ${fuzzyFilteredArtists.length}`);
debugLog(' Top 10 results:');
fuzzyFilteredArtists.slice(0, 10).forEach((artist, index) => {
const score = allResults.get(artist) || 1;
const similarity = Math.round((1 - score) * 100);
debugLog(` ${index + 1}. "${artist}" (Best score: ${score.toFixed(3)}, ${similarity}% match)`);
});
if (fuzzyFilteredArtists.length > 10) {
debugLog(` ... and ${fuzzyFilteredArtists.length - 10} more results`);
}
return fuzzyFilteredArtists;
};
// Sort queue items by order