diff --git a/package-lock.json b/package-lock.json index 2301919..ee1ad2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 96eb5d9..3fc69cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/hooks/useArtists.ts b/src/hooks/useArtists.ts index 9bd90c5..e1f1840 100644 --- a/src/hooks/useArtists.ts +++ b/src/hooks/useArtists.ts @@ -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(); @@ -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, diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index f68d6dd..91ac39a 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -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, diff --git a/src/redux/queueSlice.ts b/src/redux/queueSlice.ts index d930c6d..34b87cd 100644 --- a/src/redux/queueSlice.ts +++ b/src/redux/queueSlice.ts @@ -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 = {}; diff --git a/src/utils/dataProcessing.ts b/src/utils/dataProcessing.ts index 3dc0845..cdf5e19 100644 --- a/src/utils/dataProcessing.ts +++ b/src/utils/dataProcessing.ts @@ -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 = ( @@ -18,8 +19,11 @@ export const filterDisabledSongs = (songs: Song[], disabledSongPaths: Set !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): 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; + if (!searchTerm.trim()) { + debugLog('šŸ“ No search term, returning all songs'); + return filteredSongs; + } - const terms = (searchTerm || '').toLowerCase().split(/\s+/).filter(term => term.length > 0); + // 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, + }; - if (terms.length === 0) return filteredSongs; + // Split search term into individual words for better matching + const searchWords = searchTerm.toLowerCase().split(/\s+/).filter(word => word.length >= 2); - return filteredSongs.filter(song => { - const songTitle = (song.title || '').toLowerCase(); - const songArtist = (song.artist || '').toLowerCase(); + 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 -> array of scores for each word + + searchWords.forEach((word, wordIndex) => { + debugLog(`\nšŸ”¤ Searching for word ${wordIndex + 1}: "${word}"`); - // If only one term, use OR logic (title OR artist) - if (terms.length === 1) { - return songTitle.includes(terms[0]) || songArtist.includes(terms[0]); + 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`); + } } - // If multiple terms, use AND logic (all terms must match somewhere) - return terms.every(term => - songTitle.includes(term) || songArtist.includes(term) - ); + // 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 -> 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): 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(); // 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(); // 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